import type { BetterAuthClientOptions, InferSessionFromClient, InferUserFromClient, User } from 'better-auth/client'; import { twoFactorClient } from 'better-auth/client/plugins'; import { createAuthClient } from 'better-auth/vue'; import { Err, Ok, type Result } from '~~/types/result'; import { assert } from '~~/utils/assert'; export enum AuthError { NotAuthenticated = 'NOT_AUTHENTICATED', SignInFailed = 'SIGN_IN_FAILED', SignUpFailed = 'SIGN_UP_FAILED', SignOutFailed = 'SIGN_OUT_FAILED', UpdateFailed = 'UPDATE_FAILED', NetworkError = 'NETWORK_ERROR', } export type AuthUser = InferUserFromClient & { twoFactorEnabled?: boolean | null; }; type sessionData = { session: InferSessionFromClient | null; user: AuthUser | null; }; // Signing in either finishes right away, or stops at the second factor when // the account has two-factor authentication turned on. export type SignInResult = | { twoFactorRequired: false; user: User; token: string } | { twoFactorRequired: true }; export const useAuth = () => { const authClient = createAuthClient({ plugins: [twoFactorClient()], }); const session = useState | null>('auth:session', () => null); const user = useState('auth:user', () => null); const sessionFetching = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false); let sessionPromise: Promise> | null = null; const fetchSession = async (): Promise> => { if (sessionPromise) { console.log('already fetching session'); return sessionPromise; } sessionFetching.value = true; const { promise, resolve } = Promise.withResolvers>(); sessionPromise = promise; let data: { session: InferSessionFromClient; user: AuthUser; } | null = null; try { if (import.meta.server) { data = ( await useFetch<{ session: InferSessionFromClient; user: AuthUser; }>('/api/auth/get-session') ).data.value ?? null; } else { data = (await authClient.getSession()).data; } session.value = data?.session || null; user.value = data?.user || null; resolve(Ok({ session: data?.session || null, user: data?.user || null })); } catch (error) { console.error('Failed to fetch session:', error); resolve(Err(AuthError.NetworkError)); } finally { sessionFetching.value = false; } return sessionPromise; }; if (import.meta.client) { authClient.$store.listen('$sessionSignal', async (signal) => { if (!signal) return; await fetchSession(); }); } const signIn = async ( email: string, password: string ): Promise> => { sessionFetching.value = true; try { const { data, error } = await authClient.signIn.email({ email, password, }); if (error) { console.error('Sign in failed:', error); return Err({ error: AuthError.SignInFailed, data: error }); } if (!data) { return Err({ error: AuthError.SignInFailed }); } // no session exists yet, verifyTwoFactor() finishes the sign in if ('twoFactorRedirect' in data && data.twoFactorRedirect) { return Ok({ twoFactorRequired: true }); } user.value = data.user; clearNuxtData(); return Ok({ twoFactorRequired: false, user: data.user, token: data.token }); } catch (err) { console.error('Sign in error:', err); return Err({ error: AuthError.NetworkError }); } finally { sessionFetching.value = false; } }; const verifyTwoFactor = async ( code: string, options: { backupCode?: boolean; trustDevice?: boolean } = {} ): Promise> => { sessionFetching.value = true; try { const body = { code, trustDevice: options.trustDevice ?? false }; const { data, error } = options.backupCode ? await authClient.twoFactor.verifyBackupCode(body) : await authClient.twoFactor.verifyTotp(body); if (error) { return Err({ error: AuthError.SignInFailed, data: error }); } if (!data || !data.token) { return Err({ error: AuthError.SignInFailed }); } user.value = data.user as AuthUser; clearNuxtData(); return Ok({ user: data.user as User, token: data.token }); } catch (err) { console.error('Two-factor verification error:', err); return Err({ error: AuthError.NetworkError }); } finally { sessionFetching.value = false; } }; const signUp = async ( email: string, password: string, name: string ): Promise> => { sessionFetching.value = true; try { const { data, error } = await authClient.signUp.email({ email, password, name, }); if (error) { console.error('Sign up failed:', error); return Err({ error: AuthError.SignUpFailed, data: error }); } if (!data || !data.token) { return Err({ error: AuthError.SignUpFailed }); } user.value = data.user; clearNuxtData(); return Ok({ user: data.user, token: data.token }); } catch (err) { console.error('Sign up error:', err); return Err({ error: AuthError.NetworkError }); } finally { sessionFetching.value = false; } }; const signOut = async (): Promise> => { sessionFetching.value = true; try { const { error } = await authClient.signOut(); if (error) { console.error('Sign out failed:', error); return Err(AuthError.SignOutFailed); } user.value = null; session.value = null; clearNuxtData(); return Ok(undefined); } catch (err) { console.error('Sign out error:', err); return Err(AuthError.NetworkError); } finally { sessionFetching.value = false; } }; const updateProfile = async ( updates: { name?: string; image?: string | null } ): Promise> => { try { const { error } = await authClient.updateUser(updates); if (error) { console.error('Profile update failed:', error); return Err(AuthError.UpdateFailed); } if (user.value) { user.value = { ...user.value, ...updates }; } return Ok(undefined); } catch (err) { console.error('Profile update error:', err); return Err(AuthError.NetworkError); } }; return { client: authClient, session, user, loggedIn: computed(() => !!session.value), signIn, verifyTwoFactor, signUp, signOut, updateProfile, fetchSession, }; };