Important Fixes, New mechanics, and many more
Some checks failed
CI / validate (push) Failing after 9m21s
Some checks failed
CI / validate (push) Failing after 9m21s
This commit is contained in:
188
apps/web/composables/useDngAuth.ts
Normal file
188
apps/web/composables/useDngAuth.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user