DocDoeAI / src /lib /auth /auth-client.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
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.");
}
}