Download src/lib/auth/auth-client.ts from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 20.5 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/auth/auth-client.ts
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/lib/auth/auth-client.ts
-
curl -L -o auth-client.ts https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/auth/auth-client.ts
20.5 kB
| 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<AuthTokenResponse | null> { | |
| const refreshToken = getRefreshToken(); | |
| if (!refreshToken || refreshToken === DEMO_PREVIEW_TOKEN) return null; | |
| try { | |
| const response = await apiJson<AuthTokenResponse, { refresh_token: string }>( | |
| "/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<void>("/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<AuthTokenResponse, AuthLoginRequest>("/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<AuthSignupResponse, AuthSignupRequest>("/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<ResendSignupOtpResponse, { email: string }>( | |
| "/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<PasswordRecoveryResponse, { email: string }>( | |
| "/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<PasswordResetResponse, { token: string; password: string }>( | |
| "/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<ApiUser>("/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<ApiUser, UserUpdateRequest>("/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<AccountDeleteResponse>("/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."); | |
| } | |
| } | |