SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
5.5 KB · 100 lines tsx
Raw Blame History
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