interface DngAuthUser { id: string email?: string | null is_anonymous?: boolean user_metadata?: Record } 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('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 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(`${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(`${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(`${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(`${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(`${config.public.supabaseUrl}/auth/v1/user`, { method: 'PUT', headers: authHeaders(token), body: { password }, })) if (session.value) persist({ ...session.value, user }) return user } 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, accessToken, signOut, invalidate } }