/** * 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; email: string; name: string; avatar_url: string | null; role: string; email_verified_at: string | null; 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 }), }), logIn: (email: string, password: string) => authFetch("/auth/login", { method: "POST", body: JSON.stringify({ email, password }), }), 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" }), updateProfile: (name?: string, avatarURL?: string | null) => authFetch("/users/me", { method: "PATCH", body: JSON.stringify({ name: name ?? undefined, avatar_url: avatarURL === undefined ? undefined : avatarURL, }), }), /** * 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; }, };