TypeScript 97.5%
SQL 1.4%
Python 0.8%
1import Link from "next/link";2import { KeyRound } from "lucide-react";3import { requireAdmin } from "@/lib/session";4import { getAllowlistCounts, listAllowlist, str } from "@/lib/queries/admin";5import { adminEmails } from "@/lib/access";6import { formatNumber } from "@/lib/format";7import { PageHeader } from "@/components/ui/page-header";8import { Badge } from "@/components/ui/badge";9import { Alert } from "@/components/ui/alert";10import { EmptyState } from "@/components/ui/empty-state";11import { Stat, StatGrid } from "@/components/ui/stat";12import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";13import { DateCell, Mono, Panel, SearchForm } from "@/components/admin/primitives";14import { AddEmailsDialog, RemoveAllowButton, ResendInviteButton } from "@/components/admin/access-controls";1516export const dynamic = "force-dynamic";1718function StatusBadgeFor({ status, banned }: { status: "account" | "invited" | "pending"; banned: boolean | null }) {19 if (status === "account") {20 return banned ? (21 <Badge variant="danger" dot>22 account banned23 </Badge>24 ) : (25 <Badge variant="success" dot>26 account created27 </Badge>28 );29 }30 if (status === "invited")31 return (32 <Badge variant="info" dot>33 invited34 </Badge>35 );36 return (37 <Badge variant="outline" dot>38 pending39 </Badge>40 );41}4243export default async function AdminAccessPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {44 await requireAdmin();45 const sp = await searchParams;46 const q = str(sp.q);47 const [rows, counts] = await Promise.all([listAllowlist({ q: q || undefined }), getAllowlistCounts()]);4849 return (50 <>51 <PageHeader52 eyebrow="Accounts"53 title="Access"54 description="Fetcha is invitation-only. Only emails on this list (plus the ADMIN_EMAILS environment variable) can create an account. Removing an entry does not affect an existing account — ban the user instead."55 actions={56 <>57 <SearchForm action="/admin/access" q={q} placeholder="email or note" />58 <AddEmailsDialog />59 </>60 }61 />6263 <StatGrid cols={4} className="mb-6">64 <Stat label="On the list" value={formatNumber(counts.total)} hint="allowlisted emails" />65 <Stat label="Accounts created" value={formatNumber(counts.accounts)} hint="invitation used" />66 <Stat label="Invited" value={formatNumber(counts.invited)} hint="email sent, no account yet" />67 <Stat label="Pending" value={formatNumber(counts.pending)} hint="listed, no email sent" />68 </StatGrid>6970 {adminEmails.length ? (71 <Alert variant="info" title="Administrators from the environment" className="mb-4">72 {adminEmails.length === 1 ? "This address" : "These addresses"} can always sign up and {adminEmails.length === 1 ? "is" : "are"} created with the admin role, whether or not {adminEmails.length === 1 ? "it is" : "they are"} listed73 below: {adminEmails.map((e, i) => (74 <span key={e}>75 {i > 0 ? ", " : null}76 <Mono>{e}</Mono>77 </span>78 ))}79 . Run <Mono>pnpm db:seed</Mono> to add them to the list and promote existing accounts.80 </Alert>81 ) : (82 <Alert variant="warning" title="ADMIN_EMAILS is not set" className="mb-4">83 Without it, only addresses on this list can sign up and nobody is promoted to admin automatically. Set <Mono>ADMIN_EMAILS</Mono> in the web environment and run <Mono>pnpm db:seed</Mono>.84 </Alert>85 )}8687 <Panel flush>88 {rows.length === 0 ? (89 <div className="p-4">90 <EmptyState91 icon={KeyRound}92 title={q ? "No entries match" : "The access list is empty"}93 description={q ? "Try a partial email or a different spelling." : "Add the emails of the people you want to let in. Optionally send them an invitation email with a link to the signup page."}94 action={q ? undefined : <AddEmailsDialog />}95 compact96 />97 </div>98 ) : (99 <Table>100 <TableHeader>101 <TableRow>102 <TableHead>Email</TableHead>103 <TableHead>Note</TableHead>104 <TableHead>Status</TableHead>105 <TableHead>Invited by</TableHead>106 <TableHead className="text-right">Invited</TableHead>107 <TableHead className="text-right">Added</TableHead>108 <TableHead className="text-right">Actions</TableHead>109 </TableRow>110 </TableHeader>111 <TableBody>112 {rows.map((r) => (113 <TableRow key={r.email}>114 <TableCell>115 {r.userId ? (116 <Link href={`/admin/users/${r.userId}`} className="font-medium hover:underline">117 {r.email}118 </Link>119 ) : (120 <span className="font-medium">{r.email}</span>121 )}122 {r.accountEmail && r.accountEmail.toLowerCase() !== r.email ? <div className="text-[11.5px] text-fg-subtle">account now {r.accountEmail}</div> : null}123 </TableCell>124 <TableCell className="max-w-[280px] truncate text-fg-muted" title={r.note ?? undefined}>125 {r.note ?? <span className="text-fg-subtle">—</span>}126 </TableCell>127 <TableCell>128 <StatusBadgeFor status={r.status} banned={r.accountBanned} />129 </TableCell>130 <TableCell className="max-w-[220px] truncate text-fg-muted">131 {r.invitedByUserId ? (132 <Link href={`/admin/users/${r.invitedByUserId}`} className="hover:underline">133 {r.invitedByEmail ?? r.invitedByUserId}134 </Link>135 ) : (136 <span className="text-fg-subtle">seed</span>137 )}138 </TableCell>139 <TableCell className="text-right">140 <DateCell value={r.invitedAt} />141 </TableCell>142 <TableCell className="text-right">143 <DateCell value={r.createdAt} />144 </TableCell>145 <TableCell className="text-right">146 <div className="inline-flex items-center justify-end gap-1">147 {r.status !== "account" ? <ResendInviteButton email={r.email} invitedBefore={Boolean(r.invitedAt)} /> : null}148 <RemoveAllowButton email={r.email} hasAccount={r.status === "account"} />149 </div>150 </TableCell>151 </TableRow>152 ))}153 </TableBody>154 </Table>155 )}156 </Panel>157 </>158 );159}160