Files
Dungeons-Ground/apps/web/composables/useDngAuth.ts
pavel444-byte 41f4633dca
Some checks failed
CI / validate (push) Successful in 19m25s
CI / validate (pull_request) Failing after 9m54s
feat(web): add profile management
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 19:24:50 +05:00

316 lines
9.9 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 updateUserMetadata(data: Record<string, unknown>) {
const token = await accessToken()
const user = normalizeUser(await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
method: 'PUT',
headers: authHeaders(token),
body: { data },
}))
if (session.value) persist({ ...session.value, user })
return user
}
async function avatarStorageRequest(path: string, options: RequestInit) {
const token = await accessToken()
const baseUrl = String(config.public.supabaseUrl).replace(/\/$/, '')
const response = await fetch(`${baseUrl}/storage/v1/${path}`, {
...options,
headers: {
apikey: config.public.supabaseAnonKey,
Authorization: `Bearer ${token}`,
...options.headers,
},
})
if (!response.ok) {
const payload = await response.json().catch(() => null) as { message?: string; error?: string } | null
throw new Error(payload?.message || payload?.error || `Profile picture request failed (${response.status}).`)
}
}
async function uploadAvatar(file: File) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
throw new Error('Choose a JPG, PNG, or WebP image.')
}
if (file.size > 2 * 1024 * 1024) throw new Error('Profile pictures must be 2 MB or smaller.')
await restore()
if (!session.value) throw new Error('Sign in to update your profile picture.')
const path = `${session.value.user.id}/avatar`
const body = new FormData()
body.append('cacheControl', '3600')
body.append('', file)
await avatarStorageRequest(`object/profile-avatars/${path}`, {
method: 'POST',
headers: { 'x-upsert': 'true' },
body,
})
return path
}
async function removeAvatar() {
await restore()
if (!session.value) throw new Error('Sign in to update your profile picture.')
const path = `${session.value.user.id}/avatar`
await avatarStorageRequest('object/profile-avatars', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefixes: [path] }),
})
}
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,
updateUserMetadata,
uploadAvatar,
removeAvatar,
accessToken,
signOut,
invalidate,
}
}