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%
5.6 KB · 111 lines tsx
Raw Blame History
1import Link from "next/link";2import { ChevronLeft, ChevronRight, ScrollText } from "lucide-react";3import { getWorkspace } from "@/lib/session";4import { listAuditLogs } from "@/lib/queries/account";5import { formatDate, titleCase, truncate } from "@/lib/format";6import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";7import { EmptyState } from "@/components/ui/empty-state";8import { Badge } from "@/components/ui/badge";9import { Button } from "@/components/ui/button";10import { SimpleTooltip } from "@/components/ui/tooltip";1112export const dynamic = "force-dynamic";1314const PAGE_SIZE = 25;1516function actionVariant(action: string): "default" | "success" | "danger" | "warning" | "info" | "accent" {17  if (action.endsWith(".revoked") || action.endsWith(".deleted") || action.endsWith(".archived")) return "danger";18  if (action.endsWith(".created") || action === "login" || action === "email.verified") return "success";19  if (action.startsWith("password") || action.startsWith("email") || action.startsWith("session")) return "warning";20  if (action.startsWith("admin")) return "accent";21  return "default";22}2324function summarize(meta: Record<string, unknown> | null): string {25  if (!meta) return "—";26  const parts = Object.entries(meta)27    .filter(([, v]) => v !== null && v !== undefined && typeof v !== "object")28    .map(([k, v]) => `${k}: ${String(v)}`);29  return parts.length ? truncate(parts.join(" · "), 72) : "—";30}3132export default async function AuditLogPage({ searchParams }: { searchParams: Promise<{ page?: string }> }) {33  const sp = await searchParams;34  const page = Math.max(1, Number.parseInt(sp.page ?? "1", 10) || 1);35  const ws = await getWorkspace();36  const { rows, total } = await listAuditLogs(ws.user.id, ws.organization.id, page, PAGE_SIZE);37  const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));3839  return (40    <div className="grid gap-4">41      <div className="flex flex-wrap items-end justify-between gap-3">42        <div>43          <h2 className="text-[15px] font-semibold tracking-tight">Audit log</h2>44          <p className="text-[13px] text-fg-muted">Security-relevant events for your account and organization. Retained for 365 days.</p>45        </div>46        <p className="text-[12.5px] text-fg-subtle tabular">{total.toLocaleString("en-US")} events</p>47      </div>4849      {rows.length === 0 ? (50        <EmptyState icon={ScrollText} title={page > 1 ? "Nothing on this page" : "No events yet"} description={page > 1 ? "Go back to the first page." : "Logins, key changes and settings updates will appear here."} action={page > 1 ? <Button asChild variant="outline" size="sm"><Link href="/dashboard/settings/audit">First page</Link></Button> : undefined} />51      ) : (52        <div className="overflow-hidden rounded-lg border border-border bg-bg-elevated shadow-xs">53          <Table>54            <TableHeader>55              <TableRow className="hover:bg-transparent">56                <TableHead>Time</TableHead>57                <TableHead>Action</TableHead>58                <TableHead>Target</TableHead>59                <TableHead>Details</TableHead>60                <TableHead>IP</TableHead>61                <TableHead>User agent</TableHead>62              </TableRow>63            </TableHeader>64            <TableBody>65              {rows.map((r) => (66                <TableRow key={r.id}>67                  <TableCell className="whitespace-nowrap tabular text-fg-muted">{formatDate(r.createdAt)}</TableCell>68                  <TableCell>69                    <Badge variant={actionVariant(r.action)}>{titleCase(r.action.replace(/\./g, " "))}</Badge>70                  </TableCell>71                  <TableCell className="font-mono text-[12px] text-fg-muted">{r.target ? truncate(r.target, 28) : "—"}</TableCell>72                  <TableCell className="max-w-[280px] truncate text-[12.5px] text-fg-muted" title={r.metadata ? JSON.stringify(r.metadata) : undefined}>73                    {summarize(r.metadata)}74                  </TableCell>75                  <TableCell className="font-mono text-[12px] tabular">{r.ipAddress ?? "—"}</TableCell>76                  <TableCell className="text-[12px] text-fg-subtle">77                    {r.userAgent ? (78                      <SimpleTooltip content={<span className="font-mono text-[11px]">{r.userAgent}</span>}>79                        <span className="font-mono">{truncate(r.userAgent, 36)}</span>80                      </SimpleTooltip>81                    ) : (82                      "—"83                    )}84                  </TableCell>85                </TableRow>86              ))}87            </TableBody>88          </Table>89          <div className="flex items-center justify-between border-t border-border px-4 py-2 text-[12.5px] text-fg-muted">90            <span className="tabular">91              Page {page} of {pages}92            </span>93            <div className="flex items-center gap-1">94              <Button asChild variant="ghost" size="xs" aria-disabled={page <= 1}>95                <Link href={`/dashboard/settings/audit?page=${page - 1}`} aria-label="Previous page" className={page <= 1 ? "pointer-events-none opacity-50" : undefined}>96                  <ChevronLeft /> Prev97                </Link>98              </Button>99              <Button asChild variant="ghost" size="xs" aria-disabled={page >= pages}>100                <Link href={`/dashboard/settings/audit?page=${page + 1}`} aria-label="Next page" className={page >= pages ? "pointer-events-none opacity-50" : undefined}>101                  Next <ChevronRight />102                </Link>103              </Button>104            </div>105          </div>106        </div>107      )}108    </div>109  );110}111