265 lines
8.3 KiB
TypeScript
265 lines
8.3 KiB
TypeScript
/**
|
|
* 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;
|
|
/** 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<T> = {
|
|
success: boolean;
|
|
data?: T;
|
|
error?: { code: number; message: string };
|
|
};
|
|
|
|
/** Low-level fetch helper that unwraps the BFF envelope. */
|
|
async function authFetch<T>(
|
|
path: string,
|
|
init?: RequestInit,
|
|
): Promise<T> {
|
|
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<T>;
|
|
|
|
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<User>("/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<User | { otp_required: true }>(
|
|
"/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<User>("/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<User | null> => {
|
|
try {
|
|
return await authFetch<User>("/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<User>("/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<User>("/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;
|
|
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<User>("/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<string> => {
|
|
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;
|
|
},
|
|
};
|