import { demoUser } from "@/lib/frontend-demo-data"; import { getPublicDemoMode, getPublicApiBaseUrl } from "@/lib/env"; import { apiFetch, apiJson, apiPatch, isApiError, normalizeApiError } from "@/lib/api/client"; import { clearAuthenticationRequired, markAuthenticationRequired, } from "@/lib/auth/session-requirement"; import type { ApiUser, AccountDeleteRequest, AccountDeleteResponse, AuthLoginRequest, AuthSignupRequest, AuthSignupResponse, AuthTokenResponse, ApiErrorBody, UserUpdateRequest, } from "@/lib/api/types"; export const DEMO_PREVIEW_TOKEN = "docdoe-demo-preview-token"; export const AUTH_SESSION_UPDATED_EVENT = "docdoe-auth-session-updated"; const TOKEN_KEY = "exam_success_access_token"; const USER_KEY = "exam_success_user"; const EXPIRES_AT_KEY = "exam_success_token_expires_at"; const REFRESH_TOKEN_KEY = "exam_success_refresh_token"; const REMEMBER_EMAIL_KEY = "docdoe_remember_email"; const STAY_SIGNED_IN_KEY = "docdoe_stay_signed_in"; function authStorage(preferLocal = true): Storage { if (typeof window === "undefined") { throw new Error("auth storage is browser-only"); } if (preferLocal) return window.localStorage; return window.sessionStorage; } function readAuthItem(key: string) { if (typeof window === "undefined") return null; return window.localStorage.getItem(key) ?? window.sessionStorage.getItem(key); } function writeAuthItem(key: string, value: string, staySignedIn: boolean) { const primary = authStorage(staySignedIn); const secondary = authStorage(!staySignedIn); primary.setItem(key, value); secondary.removeItem(key); } export function isDemoPreviewSession() { return getAuthToken() === DEMO_PREVIEW_TOKEN; } /** The only route that may opt into the local, unauthenticated preview. */ export function isExplicitDemoPreviewPath() { return typeof window !== "undefined" && window.location.pathname === "/preview/tuition"; } export function getAuthToken() { if (typeof window === "undefined") return null; const token = readAuthItem(TOKEN_KEY); const expiresAt = Number(readAuthItem(EXPIRES_AT_KEY)); if (token && Number.isFinite(expiresAt) && expiresAt > 0 && Date.now() > expiresAt) { // Prefer refresh over hard wipe when a refresh_token is present. if (readAuthItem(REFRESH_TOKEN_KEY)) { return token; } clearAuthSession(); markAuthenticationRequired(); return null; } return token; } export function getStoredUser() { if (typeof window === "undefined") return null; const rawUser = readAuthItem(USER_KEY); if (!rawUser) return null; try { return JSON.parse(rawUser) as ApiUser; } catch { return null; } } export function getRememberedEmail() { if (typeof window === "undefined") return ""; return window.localStorage.getItem(REMEMBER_EMAIL_KEY) ?? ""; } export function setRememberedEmail(email: string | null) { if (typeof window === "undefined") return; if (!email?.trim()) { window.localStorage.removeItem(REMEMBER_EMAIL_KEY); return; } window.localStorage.setItem(REMEMBER_EMAIL_KEY, email.trim().toLowerCase()); } export function getStaySignedInPreference() { if (typeof window === "undefined") return true; const raw = window.localStorage.getItem(STAY_SIGNED_IN_KEY); if (raw === null) return true; return raw === "1"; } export function setStaySignedInPreference(staySignedIn: boolean) { if (typeof window === "undefined") return; window.localStorage.setItem(STAY_SIGNED_IN_KEY, staySignedIn ? "1" : "0"); } export function getRefreshToken() { if (typeof window === "undefined") return null; return readAuthItem(REFRESH_TOKEN_KEY); } /** Exchange refresh_token for a new AuthTokenResponse. Returns null on failure. */ export async function refreshAuthSession(): Promise { const refreshToken = getRefreshToken(); if (!refreshToken || refreshToken === DEMO_PREVIEW_TOKEN) return null; try { const response = await apiJson( "/auth/refresh", { refresh_token: refreshToken }, { skipAuthClear: true }, ); setAuthSession(response); return response; } catch { return null; } } export function setAuthSession( response: AuthTokenResponse, options?: { staySignedIn?: boolean }, ) { if (typeof window === "undefined") return; const staySignedIn = options?.staySignedIn ?? getStaySignedInPreference(); setStaySignedInPreference(staySignedIn); writeAuthItem(TOKEN_KEY, response.access_token, staySignedIn); writeAuthItem(USER_KEY, JSON.stringify(response.user), staySignedIn); if (response.expires_in) { writeAuthItem( EXPIRES_AT_KEY, String(Date.now() + response.expires_in * 1000), staySignedIn, ); } if (response.refresh_token) { writeAuthItem(REFRESH_TOKEN_KEY, response.refresh_token, staySignedIn); } else { // Google OAuth sessions are first-party backend JWTs and do not carry a // Supabase refresh token. Never let a previous Supabase account's token // survive an identity switch and later replace the Google session. window.localStorage.removeItem(REFRESH_TOKEN_KEY); window.sessionStorage.removeItem(REFRESH_TOKEN_KEY); } clearAuthenticationRequired(); // OAuth finishes inside a child route while AuthProvider remains mounted. // Notify the provider immediately so client-side navigation cannot carry a // stale `user = null` into Home or Onboarding. window.dispatchEvent(new Event(AUTH_SESSION_UPDATED_EVENT)); } function safeNextPath(nextPath: string) { if (!nextPath.startsWith("/") || nextPath.startsWith("//")) return "/study-chat"; return nextPath; } export function startGoogleSignIn({ nextPath = "/study-chat", inviteCode, }: { nextPath?: string; inviteCode?: string; } = {}) { const params = new URLSearchParams({ next: safeNextPath(nextPath), }); if (inviteCode?.trim()) { params.set("invite_code", inviteCode.trim()); } window.location.assign(`${getPublicApiBaseUrl()}/auth/google/start?${params.toString()}`); } export function setStoredUser(user: ApiUser) { if (typeof window === "undefined") return; const staySignedIn = getStaySignedInPreference(); writeAuthItem(USER_KEY, JSON.stringify(user), staySignedIn); } export function clearAuthSession() { if (typeof window === "undefined") return; for (const storage of [window.localStorage, window.sessionStorage]) { storage.removeItem(TOKEN_KEY); storage.removeItem(USER_KEY); storage.removeItem(EXPIRES_AT_KEY); storage.removeItem(REFRESH_TOKEN_KEY); } } /** Clear the server-scoped media cookie as well as client-managed credentials. */ export function logoutCurrentSession() { // Start the request before clearing storage: apiFetch reads the current // bearer token synchronously. Logout still completes locally if the network // is unavailable, so a stale browser view cannot keep using private APIs. void apiFetch("/auth/logout", { method: "POST" }).catch(() => undefined); markAuthenticationRequired(); clearAuthSession(); } function isDocDoeAccountKey(key: string) { return ( key.startsWith("docdoe") || key.startsWith("exam_success_") || key === "access_token" || key === "auth_token" ); } /** Remove only DocDoe-owned browser data after the backend confirms deletion. */ export function clearDeletedAccountLocalData() { if (typeof window === "undefined") return; for (const storage of [window.localStorage, window.sessionStorage]) { if (!storage) continue; const keys = Array.from({ length: storage.length }, (_, index) => storage.key(index)) .filter((key): key is string => Boolean(key)); for (const key of keys) { if (isDocDoeAccountKey(key)) storage.removeItem(key); } } } /** Local demo session for previewing the student workspace without backend auth. */ export function setDemoPreviewSession() { setAuthSession({ access_token: DEMO_PREVIEW_TOKEN, token_type: "bearer", expires_in: 60 * 60 * 24 * 7, user: demoUser, }); } function userSafeAuthError(error: unknown, action: "login" | "signup" | "resend" | "profile") { if (isApiError(error)) { const body = error.body as ApiErrorBody | null; const errorCode = body?.error?.code ?? (body?.detail && typeof body.detail === "object" && "code" in body.detail ? String(body.detail.code ?? "") : ""); if (action === "login" && error.status === 401) { return "Could not sign in right now. Please check your email and password."; } if (action === "signup" && errorCode === "BETA_INVITE_ONLY") { return "DocDoe beta accounts are invitation-only. Sign in with the account prepared for you."; } if (action === "signup" && (error.status === 400 || error.status === 409)) { return "An account with this email already exists. Please sign in."; } if (action === "signup" && error.status === 403) { return "This invite code is not valid. Contact the DocDoe team for beta access."; } if (action === "signup" && error.status === 503) { return "Email account verification is being configured. Please try again later."; } if (action === "signup" && error.status === 429) { return "Too many signup attempts. Wait a moment, then try again."; } if (action === "signup" && error.status === 502) { return "We couldn't send your verification email. Please try again in a few minutes."; } if (action === "resend" && error.status === 429) { return "Too many code requests. Wait a moment before requesting another code."; } if (action === "resend" && error.status === 404) { return "Signup verification is not available on this deployment. Please sign in or contact the DocDoe team."; } if (action === "resend" && error.status === 502) { return "We couldn't send your verification code. Please try again shortly."; } if (action === "resend" && error.status === 503) { return "Email account verification is being configured. Please try again later."; } if (error.isAuthError) { return "Your session expired. Please sign in again."; } if (error.status == null) { return action === "signup" ? "Could not reach DocDoe to create your account. Check your connection and try again." : "Could not reach DocDoe right now. Check your connection and try again."; } } if (action === "signup") return "Could not create your account right now. Please try again."; if (action === "login") return "Could not sign in right now. Please try again."; if (action === "resend") return "Could not resend the verification code right now. Please try again."; return normalizeApiError(error); } export async function loginWithEmailPassword( payload: AuthLoginRequest, options?: { staySignedIn?: boolean; rememberEmail?: boolean }, ) { try { const response = await apiJson("/auth/login", payload); setAuthSession(response, { staySignedIn: options?.staySignedIn }); if (options?.rememberEmail) { setRememberedEmail(payload.email); } else if (options?.rememberEmail === false) { setRememberedEmail(null); } return response; } catch (error) { throw new Error(userSafeAuthError(error, "login")); } } export async function requestLoginOtp(email: string) { try { return await apiJson<{ message: string }, { email: string }>( "/auth/login/otp/request", { email: email.trim().toLowerCase() }, { timeoutMs: 30_000 }, ); } catch (error) { if (isApiError(error) && error.status === 429) { const wait = error.retryAfter ?? 60; throw new Error(`Please wait ${wait} seconds before requesting another code.`); } if (isApiError(error) && error.status === 503) { throw new Error("Email sign-in codes are temporarily unavailable. Use your password instead."); } throw new Error("We could not send a sign-in code right now. Please try again."); } } export async function verifyLoginOtp( payload: { email: string; code: string }, options?: { staySignedIn?: boolean; rememberEmail?: boolean }, ) { try { const response = await apiJson< AuthTokenResponse, { email: string; code: string } >("/auth/login/otp/verify", { email: payload.email.trim().toLowerCase(), code: payload.code.trim(), }); setAuthSession(response, { staySignedIn: options?.staySignedIn }); if (options?.rememberEmail) { setRememberedEmail(payload.email); } else if (options?.rememberEmail === false) { setRememberedEmail(null); } return response; } catch (error) { if (isApiError(error) && error.status === 401) { throw new Error("That code is invalid or expired. Request a new one."); } if (isApiError(error) && error.status === 503) { throw new Error("Email sign-in codes are temporarily unavailable. Use your password instead."); } if (isApiError(error) && error.status === 429) { throw new Error("Too many code attempts. Wait a moment, then request a new code."); } throw new Error("Could not verify the sign-in code. Please try again."); } } export async function signupWithEmailPassword(payload: AuthSignupRequest) { try { const response = await apiJson("/auth/signup", { ...payload, name: payload.name.trim(), email: payload.email.trim().toLowerCase(), }); if ("access_token" in response) setAuthSession(response); return response; } catch (error) { throw new Error(userSafeAuthError(error, "signup")); } } export async function verifySignupOtp(payload: { email: string; code: string }) { try { const response = await apiJson< AuthTokenResponse, { email: string; code: string } >("/auth/signup/otp/verify", { email: payload.email.trim().toLowerCase(), code: payload.code.trim(), }); setAuthSession(response); return response; } catch (error) { if (isApiError(error) && error.status === 401) { throw new Error("That verification code is invalid or expired. Request a new one."); } if (isApiError(error) && error.status === 429) { throw new Error("Too many code attempts. Wait a moment, then request a new code."); } throw new Error("Could not verify your email right now. Please try again."); } } type ResendSignupOtpResponse = { sent: boolean; }; /** * Re-request the signup verification code for an unconfirmed account. * The backend answers identically whether or not the address is registered, * so callers must not read the response as an account-existence signal. */ export async function resendSignupOtp(email: string) { try { return await apiJson( "/auth/signup/otp/resend", { email: email.trim().toLowerCase() }, { timeoutMs: 30_000 }, ); } catch (error) { throw new Error(userSafeAuthError(error, "resend")); } } /** Keep the client aligned with the backend password rule before a request is sent. */ export function getPasswordPolicyError(password: string) { if (password.length < 8) { return "Use at least 8 characters."; } if (!/[A-Za-z]/.test(password) || !/\d/.test(password)) { return "Use at least one letter and one number."; } return null; } type PasswordRecoveryResponse = { message: string; }; type PasswordResetResponse = { reset: boolean; }; export async function requestPasswordRecovery(email: string) { try { return await apiJson( "/auth/forgot-password", { email }, { timeoutMs: 30_000 }, ); } catch (error) { if (isApiError(error) && error.status === 503) { throw new Error("Password recovery is temporarily unavailable. Please try again shortly."); } throw new Error("We could not send a recovery link right now. Please try again."); } } export async function resetPasswordWithToken({ token, password, }: { token: string; password: string; }) { try { return await apiJson( "/auth/reset-password", { token, password }, { timeoutMs: 30_000 }, ); } catch (error) { if (isApiError(error) && error.status === 503) { throw new Error("Password recovery is temporarily unavailable. Please try again shortly."); } if (isApiError(error) && error.status === 400) { throw new Error("This recovery link is invalid or has expired. Request a new one."); } throw new Error("We could not reset your password right now. Please request a new recovery link."); } } export async function fetchCurrentUser() { const token = getAuthToken(); if (token === DEMO_PREVIEW_TOKEN) { if (getPublicDemoMode() === "live" || !isExplicitDemoPreviewPath()) { clearAuthSession(); return null; } // A preview token represents a local, deliberately offline session. It is // not a server credential, so probing /auth/session can hold every // protected route behind the boot screen until the API request times out. // Real deployments never create this token because they run in live mode. return getStoredUser() ?? demoUser; } if (!token) { if (getPublicDemoMode() !== "live" && isExplicitDemoPreviewPath()) { setDemoPreviewSession(); return demoUser; } return null; } try { const user = await apiFetch("/auth/session", { skipAuthClear: true }); // A request started before an OAuth callback may complete successfully // after the browser has switched to a replacement token. Do not let that // older identity overwrite the replacement session in storage. if (getAuthToken() !== token) { return getAuthToken() ? getStoredUser() : null; } setStoredUser(user); return user; } catch (error) { if (isApiError(error) && error.isAuthError) { // Try refresh before wiping — lasting sessions need refresh_token (#50). if (getAuthToken() === token || getRefreshToken()) { const refreshed = await refreshAuthSession(); if (refreshed?.user) { return refreshed.user; } } // The request may have started with an older token while an OAuth // callback was completing. Never let that stale 401 clear the newly // persisted session (or make the provider render a guest state). if (getAuthToken() === token) { const stored = getStoredUser(); // Soft-fail: keep a just-written login session if /auth/session 401s. if (stored) { return stored; } clearAuthSession(); markAuthenticationRequired(); return null; } return getAuthToken() ? getStoredUser() : null; } if (getPublicDemoMode() === "live") { return getStoredUser(); } return getStoredUser() ?? demoUser; } } export async function updateCurrentUser(payload: UserUpdateRequest) { const token = getAuthToken(); if (token === DEMO_PREVIEW_TOKEN) { const nextUser = { ...(getStoredUser() ?? demoUser), ...payload, }; setStoredUser(nextUser); return nextUser; } if (!token) { if (getPublicDemoMode() === "live") { throw new Error("Authentication required to update profile."); } const nextUser = { ...(getStoredUser() ?? demoUser), ...payload, }; setStoredUser(nextUser); return nextUser; } try { const user = await apiPatch("/users/me", payload); setStoredUser(user); return user; } catch (error) { throw new Error(userSafeAuthError(error, "profile")); } } export async function deleteCurrentAccount(payload: AccountDeleteRequest) { const token = getAuthToken(); if (!token || token === DEMO_PREVIEW_TOKEN) { throw new Error("The shared preview account cannot be deleted."); } try { return await apiFetch("/users/me", { method: "DELETE", body: JSON.stringify(payload), timeoutMs: 30_000, }); } catch (error) { if (isApiError(error) && error.message.trim()) { throw new Error(error.message.trim()); } throw new Error("Your account could not be deleted. Nothing was changed; please try again."); } }