All checks were successful
CI / validate (push) Successful in 19m38s
Co-authored-by: multica-agent <github@multica.ai>
281 lines
9.3 KiB
TypeScript
281 lines
9.3 KiB
TypeScript
interface DngAuthUser {
|
|
id: string
|
|
email?: string | null
|
|
is_anonymous?: boolean
|
|
user_metadata?: Record<string, unknown>
|
|
}
|
|
|
|
interface DngSession {
|
|
accessToken: string
|
|
refreshToken: string
|
|
expiresAt: number
|
|
user: DngAuthUser
|
|
}
|
|
|
|
interface AuthResponse {
|
|
access_token: string
|
|
refresh_token: string
|
|
expires_in: number
|
|
user: DngAuthUser
|
|
}
|
|
|
|
interface SignUpResponse {
|
|
access_token?: string | null
|
|
refresh_token?: string | null
|
|
expires_in?: number | null
|
|
user: DngAuthUser
|
|
}
|
|
|
|
const STORAGE_KEY = 'dng-auth-session'
|
|
|
|
export function useDngAuth() {
|
|
const config = useRuntimeConfig()
|
|
const session = useState<DngSession | null>('dng-auth-session', () => null)
|
|
const hydrated = useState('dng-auth-hydrated', () => false)
|
|
|
|
function authHeaders(accessToken?: string) {
|
|
return {
|
|
apikey: config.public.supabaseAnonKey,
|
|
'Content-Type': 'application/json',
|
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
}
|
|
}
|
|
|
|
function persist(next: DngSession | null) {
|
|
session.value = next
|
|
if (!import.meta.client) return
|
|
if (next) localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
|
else localStorage.removeItem(STORAGE_KEY)
|
|
}
|
|
|
|
function normalizeUser(user: DngAuthUser): DngAuthUser {
|
|
return {
|
|
...user,
|
|
email: user.email || undefined,
|
|
}
|
|
}
|
|
|
|
function isEmailAccount(user: DngAuthUser | undefined): boolean {
|
|
return Boolean(user?.email && !user.is_anonymous)
|
|
}
|
|
|
|
function parseStoredSession(value: string): DngSession | null {
|
|
try {
|
|
const saved = JSON.parse(value) as Partial<DngSession>
|
|
if (
|
|
typeof saved.accessToken !== 'string' || !saved.accessToken
|
|
|| typeof saved.refreshToken !== 'string' || !saved.refreshToken
|
|
|| typeof saved.expiresAt !== 'number' || !Number.isFinite(saved.expiresAt)
|
|
|| !saved.user || typeof saved.user.id !== 'string' || !saved.user.id
|
|
) return null
|
|
return { ...saved, user: normalizeUser(saved.user) } as DngSession
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function fetchUser(accessToken: string) {
|
|
const user = await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
|
|
headers: authHeaders(accessToken),
|
|
})
|
|
return normalizeUser(user)
|
|
}
|
|
|
|
function fromResponse(response: AuthResponse): DngSession {
|
|
return {
|
|
accessToken: response.access_token,
|
|
refreshToken: response.refresh_token,
|
|
expiresAt: Date.now() + response.expires_in * 1000,
|
|
user: normalizeUser(response.user),
|
|
}
|
|
}
|
|
|
|
async function refresh() {
|
|
if (!session.value?.refreshToken) return null
|
|
try {
|
|
const response = await $fetch<AuthResponse>(`${config.public.supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
body: { refresh_token: session.value.refreshToken },
|
|
})
|
|
const next = fromResponse(response)
|
|
persist(next)
|
|
return next
|
|
} catch {
|
|
persist(null)
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function restore() {
|
|
if (!import.meta.client) return session.value
|
|
if (hydrated.value) {
|
|
if (session.value && !isEmailAccount(session.value.user)) persist(null)
|
|
return session.value
|
|
}
|
|
|
|
let restoredFromStorage = false
|
|
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ''))
|
|
const hashAccessToken = hash.get('access_token')
|
|
const hashRefreshToken = hash.get('refresh_token')
|
|
if (hashAccessToken && hashRefreshToken) {
|
|
const expiresIn = Number(hash.get('expires_in') ?? 3600)
|
|
const user = await fetchUser(hashAccessToken)
|
|
persist({ accessToken: hashAccessToken, refreshToken: hashRefreshToken, expiresAt: Date.now() + expiresIn * 1000, user })
|
|
history.replaceState(null, '', `${window.location.pathname}${window.location.search}`)
|
|
} else {
|
|
const saved = localStorage.getItem(STORAGE_KEY)
|
|
if (saved) {
|
|
const parsed = parseStoredSession(saved)
|
|
if (parsed) {
|
|
session.value = parsed
|
|
restoredFromStorage = true
|
|
} else {
|
|
persist(null)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (session.value && session.value.expiresAt <= Date.now() + 60_000) {
|
|
await refresh()
|
|
} else if (session.value && restoredFromStorage) {
|
|
try {
|
|
const user = await fetchUser(session.value.accessToken)
|
|
persist({ ...session.value, user })
|
|
} catch {
|
|
// A saved token may belong to an old Supabase project or have been
|
|
// revoked before its local expiry. Refresh once, then discard it.
|
|
await refresh()
|
|
}
|
|
}
|
|
if (session.value && !isEmailAccount(session.value.user)) persist(null)
|
|
hydrated.value = true
|
|
return session.value
|
|
}
|
|
|
|
async function signUp(displayName: string, email: string, password: string) {
|
|
const redirectTo = `${window.location.origin}/auth/callback`
|
|
const response = await $fetch<SignUpResponse>(`${config.public.supabaseUrl}/auth/v1/signup?redirect_to=${encodeURIComponent(redirectTo)}`, {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
body: {
|
|
email: email.trim().toLowerCase(),
|
|
password,
|
|
data: { display_name: displayName.trim() },
|
|
},
|
|
})
|
|
if (response.access_token && response.refresh_token && response.expires_in) {
|
|
const next = fromResponse({
|
|
access_token: response.access_token,
|
|
refresh_token: response.refresh_token,
|
|
expires_in: response.expires_in,
|
|
user: response.user,
|
|
})
|
|
persist(next)
|
|
hydrated.value = true
|
|
return { session: next, needsEmailConfirmation: false }
|
|
}
|
|
return { session: null, needsEmailConfirmation: true }
|
|
}
|
|
|
|
async function signIn(email: string, password: string) {
|
|
const response = await $fetch<AuthResponse>(`${config.public.supabaseUrl}/auth/v1/token?grant_type=password`, {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
body: { email: email.trim().toLowerCase(), password },
|
|
})
|
|
const next = fromResponse(response)
|
|
if (!isEmailAccount(next.user)) throw new Error('An email account is required.')
|
|
persist(next)
|
|
hydrated.value = true
|
|
return next
|
|
}
|
|
|
|
async function requestPasswordReset(email: string) {
|
|
const redirectTo = `${window.location.origin}/auth/reset-password`
|
|
await $fetch(`${config.public.supabaseUrl}/auth/v1/recover?redirect_to=${encodeURIComponent(redirectTo)}`, {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
body: { email: email.trim().toLowerCase() },
|
|
})
|
|
}
|
|
|
|
async function updatePassword(password: string) {
|
|
const token = await accessToken()
|
|
const user = normalizeUser(await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
|
|
method: 'PUT',
|
|
headers: authHeaders(token),
|
|
body: { password },
|
|
}))
|
|
if (session.value) persist({ ...session.value, user })
|
|
return user
|
|
}
|
|
|
|
async function updateProfile(displayName: string) {
|
|
const nextDisplayName = displayName.trim()
|
|
if (nextDisplayName.length < 2 || nextDisplayName.length > 80) {
|
|
throw new Error('Display name must contain between 2 and 80 characters.')
|
|
}
|
|
|
|
const token = await accessToken()
|
|
if (!session.value) throw new Error('Enter the alpha to continue.')
|
|
const activeSession = session.value
|
|
|
|
await $fetch(`${config.public.supabaseUrl}/rest/v1/profiles?id=eq.${encodeURIComponent(activeSession.user.id)}`, {
|
|
method: 'PATCH',
|
|
headers: { ...authHeaders(token), Prefer: 'return=minimal' },
|
|
body: { display_name: nextDisplayName },
|
|
})
|
|
|
|
try {
|
|
const user = normalizeUser(await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
|
|
method: 'PUT',
|
|
headers: authHeaders(token),
|
|
body: { data: { display_name: nextDisplayName } },
|
|
}))
|
|
persist({ ...activeSession, user })
|
|
return user
|
|
} catch (cause) {
|
|
const previousDisplayName = activeSession.user.user_metadata?.display_name
|
|
const rollbackDisplayName = typeof previousDisplayName === 'string' && previousDisplayName.trim()
|
|
? previousDisplayName.trim()
|
|
: activeSession.user.email?.split('@')[0] || 'Adventurer'
|
|
await $fetch(`${config.public.supabaseUrl}/rest/v1/profiles?id=eq.${encodeURIComponent(activeSession.user.id)}`, {
|
|
method: 'PATCH',
|
|
headers: { ...authHeaders(token), Prefer: 'return=minimal' },
|
|
body: { display_name: rollbackDisplayName },
|
|
}).catch(() => undefined)
|
|
throw cause
|
|
}
|
|
}
|
|
|
|
async function accessToken() {
|
|
await restore()
|
|
if (!session.value) throw new Error('Enter the alpha to continue.')
|
|
if (!isEmailAccount(session.value.user)) {
|
|
persist(null)
|
|
throw new Error('Sign in with an email account to continue.')
|
|
}
|
|
if (session.value.expiresAt <= Date.now() + 60_000) await refresh()
|
|
if (!session.value) throw new Error('Your session expired. Sign in again.')
|
|
return session.value.accessToken
|
|
}
|
|
|
|
async function signOut() {
|
|
const token = session.value?.accessToken
|
|
persist(null)
|
|
if (!token) return
|
|
await $fetch(`${config.public.supabaseUrl}/auth/v1/logout`, {
|
|
method: 'POST',
|
|
headers: authHeaders(token),
|
|
}).catch(() => undefined)
|
|
}
|
|
|
|
function invalidate() {
|
|
persist(null)
|
|
hydrated.value = true
|
|
}
|
|
|
|
return { session, hydrated, restore, refresh, signUp, signIn, requestPasswordReset, updatePassword, updateProfile, accessToken, signOut, invalidate }
|
|
}
|