Files
spsays-training/lib/auth.ts
T

191 lines
5.5 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;
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 }),
}),
logIn: (email: string, password: string) =>
authFetch<User>("/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<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 }),
}),
/**
* 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,
}),
}),
updateProfile: (
name?: string,
avatarURL?: string | null,
handle?: string,
) =>
authFetch<User>("/users/me", {
method: "PATCH",
body: JSON.stringify({
name: name ?? undefined,
avatar_url: avatarURL === undefined ? undefined : avatarURL,
handle: handle ?? undefined,
}),
}),
/**
* 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;
},
};