import { headers } from "next/headers"; import { after } from "next/server"; import { findMemberById, findMemberByLoginEmail } from "@/lib/auth/admission"; import { sessionClaims } from "@/lib/auth/access"; import { createAuth, MAGIC_LINK_MINUTES, type AuthEmail } from "@/lib/auth/config"; import type { AppSession } from "@/lib/auth/session"; import type { BrandConfig } from "@/lib/brand"; import { getBrand } from "@/lib/brand/get-brand"; import { getDb } from "@/lib/db/client"; import { sendMail } from "@/lib/email/resend"; import { t } from "@/lib/i18n"; import { familyRole, sessionAccount } from "@/lib/members/family"; import { reportError } from "@/lib/observability"; function authEmailText({ kind, email, url, code }: AuthEmail, brand: BrandConfig): string { const e = t.authEmail; switch (kind) { case "login": return e.login(brand.appName, url!, code!, MAGIC_LINK_MINUTES); case "invite": return e.invite(brand.appName, url!, code!, MAGIC_LINK_MINUTES); case "notMember": return e.notMember(brand.appName, email, brand.supportEmail); case "accountInactive": return e.accountInactive(brand.appName, brand.supportEmail); case "membershipInactive": return e.membershipInactive(brand.appName, brand.membershipUrl ?? null, brand.supportEmail); case "checkUnavailable": return e.checkUnavailable(brand.appName); case "signupRequired": return e.signupRequired(brand.appName, email, url!, brand.supportEmail); } } // The app's one Better Auth instance (lib/auth/config.ts has the rules), // built on first use: the build imports this module without a database // (a preview without DATABASE_URL must still build). let instance: ReturnType | null = null; export function getAuthInstance(): ReturnType { instance ??= buildInstance(); return instance; } const buildInstance = () => createAuth(getDb(), { // Who the address belongs to, which email and the send all run after the // response: a member's request must not take longer than a stranger's. defer: (task) => after(task), async sendAuthEmail(message) { const brand = await getBrand(); const result = await sendMail({ to: message.email, subject: t.authEmail.subject(message.kind, brand.appName), text: authEmailText(message, brand), }); if ("error" in result) reportError("auth email", new Error(result.error), { kind: message.kind }); }, }); // The signed-in member, in the shape the pages and guards used with Auth.js: // role, active, memberId and fullName are read from the members table on every // request, so a deactivated or deleted member is signed out at once. export async function auth(): Promise { // headers() first: it marks the page dynamic before anything touches the // database (a statically prerendered page must not open it at build time). const requestHeaders = await headers(); const session = await getAuthInstance().api.getSession({ headers: requestHeaders }); if (!session) return null; const person = await findMemberByLoginEmail(session.user.email); if (person?.householdOf) { // A person in a family works on the account they joined. const account = sessionAccount(person, await findMemberById(person.householdOf)); const role = account ? familyRole(person.role, account.role) : null; if (!account || !role) return null; return { user: { email: session.user.email, memberId: account.memberId, personId: person.memberId, role, active: true, fullName: person.fullName, // The Admin area is personal: the person's own flag, not the account's. ordersReferent: person.ordersReferent, }, }; } const claims = sessionClaims(person); if (!claims) return null; return { user: { email: session.user.email, ...claims } }; } // Server Action: ends this browser's session. export async function signOut(): Promise { const requestHeaders = await headers(); await getAuthInstance().api.signOut({ headers: requestHeaders }); }