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