Files
Dungeons-Ground/apps/web/composables/useDngAuth.ts
pavel444-byte 98d65bcde9
All checks were successful
CI / validate (push) Successful in 19m38s
fix(web): route avatar to profile management
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 20:11:43 +05:00

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 }
}