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 · 160 lines tsx
Raw Blame History
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