/** * Client-side BFF auth helper. * * All calls run in the browser with `credentials: "include"` so the httpOnly * session cookie set by the BFF on /auth/login is sent back on every * subsequent request. * * For dev: localhost:3000 (Next.js) and localhost:8080 (BFF) are same-site * per browser definition, so SameSite=Lax cookies are accepted. * For prod: set COOKIE_DOMAIN=.prabhupadasays.org on the BFF so the cookie * is shared between prabhupadasays.org and api.prabhupadasays.org. */ const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080"; export type User = { id: string; /** * Nullable: OAuth users (notably some Facebook accounts) may sign up * without sharing an email. They can fill it in later via `auth.setEmail`. */ email: string | null; handle: string; name: string; avatar_url: string | null; role: string; plan: "free" | "premium"; email_verified_at: string | null; /** * True when the account has a password hash on file. False for OAuth-only * users who haven't set one yet — the frontend uses this to pick between * the "Change password" modal and the "Set a password" flow. */ has_password: boolean; /** True when an admin has linked this account to the disciples directory — * one of Śrīla Prabhupāda's own initiated disciples. Admin-set only. */ is_direct_disciple?: boolean; /** Short self-introduction (≤200 chars), plain text with `@handle` * mentions linkified at render time. null when unset. */ bio: string | null; /** Account country as an ISO-3166-1 alpha-2 code (e.g. "IN"), or null * when unknown. Captured best-effort at signup; user-editable. */ country: string | null; /** Devotional profile. */ /** Spiritual lineage code (e.g. "gaudiya"); new accounts default to * "gaudiya" but it's optional, so null means "not specified". */ sampradaya: string | null; gender: string | null; initiated: boolean; /** ISO date "YYYY-MM-DD" (or full timestamp from the DB), null if unset. */ initiated_at: string | null; daily_chanting_rounds: number | null; /** System-managed notification scheduling ("HH:MM"), set by the backend * later — surfaced here for completeness, no UI edits them today. */ preferred_chanting_time: string | null; peak_active_time: string | null; /** Effective save limits in force for this user (plan default merged with * any admin override). -1 means unlimited. Drives the "X / Y used" displays * and the create-action gates. */ limits: { bookmarks: number; public_collections: number; private_collections: number; }; created_at: string; updated_at: string; }; /** Error shape returned by the BFF envelope. */ export class AuthError extends Error { constructor( public status: number, message: string, ) { super(message); this.name = "AuthError"; } } type Envelope = { success: boolean; data?: T; error?: { code: number; message: string }; }; /** Low-level fetch helper that unwraps the BFF envelope. */ async function authFetch( path: string, init?: RequestInit, ): Promise { const res = await fetch(`${BFF_BASE}/api/v1${path}`, { credentials: "include", headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, ...init, }); // 204 No Content if (res.status === 204) return undefined as T; const body = (await res.json().catch(() => ({}))) as Envelope; if (!res.ok || body.success === false) { throw new AuthError(res.status, body.error?.message || res.statusText); } return (body.data ?? (undefined as unknown as T)) as T; } export const auth = { signUp: (email: string, password: string, name: string) => authFetch("/auth/signup", { method: "POST", body: JSON.stringify({ email, password, name }), }), /** * Submits email/password. Three outcomes: * - kind: "session" → fully signed in; auth-provider sets the user. * - kind: "otp_required" → account has been unverified for >30 days; * a 6-digit code was emailed. Caller must show an OTP form and call * `verifyLoginOTP(email, password, otp)` to complete sign-in. * Throws AuthError on credential / server errors. */ logIn: async ( email: string, password: string, ): Promise< | { kind: "session"; user: User } | { kind: "otp_required" } > => { const data = await authFetch( "/auth/login", { method: "POST", body: JSON.stringify({ email, password }), }, ); if (data && typeof data === "object" && "otp_required" in data) { return { kind: "otp_required" }; } return { kind: "session", user: data as User }; }, /** * Completes a login that was paused by the OTP gate. Re-checks the * password (the OTP alone isn't enough — defence in depth), validates * the code, marks the email verified, and sets the session cookie. */ verifyLoginOTP: (email: string, password: string, otp: string) => authFetch("/auth/verify-login-otp", { method: "POST", body: JSON.stringify({ email, password, otp }), }), logOut: () => authFetch<{ ok: boolean }>("/auth/logout", { method: "POST" }), /** Returns the current user, or null when not authenticated. */ me: async (): Promise => { try { return await authFetch("/auth/me"); } catch (e) { if (e instanceof AuthError && e.status === 401) return null; throw e; } }, forgotPassword: (email: string) => authFetch<{ ok: boolean }>("/auth/forgot-password", { method: "POST", body: JSON.stringify({ email }), }), resetPassword: (token: string, newPassword: string) => authFetch<{ ok: boolean }>("/auth/reset-password", { method: "POST", body: JSON.stringify({ token, new_password: newPassword }), }), verifyEmail: (token: string) => authFetch<{ ok: boolean }>("/auth/verify-email", { method: "POST", body: JSON.stringify({ token }), }), resendVerification: () => authFetch<{ ok: boolean }>("/auth/resend-verification", { method: "POST" }), /** * Sets (or replaces) the signed-in user's email. The new address is stored * as unverified and a verification link is mailed out immediately. Used by * the "add your email" prompt for OAuth users who signed up without one. */ setEmail: (email: string) => authFetch("/auth/email", { method: "POST", body: JSON.stringify({ email }), }), /** * Returns the list of OAuth provider names already linked to the * signed-in user (e.g. `["google", "facebook"]`). The settings page * uses this to flip per-provider rows between "Connect" / "Connected". */ linkedProviders: () => authFetch<{ providers: string[] }>("/auth/linked-providers"), /** * Replaces the signed-in user's password. The BFF verifies the current * password, hashes the new one, and re-issues the session cookie so this * device stays logged in (every other device is signed out the next time * its JWT is checked). */ changePassword: (currentPassword: string, newPassword: string) => authFetch("/auth/change-password", { method: "POST", body: JSON.stringify({ current_password: currentPassword, new_password: newPassword, }), }), /** * Partial profile patch. Only the keys present in `patch` are sent — an * omitted key leaves that field unchanged; an explicit `""`/`null` (for * the nullable fields) or `0` (for daily_chanting_rounds) clears it. */ updateProfile: (patch: { name?: string; avatar_url?: string | null; handle?: string; /** Plain text, max 200 chars; "" clears it. */ bio?: string; country?: string | null; sampradaya?: string; gender?: string | null; initiated?: boolean; initiated_at?: string | null; daily_chanting_rounds?: number | null; preferred_chanting_time?: string | null; peak_active_time?: string | null; }) => authFetch("/users/me", { method: "PATCH", body: JSON.stringify(patch), }), /** * Two-step avatar upload: * 1. Ask the BFF for a presigned R2 PUT URL. * 2. PUT the file directly to R2. * 3. Caller persists the returned publicURL via updateProfile(). */ uploadAvatar: async (file: File): Promise => { const { upload_url, public_url } = await authFetch<{ upload_url: string; public_url: string; }>("/users/me/avatar-presign", { method: "POST", body: JSON.stringify({ content_type: file.type }), }); const put = await fetch(upload_url, { method: "PUT", body: file, headers: { "Content-Type": file.type }, }); if (!put.ok) { throw new AuthError(put.status, "avatar upload failed"); } return public_url; }, };