1"use client";2import { useMemo, useState, type FormEvent } from "react";3import { KeyRound, Plus, Trash2 } from "lucide-react";4import { api, type ApiKey, type UsageRow } from "@/lib/api";5import { useAsync } from "@/lib/hooks";6import { useAuth } from "@/lib/auth";7import { useToast } from "@/lib/toast";8import { fmtBytes, fmtCompact, fmtInt, fmtRelative, fmtTs } from "@/lib/format";9import { Banner, Card, ErrorState, Field, PageHeader, StatusBadge } from "@/components/ui";10import { DataTable } from "@/components/DataTable";11import { ConfirmDialog, Modal } from "@/components/ConfirmDialog";12import { CopyButton } from "@/components/CopyButton";1314const SCOPES: { id: string; help: string }[] = [15 { id: "read:market", help: "bars, sessions, coverage, catalog, instruments" },16 { id: "read:fundamentals", help: "point-in-time fundamentals" },17 { id: "query", help: "SQL playground (DuckDB, read-only)" },18 { id: "export", help: "async Parquet/CSV exports" },19 { id: "admin", help: "jobs, keys, storage, settings — owner only" },20];2122export default function KeysPage() {23 const { isOwner } = useAuth();24 const toast = useToast();25 const { data, error, loading, reload } = useAsync((s) => api.get<{ data: ApiKey[]; usage: UsageRow[] }>("/admin/keys", undefined, s), [], { enabled: isOwner });26 const [create, setCreate] = useState(false);27 const [revoke, setRevoke] = useState<ApiKey | null>(null);28 const [busy, setBusy] = useState(false);29 const [token, setToken] = useState<{ token: string; name: string } | null>(null);3031 const usageByPrincipal = useMemo(() => {32 const m = new Map<string, { requests: number; rows: number; bytes: number; last: string }>();33 for (const u of data?.usage ?? []) {34 const e = m.get(u.principal) ?? { requests: 0, rows: 0, bytes: 0, last: u.day };35 e.requests += Number(u.requests);36 e.rows += Number(u.rows_returned);37 e.bytes += Number(u.bytes_exported);38 if (u.day > e.last) e.last = u.day;39 m.set(u.principal, e);40 }41 return m;42 }, [data]);4344 if (!isOwner) return <Banner tone="warn">API keys are managed by the owner.</Banner>;4546 async function doRevoke() {47 if (!revoke) return;48 setBusy(true);49 try {50 await api.del(`/admin/keys/${revoke.id}`);51 toast.success("Key revoked", revoke.name);52 setRevoke(null);53 reload();54 } catch (e) {55 toast.error(e);56 } finally {57 setBusy(false);58 }59 }6061 return (62 <>63 <PageHeader64 title="API keys"65 subtitle="Bearer tokens for the SDK and scripts. Usage is aggregated per day (last 14 days)."66 actions={67 <button className="btn btn-sm btn-primary" onClick={() => setCreate(true)}>68 <Plus className="h-3.5 w-3.5" /> New key69 </button>70 }71 />72 {error && <ErrorState error={error} retry={reload} />}73 <Card>74 <DataTable75 rows={data?.data}76 loading={loading}77 rowKey={(k) => k.id}78 mobile="cards"79 columns={[80 { key: "name", header: "Name", render: (k) => (<div><div className="font-medium">{k.name}</div><div className="font-mono text-[11px] text-ink-3">{k.prefix}…</div></div>) },81 { key: "scopes", header: "Scopes", render: (k) => <div className="flex flex-wrap gap-1">{k.scopes.map((s) => <span key={s} className="pill bg-surface-2 text-ink-2">{s}</span>)}</div> },82 { key: "rate_limit_per_minute", header: "Rate/min", num: true, hideSm: true, render: (k) => fmtInt(k.rate_limit_per_minute) },83 { key: "last_used_at", header: "Last used", render: (k) => <span className="tnum text-xs" title={fmtTs(k.last_used_at)}>{k.last_used_at ? fmtRelative(k.last_used_at) : "never"}</span> },84 {85 key: "usage",86 header: "Usage (14 d)",87 render: (k) => {88 const u = usageByPrincipal.get(`api_key:${k.id}`) ?? usageByPrincipal.get(`key:${k.prefix}`);89 return u ? <span className="tnum text-xs">{fmtInt(u.requests)} req · {fmtCompact(u.rows)} rows · {fmtBytes(u.bytes)}</span> : <span className="text-ink-3">—</span>;90 },91 },92 { key: "expires_at", header: "Expires", hideSm: true, render: (k) => <span className="tnum text-xs">{k.expires_at ? fmtTs(k.expires_at) : "never"}</span> },93 { key: "status", header: "Status", render: (k) => <StatusBadge status={k.revoked_at ? "error" : k.expires_at && new Date(k.expires_at) < new Date() ? "expired" : "active"} label={k.revoked_at ? "revoked" : k.expires_at && new Date(k.expires_at) < new Date() ? "expired" : "active"} /> },94 { key: "actions", header: "", render: (k) => (!k.revoked_at ? (<button className="btn btn-sm" onClick={() => setRevoke(k)}><Trash2 className="h-3.5 w-3.5" /> Revoke</button>) : null) },95 ]}96 empty="No API keys yet."97 />98 </Card>99100 <Card title="Daily usage (all principals, 14 days)" className="mt-4">101 <DataTable102 rows={data?.usage}103 loading={loading}104 rowKey={(u) => `${u.principal}-${u.day}`}105 mobile="scroll"106 maxHeight="20rem"107 columns={[108 { key: "day", header: "Day", render: (u) => <span className="tnum">{u.day}</span> },109 { key: "principal", header: "Principal", mono: true },110 { key: "requests", header: "Requests", num: true, render: (u) => fmtInt(u.requests) },111 { key: "rows_returned", header: "Rows", num: true, render: (u) => fmtInt(u.rows_returned) },112 { key: "bytes_exported", header: "Exported", num: true, render: (u) => fmtBytes(u.bytes_exported) },113 ]}114 empty="No usage recorded."115 />116 </Card>117118 <Modal open={create} title="Create API key" onClose={() => setCreate(false)}>119 <CreateKeyForm120 onCreated={(t, name) => {121 setCreate(false);122 setToken({ token: t, name });123 reload();124 }}125 />126 </Modal>127128 <Modal open={!!token} title="Key created — copy it now" onClose={() => setToken(null)}>129 {token && (130 <div className="flex flex-col gap-3">131 <Banner tone="warn">This token is shown <strong>once</strong>. Store it in a password manager; the server keeps only a hash.</Banner>132 <div className="flex items-center gap-2 rounded-md border border-border bg-surface-2 p-2">133 <KeyRound className="h-4 w-4 shrink-0 text-ink-3" />134 <code className="flex-1 break-all text-xs">{token.token}</code>135 <CopyButton text={token.token} />136 </div>137 <pre className="overflow-auto rounded-md border border-hairline bg-surface-2 p-2 text-xs">{`export FINDATA_API_KEY="${token.token}"\ncurl -H "Authorization: Bearer $FINDATA_API_KEY" "${typeof window !== "undefined" ? window.location.origin : ""}/api/v1/health"`}</pre>138 <button className="btn btn-primary self-end" onClick={() => setToken(null)}>Done</button>139 </div>140 )}141 </Modal>142143 <ConfirmDialog open={!!revoke} title={`Revoke “${revoke?.name}”?`} confirmLabel="Revoke" danger busy={busy} onCancel={() => setRevoke(null)} onConfirm={() => void doRevoke()}>144 Every client using this key will start receiving 401 immediately. This cannot be undone.145 </ConfirmDialog>146 </>147 );148}149150function CreateKeyForm({ onCreated }: { onCreated: (token: string, name: string) => void }) {151 const toast = useToast();152 const [name, setName] = useState("");153 const [scopes, setScopes] = useState<string[]>(["read:market"]);154 const [rate, setRate] = useState(600);155 const [expires, setExpires] = useState("");156 const [busy, setBusy] = useState(false);157158 async function submit(e: FormEvent) {159 e.preventDefault();160 setBusy(true);161 try {162 const out = await api.post<{ id: string; token: string }>("/admin/keys", {163 name: name.trim(),164 scopes,165 rate_limit_per_minute: rate,166 expires_at: expires ? new Date(expires).toISOString() : null,167 });168 onCreated(out.token, name.trim());169 } catch (err) {170 toast.error(err);171 } finally {172 setBusy(false);173 }174 }175176 return (177 <form onSubmit={submit} className="flex flex-col gap-3">178 <Field label="Name">179 <input className="input" value={name} onChange={(e) => setName(e.target.value)} maxLength={60} required placeholder="research-laptop" autoFocus />180 </Field>181 <fieldset>182 <legend className="label">Scopes</legend>183 <div className="flex flex-col gap-1.5">184 {SCOPES.map((s) => (185 <label key={s.id} className="flex items-start gap-2 text-sm">186 <input type="checkbox" className="mt-0.5" checked={scopes.includes(s.id)} onChange={(e) => setScopes((cur) => (e.target.checked ? [...cur, s.id] : cur.filter((x) => x !== s.id)))} />187 <span>188 <code className="text-xs">{s.id}</code> <span className="text-xs text-ink-3">— {s.help}</span>189 </span>190 </label>191 ))}192 </div>193 </fieldset>194 <div className="grid grid-cols-2 gap-2">195 <Field label="Rate limit / minute">196 <input type="number" className="input" min={1} max={10000} value={rate} onChange={(e) => setRate(Number(e.target.value) || 600)} />197 </Field>198 <Field label="Expires (optional)">199 <input type="datetime-local" className="input" value={expires} onChange={(e) => setExpires(e.target.value)} />200 </Field>201 </div>202 <div className="flex justify-end pt-1">203 <button className="btn btn-primary" disabled={busy || !name.trim() || scopes.length === 0}>204 {busy ? "Creating…" : "Create key"}205 </button>206 </div>207 </form>208 );209}210