TypeScript 97.5%
SQL 1.4%
Python 0.8%
1"use server";23import { revalidatePath } from "next/cache";4import { z } from "zod";5import { getDb, signupAllowlist, eq, inArray } from "@fetcha/db";6import { getEmailService } from "@fetcha/email";7import { requireAdmin, requestMeta } from "@/lib/session";8import { writeAudit } from "@/lib/audit";9import { SITE_URL } from "@/lib/utils";10import type { AuthUser } from "@/lib/auth";11import type { AdminActionResult } from "@/actions/admin";1213/**14 * Signup allowlist management (invitation-only platform). Every mutation is audited as15 * `admin.action` with metadata.type in { access.allow, access.invite, access.revoke }.16 */1718async function audit(admin: AuthUser, type: "access.allow" | "access.invite" | "access.revoke", target: string, metadata: Record<string, unknown> = {}) {19 const meta = await requestMeta();20 await writeAudit({ userId: admin.id, action: "admin.action", target, metadata: { type, ...metadata }, ipAddress: meta.ip, userAgent: meta.userAgent });21}2223function fail(e: unknown): { ok: false; error: string } {24 if (e instanceof z.ZodError) return { ok: false, error: e.issues[0]?.message ?? "Invalid input" };25 return { ok: false, error: (e as Error)?.message ?? "Unexpected error" };26}2728function revalidate() {29 revalidatePath("/admin/access");30}3132const emailSchema = z.string().trim().toLowerCase().email("Invalid email address").max(254);3334/** Split a comma / newline / whitespace separated list into unique, lower-cased, valid emails. */35function parseEmailList(raw: string): { emails: string[]; invalid: string[] } {36 const seen = new Set<string>();37 const emails: string[] = [];38 const invalid: string[] = [];39 for (const token of raw.split(/[\s,;]+/)) {40 const t = token.trim();41 if (!t) continue;42 const r = emailSchema.safeParse(t);43 if (!r.success) {44 invalid.push(t);45 continue;46 }47 if (seen.has(r.data)) continue;48 seen.add(r.data);49 emails.push(r.data);50 }51 return { emails, invalid };52}5354function signupUrlFor(email: string): string {55 return `${SITE_URL}/signup?email=${encodeURIComponent(email)}`;56}5758const allowSchema = z.object({59 emails: z.string().trim().min(3, "Enter at least one email address").max(20_000),60 note: z.string().trim().max(280, "Note is too long (280 characters max)").optional().or(z.literal("")),61 sendInvite: z.boolean().default(false),62});6364export type AllowEmailsInput = z.input<typeof allowSchema>;65export type AllowEmailsData = { added: number; existing: number; invited: number; invalid: string[]; failed: string[] };6667export async function allowEmails(input: AllowEmailsInput): Promise<AdminActionResult<AllowEmailsData>> {68 const admin = await requireAdmin();69 try {70 const d = allowSchema.parse(input);71 const { emails, invalid } = parseEmailList(d.emails);72 if (emails.length === 0) return { ok: false, error: invalid.length ? `No valid email address found (rejected: ${invalid.slice(0, 3).join(", ")}${invalid.length > 3 ? "…" : ""}).` : "Enter at least one email address." };73 if (emails.length > 200) return { ok: false, error: "Add at most 200 addresses at a time." };7475 const db = getDb();76 const note = d.note?.trim() || null;77 let invited = 0;78 const failed: string[] = [];7980 const already = new Set((await db.select({ email: signupAllowlist.email }).from(signupAllowlist).where(inArray(signupAllowlist.email, emails))).map((r) => r.email));81 const fresh = emails.filter((e) => !already.has(e));82 if (fresh.length) await db.insert(signupAllowlist).values(fresh.map((email) => ({ email, note, invitedByUserId: admin.id }))).onConflictDoNothing();83 // Existing entries keep their inviter; only refresh the note when one was provided.84 if (note && already.size) await db.update(signupAllowlist).set({ note }).where(inArray(signupAllowlist.email, Array.from(already)));85 const added = fresh.length;86 const existing = already.size;87 await audit(admin, "access.allow", emails.length === 1 ? emails[0]! : `${emails.length} emails`, { emails, note, added, existing });8889 if (d.sendInvite) {90 const inviterName = admin.name?.trim() || admin.email;91 for (const email of emails) {92 try {93 await getEmailService().sendInvite(email, { inviterName, signupUrl: signupUrlFor(email) });94 await db.update(signupAllowlist).set({ invitedAt: new Date() }).where(eq(signupAllowlist.email, email));95 invited++;96 } catch (e) {97 failed.push(email);98 console.error("[access] invite failed", email, (e as Error).message);99 }100 }101 await audit(admin, "access.invite", emails.length === 1 ? emails[0]! : `${emails.length} emails`, { emails, invited, failed });102 }103104 revalidate();105 const warnings: string[] = [];106 if (invalid.length) warnings.push(`${invalid.length} entr${invalid.length === 1 ? "y was" : "ies were"} not a valid email and ${invalid.length === 1 ? "was" : "were"} skipped: ${invalid.slice(0, 5).join(", ")}${invalid.length > 5 ? "…" : ""}`);107 if (failed.length) warnings.push(`Invitation email could not be sent to ${failed.join(", ")}. Use “Resend invite” later.`);108 return { ok: true, data: { added, existing, invited, invalid, failed }, warning: warnings.length ? warnings.join(" ") : undefined };109 } catch (e) {110 return fail(e);111 }112}113114export async function resendInvite(email: string): Promise<AdminActionResult> {115 const admin = await requireAdmin();116 try {117 const target = emailSchema.parse(email);118 const db = getDb();119 const [row] = await db.select().from(signupAllowlist).where(eq(signupAllowlist.email, target)).limit(1);120 if (!row) return { ok: false, error: "This address is not on the access list." };121 if (row.userId) return { ok: false, error: "This address already has an account; there is nothing to invite." };122 const inviterName = admin.name?.trim() || admin.email;123 await getEmailService().sendInvite(target, { inviterName, signupUrl: signupUrlFor(target) });124 await db.update(signupAllowlist).set({ invitedAt: new Date() }).where(eq(signupAllowlist.email, target));125 await audit(admin, "access.invite", target, { emails: [target], invited: 1, resend: true });126 revalidate();127 return { ok: true };128 } catch (e) {129 return fail(e);130 }131}132133export async function revokeAllow(email: string): Promise<AdminActionResult> {134 const admin = await requireAdmin();135 try {136 const target = emailSchema.parse(email);137 const db = getDb();138 const [row] = await db.select({ email: signupAllowlist.email, userId: signupAllowlist.userId, note: signupAllowlist.note }).from(signupAllowlist).where(eq(signupAllowlist.email, target)).limit(1);139 if (!row) return { ok: false, error: "This address is not on the access list." };140 if (row.userId) return { ok: false, error: "This address already has an account; ban the user instead." };141 await db.delete(signupAllowlist).where(eq(signupAllowlist.email, target));142 await audit(admin, "access.revoke", target, { note: row.note });143 revalidate();144 return { ok: true };145 } catch (e) {146 return fail(e);147 }148}149