SPB Git

spb/findata

Public
9.5 KB · 200 lines tsx
1"use client";2/* eslint-disable @next/next/no-img-element */3import { useEffect, useState, type FormEvent } from "react";4import { QrCode, Radar, Save, ShieldCheck } from "lucide-react";5import { api, type SettingsOut } from "@/lib/api";6import { useAsync } from "@/lib/hooks";7import { useAuth } from "@/lib/auth";8import { useToast } from "@/lib/toast";9import { fmtInt, fmtTs, splitList } from "@/lib/format";10import { Banner, Card, Empty, ErrorState, Field, KV, PageHeader, StatusBadge } from "@/components/ui";11import { JsonView } from "@/components/JsonView";12import { CopyButton } from "@/components/CopyButton";1314export default function SettingsPage() {15  const { me, isOwner, refresh } = useAuth();16  const toast = useToast();17  const settings = useAsync((s) => api.get<SettingsOut>("/admin/settings", undefined, s), [], { enabled: isOwner });1819  // TOTP20  const [setup, setSetup] = useState<{ otpauth_uri: string; qr_png_data_url: string; secret: string } | null>(null);21  const [code, setCode] = useState("");22  const [busy, setBusy] = useState(false);2324  async function startSetup() {25    setBusy(true);26    try {27      setSetup(await api.post("/auth/totp/setup"));28    } catch (e) {29      toast.error(e);30    } finally {31      setBusy(false);32    }33  }34  async function verify(e: FormEvent) {35    e.preventDefault();36    setBusy(true);37    try {38      await api.post("/auth/totp/verify", { code: code.replace(/\s+/g, "") });39      toast.success("Two-factor authentication enabled");40      setSetup(null);41      setCode("");42      await refresh();43    } catch (err) {44      toast.error(err);45    } finally {46      setBusy(false);47    }48  }4950  // Tracked tickers51  const [tickers, setTickers] = useState("");52  const [tickersDirty, setTickersDirty] = useState(false);53  useEffect(() => {54    const row = settings.data?.settings.find((s) => s.key === "fundamentals_tracked_tickers");55    if (row && !tickersDirty) setTickers(Array.isArray(row.value) ? (row.value as string[]).join("\n") : "");56  }, [settings.data, tickersDirty]);57  async function saveTickers() {58    setBusy(true);59    try {60      const out = await api.put<{ tickers: string[] }>("/admin/settings/tracked-tickers", { tickers: splitList(tickers) });61      toast.success("Tracked tickers saved", `${out.tickers.length} tickers`);62      setTickers(out.tickers.join("\n"));63      setTickersDirty(false);64      settings.reload();65    } catch (e) {66      toast.error(e);67    } finally {68      setBusy(false);69    }70  }7172  // Discovery73  const [disc, setDisc] = useState<unknown>(null);74  async function discover() {75    setBusy(true);76    setDisc(null);77    try {78      setDisc(await api.post("/admin/discover"));79      toast.success("Discovery finished");80    } catch (e) {81      toast.error(e);82    } finally {83      setBusy(false);84    }85  }8687  const env = settings.data?.env;88  const otherSettings = (settings.data?.settings ?? []).filter((s) => s.key !== "fundamentals_tracked_tickers");8990  return (91    <>92      <PageHeader title="Settings" subtitle="Account security, environment flags and ingestion preferences" />93      <div className="grid gap-4 lg:grid-cols-2">94        <Card title="Account">95          <KV96            items={[97              ["E-mail", me?.email ?? "—"],98              ["Role", <StatusBadge key="r" status="neutral" label={me?.role ?? "—"} icon={false} />],99              ["Scopes", <div key="s" className="flex flex-wrap gap-1">{(me?.scopes ?? []).map((s) => <span key={s} className="pill bg-surface-2 text-ink-2">{s}</span>)}</div>],100              ["Two-factor (TOTP)", me?.totp_enabled ? <StatusBadge key="t" status="ok" label="enabled" /> : <StatusBadge key="t" status="warning" label="not enabled" />],101            ]}102          />103          <div className="mt-4 border-t border-hairline pt-3">104            {!setup ? (105              <button className="btn" onClick={startSetup} disabled={busy}>106                <ShieldCheck className="h-4 w-4" /> {me?.totp_enabled ? "Re-enrol authenticator" : "Enable two-factor authentication"}107              </button>108            ) : (109              <form onSubmit={verify} className="flex flex-col gap-3 sm:flex-row">110                <div className="shrink-0 rounded-md border border-border bg-white p-2 self-start">111                  <img src={setup.qr_png_data_url} alt="TOTP enrolment QR code" width={160} height={160} />112                </div>113                <div className="flex flex-1 flex-col gap-2">114                  <p className="text-sm text-ink-2">115                    <QrCode className="mr-1 inline h-4 w-4" /> Scan with your authenticator, or enter the secret manually:116                  </p>117                  <div className="flex items-center gap-2">118                    <code className="break-all rounded bg-surface-2 px-2 py-1 text-xs">{setup.secret}</code>119                    <CopyButton text={setup.secret} />120                  </div>121                  <Field label="Code from the app">122                    <input className="input font-mono tracking-[0.3em]" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} required />123                  </Field>124                  <div className="flex gap-2">125                    <button className="btn btn-primary" disabled={busy || code.replace(/\s+/g, "").length < 6}>126                      Verify & enable127                    </button>128                    <button type="button" className="btn" onClick={() => setSetup(null)}>129                      Cancel130                    </button>131                  </div>132                </div>133              </form>134            )}135          </div>136        </Card>137138        <Card title="Environment">139          {!isOwner && <Empty>Environment flags are visible to the owner only.</Empty>}140          {settings.error && <ErrorState error={settings.error} retry={settings.reload} />}141          {env && (142            <KV143              items={[144                ["Environment", <StatusBadge key="e" status={env.env === "production" ? "ok" : "warning"} label={env.env} />],145                ["Lake path", <code key="l" className="text-xs">{env.lake}</code>],146                ["Public URL", <a key="u" href={env.public_url} className="text-accent-ink hover:underline break-all" target="_blank" rel="noopener">{env.public_url}</a>],147                ["Backup target", <code key="b" className="text-xs">{env.backup_target}</code>],148                ["FirstRate Data vendor", <StatusBadge key="v" status={env.vendor_configured ? "ok" : "error"} label={env.vendor_configured ? "configured" : "not configured"} />],149                ["SEC EDGAR", <StatusBadge key="s" status={env.sec_edgar_configured ? "ok" : "pending_configuration"} label={env.sec_edgar_configured ? "configured" : "pending configuration"} />],150                ["AI providers", <span key="a" className="flex gap-1"><StatusBadge status={env.ai_providers.anthropic ? "ok" : "neutral"} label={`anthropic ${env.ai_providers.anthropic ? "yes" : "no"}`} icon={false} /><StatusBadge status={env.ai_providers.openai ? "ok" : "neutral"} label={`openai ${env.ai_providers.openai ? "yes" : "no"}`} icon={false} /></span>],151              ]}152            />153          )}154          <p className="mt-3 text-[11px] text-ink-3">Secrets are never sent to the browser; only configured yes/no flags are shown.</p>155        </Card>156157        {isOwner && (158          <Card title="Tracked tickers (fundamentals)" actions={<span className="text-xs text-ink-3">{fmtInt(splitList(tickers).length)} tickers</span>}>159            <textarea className="input min-h-40 font-mono uppercase" value={tickers} onChange={(e) => { setTickers(e.target.value); setTickersDirty(true); }} placeholder={"AAPL\nMSFT\nNVDA"} />160            <div className="mt-2 flex items-center justify-between gap-2">161              <span className="text-xs text-ink-3">Used by <code>fundamentals</code> jobs when no tickers are given. Max 2 000, deduplicated and upper-cased.</span>162              <button className="btn btn-primary" onClick={saveTickers} disabled={busy || !tickersDirty}>163                <Save className="h-4 w-4" /> Save164              </button>165            </div>166          </Card>167        )}168169        {isOwner && (170          <Card title="Vendor discovery" actions={<button className="btn btn-sm" onClick={discover} disabled={busy}><Radar className="h-3.5 w-3.5" /> {busy ? "Probing…" : "Run discovery"}</button>}>171            <p className="text-xs text-ink-2">Live, sanitized probe of FirstRate Data and SEC EDGAR (no bulk download). Output is redacted server-side and recorded in the audit log.</p>172            <div className="mt-2">{disc ? <JsonView value={disc} maxHeight="28rem" /> : <Empty>No discovery run yet in this session.</Empty>}</div>173          </Card>174        )}175176        {isOwner && otherSettings.length > 0 && (177          <Card title="Other settings (read-only)" className="lg:col-span-2">178            <ul className="flex flex-col gap-2">179              {otherSettings.map((s) => (180                <li key={s.key} className="text-sm">181                  <div className="flex items-center justify-between gap-2">182                    <code className="text-xs">{s.key}</code>183                    <span className="tnum text-xs text-ink-3">{fmtTs(s.updated_at)}</span>184                  </div>185                  <JsonView value={s.value} collapsed />186                </li>187              ))}188            </ul>189          </Card>190        )}191      </div>192      {!me?.totp_enabled && (193        <Banner tone="warn" className="mt-4">194          Two-factor authentication is not enabled on this account. Enable it above — the owner role controls jobs, keys and storage.195        </Banner>196      )}197    </>198  );199}200
Files · fe690344fa9a90f9edec7abe72fc51ac18e87113
Menu

Theme