Files
Dungeons-Ground/apps/web/composables/useDngAuth.ts
pavel444-byte 438e5af0ad
Some checks failed
CI / validate (push) Failing after 9m21s
Important Fixes, New mechanics, and many more
2026-08-15 17:37:57 +05:00

189 lines
5.7 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
}
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 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 || hydrated.value) 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()
}
}
hydrated.value = true
return session.value
}
async function requestMagicLink(email: string) {
const redirectTo = `${window.location.origin}/auth/callback`
await $fetch(`${config.public.supabaseUrl}/auth/v1/otp?redirect_to=${encodeURIComponent(redirectTo)}`, {
method: 'POST',
headers: authHeaders(),
body: { email: email.trim().toLowerCase(), create_user: true },
})
}
async function signInAnonymously() {
const response = await $fetch<AuthResponse>(`${config.public.supabaseUrl}/auth/v1/signup`, {
method: 'POST',
headers: authHeaders(),
body: {
data: { display_name: 'Guest Adventurer' },
gotrue_meta_security: {},
},
})
const next = fromResponse(response)
persist(next)
hydrated.value = true
return next
}
async function accessToken() {
await restore()
if (!session.value) throw new Error('Enter the alpha 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, requestMagicLink, signInAnonymously, accessToken, signOut, invalidate }
}