'use client'; import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; import { authClient } from '@/lib/auth-client'; import { fetchApi } from '@/lib/api/client'; interface User { id: string; email: string; name: string; role: string; phone?: string; languagePreference?: string; isClaimed?: boolean; rucNumber?: string; accountStatus?: string; } interface AuthContextType { user: User | null; /** Always null: auth moved to httpOnly session cookies (Better Auth). Kept for compat. */ token: string | null; isLoading: boolean; isAdmin: boolean; hasAdminAccess: boolean; login: (email: string, password: string) => Promise; loginWithGoogle: (credential: string) => Promise; loginWithMagicLink: (token: string) => Promise; register: (data: RegisterData) => Promise; logout: () => void; updateUser: (user: User) => void; setAuthData: (data: { user: User; token?: string }) => void; refreshUser: () => Promise; } interface RegisterData { email: string; password: string; name: string; phone?: string; languagePreference?: string; } const AuthContext = createContext(undefined); // Legacy storage from the pre-Better-Auth JWT era; cleared once on mount. const LEGACY_TOKEN_KEY = 'spanglish-token'; const LEGACY_USER_KEY = 'spanglish-user'; const LEGACY_AUTH_COOKIE = 'spanglish-auth'; function mapSessionUser(sessionUser: any): User { return { id: sessionUser.id, email: sessionUser.email, name: sessionUser.name, role: sessionUser.role ?? 'user', phone: sessionUser.phone ?? undefined, languagePreference: sessionUser.languagePreference ?? undefined, isClaimed: Boolean(sessionUser.isClaimed ?? true), rucNumber: sessionUser.rucNumber ?? undefined, accountStatus: sessionUser.accountStatus ?? 'active', }; } function messageFrom(error: { message?: string; code?: string; status?: number } | null, fallback: string): string { return error?.message || fallback; } export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); const refreshUser = useCallback(async (): Promise => { try { const { data } = await authClient.getSession(); if (data?.user) { const mapped = mapSessionUser(data.user); setUser(mapped); return mapped; } setUser(null); return null; } catch (error) { // Network error: keep current state console.error('Failed to refresh user data:', error); return null; } }, []); useEffect(() => { // One-time cleanup of legacy JWT-era storage try { localStorage.removeItem(LEGACY_TOKEN_KEY); localStorage.removeItem(LEGACY_USER_KEY); document.cookie = `${LEGACY_AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`; } catch { /* SSR / storage unavailable */ } refreshUser().finally(() => setIsLoading(false)); }, [refreshUser]); const login = async (email: string, password: string) => { const { error } = await authClient.signIn.email({ email, password }); if (error) { throw new Error(messageFrom(error, 'Login failed')); } await refreshUser(); }; const loginWithGoogle = async (credential: string) => { const { error } = await authClient.signIn.social({ provider: 'google', idToken: { token: credential }, }); if (error) { throw new Error(messageFrom(error, 'Google login failed')); } await refreshUser(); }; const loginWithMagicLink = async (magicToken: string): Promise => { const { error } = await authClient.magicLink.verify({ query: { token: magicToken } }); if (error) { throw new Error(messageFrom(error, 'Invalid or expired link')); } return refreshUser(); }; const register = async (registerData: RegisterData) => { const { error } = await authClient.signUp.email({ email: registerData.email, password: registerData.password, name: registerData.name, phone: registerData.phone || undefined, languagePreference: registerData.languagePreference || undefined, } as any); if (error) { // Existing-but-unclaimed accounts (created during guest booking) should // point the user to the claim flow instead of a bare "already exists". if (error.code === 'USER_ALREADY_EXISTS') { try { const { canClaim } = await fetchApi<{ canClaim: boolean }>( `/api/auth-ext/claim-eligibility?email=${encodeURIComponent(registerData.email)}` ); if (canClaim) { const err = new Error( 'This email has an unclaimed account from a previous booking. Use "Email Link" on the login page to claim it.' ); (err as any).canClaim = true; throw err; } } catch (e: any) { if (e?.canClaim) throw e; /* eligibility check failed: fall through to generic message */ } } throw new Error(messageFrom(error, 'Registration failed')); } await refreshUser(); }; const logout = useCallback(() => { // Best-effort server-side revocation; local state clears regardless. authClient.signOut().catch(() => { /* ignore network errors */ }); setUser(null); }, []); const updateUser = useCallback((updatedUser: User) => { setUser(updatedUser); }, []); // Compat shim for callers that used to push {user, token} after custom auth // flows; the session cookie is already set by then, so only state updates. const setAuthData = useCallback((data: { user: User; token?: string }) => { setUser(data.user); }, []); const isAdmin = user?.role === 'admin' || user?.role === 'organizer'; const hasAdminAccess = user?.role === 'admin' || user?.role === 'organizer' || user?.role === 'staff' || user?.role === 'marketing'; return ( {children} ); } export function useAuth() { const context = useContext(AuthContext); if (context === undefined) { throw new Error('useAuth must be used within an AuthProvider'); } return context; }