SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
6.9 KB · 149 lines typescript
Raw Blame History
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