feat: add auth
This commit is contained in:
+147
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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<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" }),
|
||||
|
||||
updateProfile: (name?: string, avatarURL?: string | null) =>
|
||||
authFetch<User>("/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<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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user