TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import Link from "next/link";5import { X } from "lucide-react";6import { useAdminQuery } from "@/components/admin/use-query";7import type { SecurityEventRow, SecurityEventsResponse } from "@/components/admin/types";8import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, DenseInput, DenseSelect, SeverityPill, JsonToggle, Mono, type Column } from "@/components/admin/primitives";9import { dateTime, int, num } from "@/components/admin/format";10import { cn } from "@/lib/utils";1112const SEVERITIES = ["", "info", "warn", "high"] as const;13const LIMITS = [50, 100, 200] as const;1415const cols: Column<SecurityEventRow>[] = [16 { key: "at", header: "Time", render: (e) => <span className="whitespace-nowrap text-fg-3">{dateTime(e.createdAt)}</span> },17 { key: "type", header: "Type", mono: true, render: (e) => <Mono className="text-fg">{e.type}</Mono> },18 { key: "sev", header: "Severity", render: (e) => <SeverityPill severity={e.severity} /> },19 { key: "user", header: "User", render: (e) => (e.userId ? <Link href={`/admin/users/${e.userId}`} className="hover:text-accent-2">{e.username ?? e.userId.slice(0, 8)}</Link> : e.adminId ? <span className="text-fg-3">admin</span> : <span className="text-fg-4">—</span>) },20 { key: "ip", header: "IP", mono: true, render: (e) => <Mono>{e.ip ?? "—"}</Mono> },21 { key: "ua", header: "Agent", render: (e) => <span className="block max-w-[220px] truncate text-fg-3" title={e.userAgent ?? ""}>{e.userAgent ?? "—"}</span> },22 { key: "meta", header: "Meta", render: (e) => <JsonToggle value={e.meta} /> },23];2425export default function AdminSecurityPage() {26 const [type, setType] = React.useState("");27 const [severity, setSeverity] = React.useState<(typeof SEVERITIES)[number]>("");28 const [limit, setLimit] = React.useState<(typeof LIMITS)[number]>(100);2930 const params = new URLSearchParams({ limit: String(limit) });31 if (type) params.set("type", type);32 if (severity) params.set("severity", severity);33 const q = useAdminQuery<SecurityEventsResponse>(`/api/admin/security/events?${params.toString()}`, { refreshMs: 30_000 });34 const d = q.data;3536 const high = d?.events.filter((e) => e.severity === "high").length ?? 0;37 const total24h = d?.summary24h.reduce((a, s) => a + num(s.n), 0) ?? 0;3839 return (40 <>41 <PageHeader title="Security" description="Authentication, admin actions, CSRF rejections and rate-limit hits. Newest first." actions={<RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />} />4243 {d ? (44 <div className="mb-3 flex flex-wrap items-center gap-1.5">45 <span className="mr-1 text-[11px] font-semibold uppercase tracking-wider text-fg-4">Last 24 h · {int(total24h)}</span>46 {d.summary24h.length === 0 ? <span className="text-[12px] text-fg-4">quiet — no events</span> : null}47 {d.summary24h.map((s) => (48 <button key={s.type} type="button" onClick={() => setType((t) => (t === s.type ? "" : s.type))} className={cn("inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 font-mono text-[11px] transition-colors focus-ring", type === s.type ? "border-accent/50 bg-accent-soft text-accent-2" : "border-line bg-surface text-fg-2 hover:bg-surface-2")}>49 {s.type} <span className="text-fg-4">{int(s.n)}</span>50 </button>51 ))}52 </div>53 ) : null}5455 <div className="mb-3 flex flex-wrap items-center gap-2">56 <div className="relative w-full sm:w-72">57 <DenseInput placeholder="Filter by exact type (e.g. login.failed)" value={type} onChange={(e) => setType(e.target.value.trim())} aria-label="Event type" list="security-types" className={type ? "pr-8" : undefined} />58 {type ? (59 <button type="button" onClick={() => setType("")} className="absolute right-2 top-1/2 -translate-y-1/2 text-fg-4 hover:text-fg" aria-label="Clear type filter">60 <X className="h-3.5 w-3.5" />61 </button>62 ) : null}63 <datalist id="security-types">{d?.summary24h.map((s) => <option key={s.type} value={s.type} />)}</datalist>64 </div>65 <DenseSelect value={severity} onChange={(e) => setSeverity(e.target.value as (typeof SEVERITIES)[number])} aria-label="Severity">66 {SEVERITIES.map((s) => (67 <option key={s} value={s}>68 {s ? s : "All severities"}69 </option>70 ))}71 </DenseSelect>72 <DenseSelect value={limit} onChange={(e) => setLimit(Number(e.target.value) as (typeof LIMITS)[number])} aria-label="Limit">73 {LIMITS.map((l) => (74 <option key={l} value={l}>75 Last {l}76 </option>77 ))}78 </DenseSelect>79 {d ? (80 <span className="ml-auto text-[12px] text-fg-3 tabular">81 {int(d.events.length)} shown · <span className={high ? "text-danger" : ""}>{int(high)} high</span>82 </span>83 ) : null}84 </div>8586 <Panel padded={false}>87 {q.error && !d ? (88 <div className="p-4">89 <ErrorState error={q.error} onRetry={() => void q.refresh()} />90 </div>91 ) : !d ? (92 <TableSkeleton rows={10} cols={7} />93 ) : (94 <DataTable columns={cols} rows={d.events} rowKey={(e) => e.id} dense stale={q.stale} empty={type || severity ? "No events match these filters." : "No security events recorded."} rowClassName={(e) => (e.severity === "high" ? "bg-danger/5" : undefined)} />95 )}96 </Panel>97 </>98 );99}100