SPB Git

spb/findata

Public
9.6 KB · 210 lines tsx
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
Files · main
Menu

Theme