SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

web: dashboard/admin/auth alignés sur le contrat réel — api.js (JSON + X-Requested-With sur toute mutation, apiData, friendlyError), auth.jsx (user plat + limits/auth, erreur de déconnexion visible), Overview (totaux du jour, 429, quotas en direct), Usage (points t, filtre clé, CSV serveur), Keys (note/expiration, clé affichée une fois), Account (mdp, e-mail, alertes, sign out everywhere, suppression), admin stable (fiche, suppression, filtres audit, bandeau dernier admin), Verify en POST + replaceState, ErrorBoundary + toasts + skeletons

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 18 days ago (Sep 6, 2026) parent 9fb13cb

18 changed files +1,068 −328

modified hfmarketdata/web/src/app/api.js +70 −3
@@ -1,5 +1,12 @@
1 1 // Tiny API client shared by every page. Injects the session key (dashboard playground) when present,
2 2 // exposes rate-limit headers on every result, never logs keys.
3 +//
4 +// Contract reminders (see docs/accounts-ratelimit.md):
5 +// * every v2 answer is an envelope `{ data, meta }` — `api()` returns the envelope as `.data`; use `apiData()` to
6 +// get the inner `data` directly;
7 +// * state-changing calls (POST/PATCH/DELETE) ALWAYS carry `Content-Type: application/json` + a `{}` body when
8 +// nothing else is sent, plus `X-Requested-With: hfmd` — that is what the server's CSRF guard checks (a bare
9 +// bodiless POST would be refused with 415).
3 10 export const BASE_URL = import.meta.env.VITE_API_BASE || ''
4 11 export const PUBLIC_BASE = 'https://www.hfmarketdata.io'
5 12 export const CONTACT_EMAIL = 'contact@spboucher.ai'
@@ -10,8 +17,11 @@ export const TIERS = [
10 17 { id: 'keyless', name: 'Keyless (per IP)', window: 'hour', requests: 30, rows: 100_000, maxRows: 5_000 },
11 18 { id: 'free', name: 'Free account + API key', window: 'minute', requests: 120, rows: 1_000_000, maxRows: 50_000 },
12 19 { id: 'high_usage', name: 'Higher limits (free, on request)', window: 'minute', requests: 600, rows: 10_000_000, maxRows: 200_000 },
20 + { id: 'unlimited', name: 'Unlimited (internal)', window: 'minute', requests: 1_000_000_000, rows: 1_000_000_000_000, maxRows: 5_000_000 },
13 21 ]
14 22
23 +const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
24 +
15 25 export function rateHeaders(res) {
16 26 const h = res.headers
17 27 const num = k => (h.get(k) == null ? null : Number(h.get(k)))
@@ -27,29 +37,86 @@ export function rateHeaders(res) {
27 37 }
28 38
29 39 export async function api(path, { method = 'GET', body, apiKey, headers = {}, raw = false, signal } = {}) {
40 + const m = method.toUpperCase()
30 41 const h = { Accept: 'application/json', ...headers }
31 − if (body !== undefined) h['Content-Type'] = 'application/json'
42 + let payload
43 + if (MUTATING.has(m)) {
44 + h['Content-Type'] = 'application/json'
45 + h['X-Requested-With'] = 'hfmd'
46 + payload = JSON.stringify(body === undefined ? {} : body)
47 + } else if (body !== undefined) {
48 + h['Content-Type'] = 'application/json'
49 + payload = JSON.stringify(body)
50 + }
32 51 if (apiKey) h.Authorization = `Bearer ${apiKey}`
33 52 const t0 = performance.now()
34 − const res = await fetch(BASE_URL + path, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'include', signal })
53 + let res
54 + try {
55 + res = await fetch(BASE_URL + path, { method: m, headers: h, body: payload, credentials: 'include', signal })
56 + } catch (e) {
57 + if (e?.name === 'AbortError') throw e
58 + const err = new Error('Network error — please check your connection.')
59 + err.status = 0
60 + err.code = 'NETWORK'
61 + err.cause = e
62 + throw err
63 + }
35 64 const ms = Math.round(performance.now() - t0)
36 65 const rate = rateHeaders(res)
37 66 if (raw) return { res, ms, rate }
38 67 const ct = res.headers.get('content-type') || ''
39 − const data = ct.includes('json') ? await res.json() : await res.text()
68 + const data = ct.includes('json') ? await res.json().catch(() => null) : await res.text()
40 69 if (!res.ok) {
41 70 const err = new Error(data?.error?.message || data?.detail || `HTTP ${res.status}`)
42 71 err.status = res.status
43 72 err.code = data?.error?.code
44 73 err.body = data
45 74 err.rate = rate
75 + err.details = data?.error?.details
46 76 throw err
47 77 }
48 78 return { data, ms, rate, status: res.status }
49 79 }
50 80
81 +/** Same as `api()` but returns the inner `data` of the `{ data, meta }` envelope (and the meta as second field). */
82 +export async function apiData(path, opts) {
83 + const r = await api(path, opts)
84 + const env = r.data
85 + return env && typeof env === 'object' && 'data' in env ? env.data : env
86 +}
87 +
51 88 export function buildUrl(path, params = {}) {
52 89 const u = new URL(path, PUBLIC_BASE)
53 90 Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, v) })
54 91 return u.toString()
55 92 }
93 +
94 +/** Human message for an API error (shared by the auth pages, the dashboard and the admin area). */
95 +const ERRORS = {
96 + INVALID_CREDENTIALS: 'Incorrect e-mail or password.',
97 + ACCOUNT_LOCKED: 'Too many failed attempts — this account is temporarily locked. Try again later or reset your password.',
98 + EMAIL_TAKEN: 'An account already exists for this e-mail. Try signing in or resetting your password.',
99 + EMAIL_NOT_VERIFIED: 'Verify your e-mail first — check your inbox for the link, or sign up again to receive a new one.',
100 + ACCOUNT_DISABLED: `This account is disabled. Contact ${CONTACT_EMAIL}.`,
101 + INVALID_TOKEN: 'This link is invalid or has expired. Request a new one.',
102 + WEAK_PASSWORD: 'Use at least 10 characters.',
103 + RATE_LIMIT_EXCEEDED: 'Too many attempts. Please wait a moment and try again.',
104 + AUTH_REQUIRED: 'Please sign in.',
105 + SESSION_REQUIRED: 'This action needs a signed-in browser session — please sign in again.',
106 + FORBIDDEN: 'You are not allowed to do this.',
107 + LAST_ADMIN: 'This is the last active administrator — promote someone else first.',
108 + KEY_LIMIT_REACHED: 'You already have 10 active keys. Revoke one first.',
109 + KEY_NOT_FOUND: 'This key no longer exists.',
110 + USER_NOT_FOUND: 'This user no longer exists.',
111 + VALIDATION_ERROR: 'Please check the highlighted fields.',
112 + UNSUPPORTED_MEDIA_TYPE: 'The request was refused by the CSRF guard — reload the page and try again.',
113 + NETWORK: 'Network error — please check your connection.',
114 +}
115 +export function friendlyError(e) {
116 + if (!e) return ''
117 + if (e.status === 404 && !e.code) return 'This feature is not available on this server yet.'
118 + if (e.status === 0 || e.code === 'NETWORK' || e.name === 'TypeError') return ERRORS.NETWORK
119 + if (e.code === 'ACCOUNT_LOCKED' && e.details?.retry_after) return `Too many failed attempts — locked for ${Math.ceil(e.details.retry_after / 60) || 1} min. Try again later or reset your password.`
120 + if (e.code === 'RATE_LIMIT_EXCEEDED' && e.rate?.retryAfter) return `Too many attempts. Retry in ${e.rate.retryAfter} s.`
121 + return ERRORS[e.code] || e.message || 'Something went wrong.'
122 +}
modified hfmarketdata/web/src/app/auth.jsx +33 −8
@@ -1,29 +1,54 @@
1 −// Session context: who is signed in, their tier, and the API key to inject in the authenticated playground.
1 +// Session context: who is signed in, their tier/limits, and how the session was authenticated.
2 2 // Backed by cookie session endpoints under /v1/auth and /v1/me (owned by the accounts module).
3 −import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
3 +//
4 +// `GET /v1/me` answers `{ data: { user: {…}, limits: {…}, auth: "session" | "key" } }`. We expose a FLAT `user`
5 +// (id, email, name, role, tier, status, keys_active, quota_alerts, …) with `limits` and `auth` attached, so the
6 +// layout, the playground and the dashboard can read `user.email`, `user.role`, `user.tier`, `user.limits`.
7 +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
4 8 import { api } from './api.js'
5 9
6 −const Ctx = createContext({ user: null, loading: true, refresh: () => {}, signout: () => {} })
10 +const Ctx = createContext({ user: null, loading: true, error: null, refresh: async () => null, signout: async () => {}, signoutError: null })
11 +
12 +export function normalizeMe(body) {
13 + const d = body?.data ?? body
14 + if (!d) return null
15 + if (d.user && typeof d.user === 'object') return { ...d.user, limits: d.limits || null, auth: d.auth || null }
16 + return d.email ? d : null
17 +}
7 18
8 19 export function AuthProvider({ children }) {
9 20 const [user, setUser] = useState(null)
10 21 const [loading, setLoading] = useState(true)
22 + const [error, setError] = useState(null)
23 + const [signoutError, setSignoutError] = useState(null)
11 24 const refresh = useCallback(async () => {
12 25 try {
13 26 const { data } = await api('/v1/me')
14 − setUser(data.data || data)
15 − } catch {
27 + const u = normalizeMe(data)
28 + setUser(u)
29 + setError(null)
30 + return u
31 + } catch (e) {
16 32 setUser(null)
33 + setError(e.status === 401 ? null : e) // anonymous is not an error; network / 5xx are
34 + return null
17 35 } finally {
18 36 setLoading(false)
19 37 }
20 38 }, [])
21 39 const signout = useCallback(async () => {
22 − try { await api('/v1/auth/logout', { method: 'POST' }) } catch { /* ignore */ }
23 − setUser(null)
40 + setSignoutError(null)
41 + try {
42 + await api('/v1/auth/logout', { method: 'POST' })
43 + setUser(null)
44 + } catch (e) {
45 + setSignoutError(e)
46 + throw e // the caller shows the error — the cookie is still valid, do NOT pretend we are signed out
47 + }
24 48 }, [])
25 49 useEffect(() => { refresh() }, [refresh])
26 − return <Ctx.Provider value={{ user, loading, refresh, signout }}>{children}</Ctx.Provider>
50 + const value = useMemo(() => ({ user, loading, error, refresh, signout, signoutError, setUser }), [user, loading, error, refresh, signout, signoutError])
51 + return <Ctx.Provider value={value}>{children}</Ctx.Provider>
27 52 }
28 53
29 54 export const useAuth = () => useContext(Ctx)
modified hfmarketdata/web/src/pages/admin/Admin.jsx +30 −22
@@ -1,7 +1,9 @@
1 −// Admin area (role === 'admin'): users, global usage, audit log.
1 +// Admin area (role === 'admin'): users, global usage, audit log. Cookie session required (an API key is refused).
2 2 import React from 'react'
3 −import { Navigate, NavLink, Route, Routes } from 'react-router-dom'
4 −import { RequireAuth } from '../dashboard/Dashboard.jsx'
3 +import { Navigate, NavLink, Route, Routes, useLocation } from 'react-router-dom'
4 +import { RequireAuth, SignOutButton } from '../dashboard/Dashboard.jsx'
5 +import ErrorBoundary from '../dashboard/ErrorBoundary.jsx'
6 +import { ToastProvider } from '../dashboard/ui.jsx'
5 7 import '../dashboard/dashboard.css'
6 8 import AdminAudit from './AdminAudit.jsx'
7 9 import AdminUsage from './AdminUsage.jsx'
@@ -9,27 +11,33 @@ import AdminUsers from './AdminUsers.jsx'
9 11 import './admin.css'
10 12
11 13 export default function Admin() {
14 + const location = useLocation()
12 15 return (
13 16 <RequireAuth role="admin">
14 − <main className="page dash adm" data-testid="admin">
15 − <aside className="dash-side">
16 − <div className="dash-user"><div className="dash-user-name">Administration</div><div className="muted dash-user-mail">role: admin</div></div>
17 − <nav className="dash-nav" aria-label="Admin">
18 − <NavLink to="" end className={({ isActive }) => (isActive ? 'active' : '')}>Users</NavLink>
19 − <NavLink to="usage" className={({ isActive }) => (isActive ? 'active' : '')}>Global usage</NavLink>
20 − <NavLink to="audit" className={({ isActive }) => (isActive ? 'active' : '')}>Audit log</NavLink>
21 − <NavLink to="/dashboard" className="dash-nav-admin">← My dashboard</NavLink>
22 − </nav>
23 − </aside>
24 − <section className="dash-main">
25 − <Routes>
26 − <Route index element={<AdminUsers />} />
27 − <Route path="usage" element={<AdminUsage />} />
28 − <Route path="audit" element={<AdminAudit />} />
29 − <Route path="*" element={<Navigate to="" replace />} />
30 − </Routes>
31 − </section>
32 − </main>
17 + <ToastProvider>
18 + <main className="page dash adm" data-testid="admin">
19 + <aside className="dash-side">
20 + <div className="dash-user"><div className="dash-user-name">Administration</div><div className="muted dash-user-mail">role: admin</div></div>
21 + <nav className="dash-nav" aria-label="Admin">
22 + <NavLink to="" end className={({ isActive }) => (isActive ? 'active' : '')}>Users</NavLink>
23 + <NavLink to="usage" className={({ isActive }) => (isActive ? 'active' : '')}>Global usage</NavLink>
24 + <NavLink to="audit" className={({ isActive }) => (isActive ? 'active' : '')}>Audit log</NavLink>
25 + <NavLink to="/dashboard" className="dash-nav-admin">← My dashboard</NavLink>
26 + </nav>
27 + <div className="dash-side-foot"><SignOutButton /></div>
28 + </aside>
29 + <section className="dash-main">
30 + <ErrorBoundary resetKey={location.pathname}>
31 + <Routes>
32 + <Route index element={<AdminUsers />} />
33 + <Route path="usage" element={<AdminUsage />} />
34 + <Route path="audit" element={<AdminAudit />} />
35 + <Route path="*" element={<Navigate to="" replace />} />
36 + </Routes>
37 + </ErrorBoundary>
38 + </section>
39 + </main>
40 + </ToastProvider>
33 41 </RequireAuth>
34 42 )
35 43 }
modified hfmarketdata/web/src/pages/admin/AdminAudit.jsx +54 −35
@@ -1,49 +1,68 @@
1 −// Audit log: GET /v1/admin/audit → [{ts, actor, action, target, meta}].
2 −import React, { useEffect, useMemo, useState } from 'react'
1 +// Audit log: GET /v1/admin/audit?limit=&action=&actor=&target=&cursor= → [{ id, ts, actor, action, target, meta }].
2 +import React, { useCallback, useEffect, useState } from 'react'
3 3 import { api } from '../../app/api.js'
4 −import PgCallout from '../../components/PgCallout.jsx'
5 −import { errText } from './AdminUsers.jsx'
4 +import { ErrorNotice, Skeleton } from '../dashboard/ui.jsx'
6 5
7 −const list = r => (Array.isArray(r) ? r : Array.isArray(r?.data) ? r.data : Array.isArray(r?.items) ? r.items : [])
6 +const ACTIONS = ['', 'user.', 'account.', 'session.', 'key.', 'token.']
8 7
9 8 export default function AdminAudit() {
10 9 const [rows, setRows] = useState(null)
10 + const [next, setNext] = useState(null)
11 11 const [err, setErr] = useState(null)
12 − const [q, setQ] = useState('')
13 − useEffect(() => {
14 − let alive = true
15 − api('/v1/admin/audit?limit=500').then(r => alive && setRows(list(r.data))).catch(e => { if (alive) { setErr(e); setRows([]) } })
16 − return () => { alive = false }
17 − }, [])
18 − const filtered = useMemo(() => {
19 − const s = q.trim().toLowerCase()
20 − return (rows || []).filter(r => !s || `${r.actor} ${r.action} ${r.target} ${JSON.stringify(r.meta || '')}`.toLowerCase().includes(s))
21 − }, [rows, q])
12 + const [f, setF] = useState({ action: '', actor: '', target: '', q: '' })
13 + const [applied, setApplied] = useState({ action: '', actor: '', target: '' })
14 + const load = useCallback(async (cursor = null) => {
15 + try {
16 + const p = new URLSearchParams({ limit: '200' })
17 + if (applied.action) p.set('action', applied.action)
18 + if (applied.actor) p.set('actor', applied.actor)
19 + if (applied.target) p.set('target', applied.target)
20 + if (cursor) p.set('cursor', cursor)
21 + const r = await api(`/v1/admin/audit?${p}`)
22 + const list = Array.isArray(r.data?.data) ? r.data.data : []
23 + setRows(prev => (cursor ? [...(prev || []), ...list] : list))
24 + setNext(r.data?.meta?.next_cursor || null)
25 + setErr(null)
26 + } catch (e) { setErr(e); setRows(prev => prev || []) }
27 + }, [applied])
28 + useEffect(() => { setRows(null); load() }, [load])
29 + const submit = e => { e.preventDefault(); setApplied({ action: f.action.trim(), actor: f.actor.trim(), target: f.target.trim() }) }
30 + const s = f.q.trim().toLowerCase()
31 + const filtered = (rows || []).filter(r => !s || `${r.actor} ${r.action} ${r.target} ${JSON.stringify(r.meta || '')}`.toLowerCase().includes(s))
22 32 return (
23 33 <div className="dash-section">
24 34 <div className="dash-head-row">
25 35 <h1>Audit log</h1>
26 − <input type="search" placeholder="Filter actor, action, target…" value={q} onChange={e => setQ(e.target.value)} aria-label="Filter audit log" className="adm-search" />
27 − </div>
28 − {err && <PgCallout kind={err.status === 404 && !err.code ? 'warn' : 'danger'}>{errText(err)}</PgCallout>}
29 − <div className="dash-table-wrap">
30 − <table className="dash-table" data-testid="audit-table">
31 − <thead><tr><th>Time (UTC)</th><th>Actor</th><th>Action</th><th>Target</th><th>Meta</th></tr></thead>
32 − <tbody>
33 − {rows === null && <tr><td colSpan={5} className="muted">Loading…</td></tr>}
34 − {rows && filtered.length === 0 && <tr><td colSpan={5} className="muted">No entries.</td></tr>}
35 − {filtered.map((r, i) => (
36 − <tr key={r.id || i}>
37 − <td className="mono">{String(r.ts || r.created_at || '').replace('T', ' ').slice(0, 19)}</td>
38 − <td className="mono">{r.actor}</td>
39 − <td><span className="dash-badge">{r.action}</span></td>
40 − <td className="mono">{r.target}</td>
41 − <td className="adm-meta mono">{r.meta ? (typeof r.meta === 'string' ? r.meta : JSON.stringify(r.meta)) : ''}</td>
42 − </tr>
43 − ))}
44 − </tbody>
45 − </table>
36 + <input type="search" placeholder="Filter loaded rows…" value={f.q} onChange={e => setF(v => ({ ...v, q: e.target.value }))} aria-label="Filter audit log" className="adm-search" />
46 37 </div>
38 + <form className="dash-filters" onSubmit={submit} aria-label="Server-side filters">
39 + <select aria-label="Action" value={f.action} onChange={e => setF(v => ({ ...v, action: e.target.value }))}>{ACTIONS.map(a => <option key={a} value={a}>{a ? `${a}*` : 'All actions'}</option>)}</select>
40 + <input placeholder="actor (user:7, cli)" value={f.actor} onChange={e => setF(v => ({ ...v, actor: e.target.value }))} aria-label="Actor" />
41 + <input placeholder="target (user:7, key:12)" value={f.target} onChange={e => setF(v => ({ ...v, target: e.target.value }))} aria-label="Target" />
42 + <button type="submit" className="btn btn-sm" data-testid="audit-apply">Apply</button>
43 + {(applied.action || applied.actor || applied.target) && <button type="button" className="btn btn-sm" onClick={() => { setF(v => ({ ...v, action: '', actor: '', target: '' })); setApplied({ action: '', actor: '', target: '' }) }}>Clear</button>}
44 + </form>
45 + <ErrorNotice error={err} onRetry={() => load()} />
46 + {rows === null ? <div className="card"><Skeleton lines={5} /></div> : (
47 + <div className="dash-table-wrap">
48 + <table className="dash-table" data-testid="audit-table">
49 + <thead><tr><th>Time (UTC)</th><th>Actor</th><th>Action</th><th>Target</th><th>Meta</th></tr></thead>
50 + <tbody>
51 + {filtered.length === 0 && <tr><td colSpan={5} className="muted">No entries.</td></tr>}
52 + {filtered.map((r, i) => (
53 + <tr key={r.id ?? i}>
54 + <td className="mono">{String(r.ts || '').replace('T', ' ').slice(0, 19)}</td>
55 + <td className="mono"><button type="button" className="adm-link" onClick={() => { setF(v => ({ ...v, actor: r.actor })); setApplied(a => ({ ...a, actor: r.actor })) }} title="Filter by this actor">{r.actor}</button></td>
56 + <td><span className="dash-badge">{r.action}</span></td>
57 + <td className="mono">{r.target ? <button type="button" className="adm-link" onClick={() => { setF(v => ({ ...v, target: r.target })); setApplied(a => ({ ...a, target: r.target })) }} title="Filter by this target">{r.target}</button> : ''}</td>
58 + <td className="adm-meta mono" title={r.meta ? JSON.stringify(r.meta) : ''}>{r.meta ? (typeof r.meta === 'string' ? r.meta : JSON.stringify(r.meta)) : ''}</td>
59 + </tr>
60 + ))}
61 + </tbody>
62 + </table>
63 + </div>
64 + )}
65 + {next && <div><button type="button" className="btn btn-sm" onClick={() => load(next)}>Load more</button></div>}
47 66 </div>
48 67 )
49 68 }
modified hfmarketdata/web/src/pages/admin/AdminUsage.jsx +27 −25
@@ -1,58 +1,60 @@
1 −// Global usage: totals, top principals, series (shape rendered defensively — the accounts module is built in parallel).
1 +// Global usage. Contract: GET /v1/admin/usage?days=&top= → { per_day: [{ day, requests, rows, rows_parquet, status_429, principals }],
2 +// top: [{ principal, requests, rows, status_429, user: { id, email, tier, key_name, prefix } | null }], totals }
2 3 import React, { useEffect, useMemo, useState } from 'react'
3 −import { api } from '../../app/api.js'
4 +import { apiData } from '../../app/api.js'
4 5 import DashChart from '../../components/DashChart.jsx'
5 −import PgCallout from '../../components/PgCallout.jsx'
6 −import { errText } from './AdminUsers.jsx'
6 +import { ErrorNotice, Skeleton, n } from '../dashboard/ui.jsx'
7 7
8 −const n = v => (v == null ? '—' : Number(v).toLocaleString())
9 −const RANGES = ['24h', '7d', '30d']
8 +const DAYS = [['7', '7 days'], ['30', '30 days'], ['90', '90 days']]
10 9
11 10 export default function AdminUsage() {
12 − const [range, setRange] = useState('7d')
11 + const [days, setDays] = useState('30')
13 12 const [data, setData] = useState(null)
14 13 const [err, setErr] = useState(null)
14 + const [tick, setTick] = useState(0)
15 15 useEffect(() => {
16 16 let alive = true
17 − api(`/v1/admin/usage?range=${range}`).then(r => { if (alive) { setData(r.data?.data || r.data); setErr(null) } }).catch(e => alive && setErr(e))
17 + setData(null)
18 + apiData(`/v1/admin/usage?days=${days}&top=50`).then(d => { if (alive) { setData(d); setErr(null) } }).catch(e => alive && setErr(e))
18 19 return () => { alive = false }
19 − }, [range])
20 − const series = useMemo(() => (data?.series || []).slice().sort((a, b) => String(a.ts).localeCompare(String(b.ts))), [data])
21 − const top = data?.top || data?.top_principals || data?.principals || []
20 + }, [days, tick])
21 + const series = useMemo(() => (data?.per_day || []).map(d => ({ ts: d.day, ...d })).sort((a, b) => a.ts.localeCompare(b.ts)), [data])
22 + const top = data?.top || []
22 23 const totals = data?.totals || {}
23 − const xFormat = range === '24h' ? ts => String(ts).slice(11, 16) : ts => String(ts).slice(5, 10)
24 + const principals = useMemo(() => Math.max(0, ...(data?.per_day || []).map(d => Number(d.principals) || 0)), [data])
24 25 return (
25 26 <div className="dash-section">
26 27 <div className="dash-head-row">
27 28 <h1>Global usage</h1>
28 29 <div className="dash-range" role="radiogroup" aria-label="Range">
29 − {RANGES.map(r => <button key={r} type="button" role="radio" aria-checked={range === r} className={`dash-range-btn ${range === r ? 'active' : ''}`} onClick={() => setRange(r)}>{r}</button>)}
30 + {DAYS.map(([v, l]) => <button key={v} type="button" role="radio" aria-checked={days === v} className={`dash-range-btn ${days === v ? 'active' : ''}`} onClick={() => setDays(v)}>{l}</button>)}
30 31 </div>
31 32 </div>
32 − {err && <PgCallout kind={err.status === 404 && !err.code ? 'warn' : 'danger'}>{errText(err)}</PgCallout>}
33 + <ErrorNotice error={err} onRetry={() => setTick(t => t + 1)} />
33 34 <div className="dash-stats">
34 − <div className="dash-stat"><span className="dash-stat-label">Requests</span><span className="dash-stat-value">{n(totals.requests)}</span></div>
35 − <div className="dash-stat"><span className="dash-stat-label">Rows</span><span className="dash-stat-value">{n(totals.rows)}</span></div>
36 − <div className="dash-stat"><span className="dash-stat-label">429</span><span className="dash-stat-value">{n(totals.status_429)}</span></div>
37 − <div className="dash-stat"><span className="dash-stat-label">Principals</span><span className="dash-stat-value">{n(totals.principals ?? (top.length || null))}</span></div>
35 + <div className="dash-stat"><span className="dash-stat-label">Requests</span><span className="dash-stat-value" data-testid="adm-requests">{data ? n(totals.requests) : '—'}</span></div>
36 + <div className="dash-stat"><span className="dash-stat-label">Rows</span><span className="dash-stat-value">{data ? n(totals.rows) : '—'}</span><span className="muted">{data ? `${n(totals.rows_parquet)} via Parquet` : ''}</span></div>
37 + <div className="dash-stat"><span className="dash-stat-label">429</span><span className="dash-stat-value">{data ? n(totals.status_429) : '—'}</span></div>
38 + <div className="dash-stat"><span className="dash-stat-label">Principals / day (max)</span><span className="dash-stat-value">{data ? n(principals) : '—'}</span></div>
38 39 </div>
39 − {series.length > 0 && (
40 + {!data && !err ? <div className="card"><Skeleton lines={4} height={18} /></div> : series.length > 0 && (
40 41 <div className="dash-charts">
41 − <div className="card"><DashChart series={series} valueKey="requests" label="Requests" xFormat={xFormat} /></div>
42 − <div className="card"><DashChart series={series} valueKey="rows" label="Rows" color="var(--accent-2)" xFormat={xFormat} /></div>
42 + <div className="card"><DashChart series={series} valueKey="requests" label="Requests per day" xFormat={ts => String(ts).slice(5, 10)} /></div>
43 + <div className="card"><DashChart series={series} valueKey="rows" label="Rows per day" color="var(--accent-2)" xFormat={ts => String(ts).slice(5, 10)} /></div>
43 44 </div>
44 45 )}
45 46 <h2>Top principals</h2>
46 47 <div className="dash-table-wrap">
47 48 <table className="dash-table" data-testid="top-principals">
48 − <thead><tr><th>Principal</th><th>User</th><th>Tier</th><th className="num">Requests</th><th className="num">Rows</th><th className="num">429</th></tr></thead>
49 + <thead><tr><th>Principal</th><th>User</th><th>Key</th><th>Tier</th><th className="num">Requests</th><th className="num">Rows</th><th className="num">429</th></tr></thead>
49 50 <tbody>
50 − {top.length === 0 && <tr><td colSpan={6} className="muted">{data ? 'No usage recorded.' : 'Loading…'}</td></tr>}
51 + {top.length === 0 && <tr><td colSpan={7} className="muted">{data ? 'No usage recorded.' : 'Loading…'}</td></tr>}
51 52 {top.map((t, i) => (
52 53 <tr key={t.principal || i}>
53 54 <td className="mono">{t.principal}</td>
54 − <td>{t.email || t.user || <span className="muted">—</span>}</td>
55 − <td>{t.tier || <span className="muted">—</span>}</td>
55 + <td className="dash-wrap">{t.user?.email || <span className="muted">{String(t.principal).startsWith('ip:') ? 'keyless (IP)' : '—'}</span>}</td>
56 + <td className="mono dash-mono-sm">{t.user?.prefix ? `${t.user.prefix}… (${t.user.key_name})` : ''}</td>
57 + <td>{t.user?.tier || (String(t.principal).startsWith('ip:') ? 'keyless' : <span className="muted">—</span>)}</td>
56 58 <td className="num mono">{n(t.requests)}</td>
57 59 <td className="num mono">{n(t.rows)}</td>
58 60 <td className="num mono">{n(t.status_429)}</td>
modified hfmarketdata/web/src/pages/admin/AdminUsers.jsx +172 −57
@@ -1,52 +1,69 @@
1 −// Users table: search, inline tier/role/status edit (PATCH), invite (POST /v1/admin/users → account + key + invitation link).
2 −import React, { useCallback, useEffect, useMemo, useState } from 'react'
3 −import { api } from '../../app/api.js'
1 +// Users: server-side search, inline tier/role/status edit (PATCH), invite (POST /v1/admin/users), detail panel
2 +// (keys, 7-day usage, pending links, key actions, reset link, delete). Contract: see routes_admin.py.
3 +import React, { useCallback, useEffect, useState } from 'react'
4 +import { apiData, api, friendlyError } from '../../app/api.js'
5 +import { useAuth } from '../../app/auth.jsx'
4 6 import PgCallout from '../../components/PgCallout.jsx'
5 7 import PgCopyButton from '../../components/PgCopyButton.jsx'
8 +import { Confirm, ErrorNotice, Skeleton, ago, fmtDate, fmtDateTime, n, useToast } from '../dashboard/ui.jsx'
6 9
7 10 const TIERS = ['free', 'high_usage', 'unlimited']
8 11 const ROLES = ['user', 'admin']
9 12 const STATUSES = ['invited', 'active', 'disabled']
10 13 const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
11 −const list = r => (Array.isArray(r) ? r : Array.isArray(r?.data) ? r.data : Array.isArray(r?.users) ? r.users : [])
12 −export const errText = e => (e?.status === 404 && !e.code ? 'Admin endpoints are not deployed on this server yet — coming soon.' : e?.message || 'Request failed.')
14 +export const errText = friendlyError
15 +
16 +function useDebounced(value, ms = 250) {
17 + const [v, setV] = useState(value)
18 + useEffect(() => { const t = setTimeout(() => setV(value), ms); return () => clearTimeout(t) }, [value, ms])
19 + return v
20 +}
13 21
14 22 export default function AdminUsers() {
23 + const { user: me } = useAuth()
24 + const toast = useToast()
15 25 const [users, setUsers] = useState(null)
26 + const [meta, setMeta] = useState({})
16 27 const [err, setErr] = useState(null)
17 28 const [q, setQ] = useState('')
29 + const [filters, setFilters] = useState({ tier: '', role: '', status: '' })
18 30 const [busyId, setBusyId] = useState(null)
19 − const [invite, setInvite] = useState({ name: '', email: '' })
31 + const [invite, setInvite] = useState({ name: '', email: '', tier: 'free' })
20 32 const [inviteErr, setInviteErr] = useState('')
21 33 const [invited, setInvited] = useState(null)
22 34 const [inviteBusy, setInviteBusy] = useState(false)
35 + const [selected, setSelected] = useState(null) // user id shown in the detail panel
36 + const [confirmDelete, setConfirmDelete] = useState(null)
37 + const dq = useDebounced(q)
23 38
24 39 const load = useCallback(async () => {
25 − try { const r = await api('/v1/admin/users'); setUsers(list(r.data)); setErr(null) } catch (e) { setErr(e); setUsers([]) }
26 − }, [])
40 + try {
41 + const p = new URLSearchParams({ limit: '500' })
42 + if (dq.trim()) p.set('search', dq.trim())
43 + for (const [k, v] of Object.entries(filters)) if (v) p.set(k, v)
44 + const r = await api(`/v1/admin/users?${p}`)
45 + setUsers(Array.isArray(r.data?.data) ? r.data.data : [])
46 + setMeta(r.data?.meta || {})
47 + setErr(null)
48 + } catch (e) { setErr(e); setUsers(u => u || []) }
49 + }, [dq, filters])
27 50 useEffect(() => { load() }, [load])
28 51
29 − const filtered = useMemo(() => {
30 − const s = q.trim().toLowerCase()
31 − return (users || []).filter(u => !s || `${u.email} ${u.name} ${u.tier} ${u.role} ${u.status}`.toLowerCase().includes(s))
32 − }, [users, q])
33 −
34 52 const patch = async (u, field, value) => {
35 53 if (u[field] === value) return
36 54 setBusyId(u.id); setErr(null)
37 55 try {
38 − const r = await api(`/v1/admin/users/${u.id}`, { method: 'PATCH', body: { [field]: value } })
39 − const nu = r.data?.data || r.data || {}
40 − setUsers(us => us.map(x => (x.id === u.id ? { ...x, [field]: value, ...(nu.id === u.id ? nu : {}) } : x)))
41 − } catch (e) { setErr(e) } finally { setBusyId(null) }
56 + const nu = await apiData(`/v1/admin/users/${u.id}`, { method: 'PATCH', body: { [field]: value } })
57 + setUsers(us => us.map(x => (x.id === u.id ? { ...x, ...(nu && nu.id === u.id ? nu : { [field]: value }) } : x)))
58 + if (field === 'role' || field === 'status') load() // admins_active may change
59 + } catch (e) { toast.error(e); setErr(e) } finally { setBusyId(null) }
42 60 }
43 61 const resend = async u => {
44 62 setBusyId(u.id); setErr(null)
45 63 try {
46 − const r = await api(`/v1/admin/users/${u.id}/invite`, { method: 'POST' })
47 − const d = r.data?.data || r.data || {}
48 − setInvited({ user: u, link: d.invite_url || d.invitation_url || d.invite_link || d.url, key: d.key, resent: true })
49 − } catch (e) { setErr(e) } finally { setBusyId(null) }
64 + const d = await apiData(`/v1/admin/users/${u.id}/invite`, { method: 'POST' })
65 + setInvited({ user: d.user || u, invitation: d.invitation || {}, keyPrefix: null, resent: true })
66 + } catch (e) { toast.error(e) } finally { setBusyId(null) }
50 67 }
51 68 const submitInvite = async e => {
52 69 e.preventDefault()
@@ -54,37 +71,48 @@ export default function AdminUsers() {
54 71 if (!EMAIL_RE.test(invite.email)) { setInviteErr('Enter a valid e-mail.'); return }
55 72 setInviteErr(''); setInviteBusy(true); setErr(null)
56 73 try {
57 − const r = await api('/v1/admin/users', { method: 'POST', body: { name: invite.name.trim(), email: invite.email.trim() } })
58 − const d = r.data?.data || r.data || {}
59 − setInvited({ user: d.user || d, link: d.invite_url || d.invitation_url || d.invite_link || d.url, key: d.key || d.api_key })
60 − setInvite({ name: '', email: '' })
74 + const d = await apiData('/v1/admin/users', { method: 'POST', body: { name: invite.name.trim(), email: invite.email.trim(), tier: invite.tier } })
75 + setInvited({ user: d.user, invitation: d.invitation || {}, keyPrefix: d.key?.prefix || null, keyName: d.key?.name })
76 + setInvite({ name: '', email: '', tier: 'free' })
61 77 await load()
62 78 } catch (e2) { setErr(e2) } finally { setInviteBusy(false) }
63 79 }
80 + const remove = async u => {
81 + setBusyId(u.id)
82 + try {
83 + await apiData(`/v1/admin/users/${u.id}`, { method: 'DELETE' })
84 + toast.success(`${u.email} deleted (anonymised).`)
85 + if (selected === u.id) setSelected(null)
86 + await load()
87 + } catch (e) { toast.error(e) } finally { setBusyId(null); setConfirmDelete(null) }
88 + }
64 89
90 + const lastAdmin = meta.admins_active === 1
65 91 return (
66 92 <div className="dash-section">
67 93 <div className="dash-head-row">
68 94 <h1>Users</h1>
69 − <input type="search" placeholder="Search e-mail, name, tier…" value={q} onChange={e => setQ(e.target.value)} aria-label="Search users" className="adm-search" />
95 + <input type="search" placeholder="Search e-mail or name…" value={q} onChange={e => setQ(e.target.value)} aria-label="Search users" className="adm-search" />
96 + </div>
97 + <div className="dash-filters" aria-label="Filters">
98 + <select aria-label="Filter by tier" value={filters.tier} onChange={e => setFilters(f => ({ ...f, tier: e.target.value }))}><option value="">All tiers</option>{TIERS.map(t => <option key={t}>{t}</option>)}</select>
99 + <select aria-label="Filter by role" value={filters.role} onChange={e => setFilters(f => ({ ...f, role: e.target.value }))}><option value="">All roles</option>{ROLES.map(t => <option key={t}>{t}</option>)}</select>
100 + <select aria-label="Filter by status" value={filters.status} onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}><option value="">All statuses</option>{[...STATUSES, 'deleted'].map(t => <option key={t}>{t}</option>)}</select>
101 + <span className="dash-subtle">{users ? `${users.length} user${users.length === 1 ? '' : 's'}` : ''}{meta.admins_active != null ? ` · ${meta.admins_active} active admin${meta.admins_active === 1 ? '' : 's'}` : ''}</span>
70 102 </div>
71 − {err && <PgCallout kind={err.status === 404 && !err.code ? 'warn' : 'danger'}>{errText(err)}</PgCallout>}
103 + {lastAdmin && <div className="dash-banner" role="status" data-testid="last-admin-banner"><strong>You are the only active administrator.</strong> Promote a second admin so the account can be recovered — the last admin can neither be demoted, disabled nor deleted.</div>}
104 + <ErrorNotice error={err} onRetry={load} />
72 105
73 106 {invited && (
74 107 <div className="card dash-reveal" role="dialog" aria-labelledby="adm-inv-title" data-testid="invite-result">
75 − <h2 id="adm-inv-title">Invitation {invited.resent ? 'link' : 'created'} — {invited.user?.email}</h2>
76 − {invited.link ? (
77 − <>
78 − <p className="muted">Send this link to the user (it also went by e-mail if the mailer is configured). It lets them choose a password.</p>
79 − <div className="dash-key-row"><code className="dash-key" data-testid="invite-link">{invited.link}</code><PgCopyButton text={invited.link} label="Copy link" /></div>
80 − </>
81 − ) : <p className="muted">The server did not return an invitation link; the user should have received it by e-mail.</p>}
82 − {invited.key && (
108 + <h2 id="adm-inv-title">Invitation {invited.resent ? 're-sent' : 'created'} — {invited.user?.email}</h2>
109 + {invited.invitation.link ? (
83 110 <>
84 − <PgCallout kind="warn" title="Initial API key — shown once">Hand it over securely; only its hash is stored.</PgCallout>
85 − <div className="dash-key-row"><code className="dash-key">{invited.key}</code><PgCopyButton text={invited.key} label="Copy key" /></div>
111 + <p className="muted">No mail provider is configured: send this link to the user yourself. It lets them choose a password (valid 7 days).</p>
112 + <div className="dash-key-row"><code className="dash-key" data-testid="invite-link">{invited.invitation.link}</code><PgCopyButton text={invited.invitation.link} label="Copy link" /></div>
86 113 </>
87 − )}
114 + ) : <p className="muted">{invited.invitation.queued || invited.invitation.delivered ? 'The invitation e-mail was sent.' : 'The server did not return an invitation link.'}</p>}
115 + {invited.keyPrefix && <p className="muted" data-testid="invite-key">An API key <code>{invited.keyPrefix}…</code> ({invited.keyName || 'default'}) was created for them; only its prefix is visible — the user rotates it from the dashboard to obtain a full key.</p>}
88 116 <button type="button" className="btn" onClick={() => setInvited(null)}>Close</button>
89 117 </div>
90 118 )}
@@ -94,37 +122,124 @@ export default function AdminUsers() {
94 122 <div className="adm-invite-row">
95 123 <label>Name<input value={invite.name} onChange={e => setInvite(v => ({ ...v, name: e.target.value }))} autoComplete="off" /></label>
96 124 <label>E-mail<input type="email" value={invite.email} onChange={e => setInvite(v => ({ ...v, email: e.target.value }))} autoComplete="off" /></label>
125 + <label>Tier<select value={invite.tier} onChange={e => setInvite(v => ({ ...v, tier: e.target.value }))} aria-label="Tier of the invited user">{TIERS.map(t => <option key={t}>{t}</option>)}</select></label>
97 126 <button type="submit" className="btn btn-primary" disabled={inviteBusy} data-testid="invite-submit">{inviteBusy ? 'Inviting…' : 'Create account + invite'}</button>
98 127 </div>
99 − {inviteErr && <p className="pg-err" role="alert">{inviteErr}</p>}
128 + {inviteErr && <p className="dash-field-err" role="alert">{inviteErr}</p>}
100 129 <p className="muted adm-hint">Creates the account (status <code>invited</code>), a first API key and an invitation link to set the password.</p>
101 130 </form>
102 131
103 − {users === null ? <p className="muted">Loading…</p> : (
132 + {users === null ? <div className="card"><Skeleton lines={4} /></div> : (
104 133 <div className="dash-table-wrap">
105 134 <table className="dash-table adm-users" data-testid="users-table">
106 − <thead><tr><th>E-mail</th><th>Name</th><th>Tier</th><th>Role</th><th>Status</th><th>Created</th><th>Last login</th><th>Actions</th></tr></thead>
135 + <thead><tr><th>E-mail</th><th>Name</th><th>Tier</th><th>Role</th><th>Status</th><th>Keys</th><th>Created</th><th>Last login</th><th>Actions</th></tr></thead>
107 136 <tbody>
108 − {filtered.length === 0 && <tr><td colSpan={8} className="muted">No user matches.</td></tr>}
109 − {filtered.map(u => (
110 − <tr key={u.id} className={busyId === u.id ? 'adm-busy' : ''}>
111 − <td className="mono">{u.email}</td>
112 − <td>{u.name}</td>
113 − <td><select aria-label={`Tier of ${u.email}`} value={u.tier || 'free'} onChange={e => patch(u, 'tier', e.target.value)} disabled={busyId === u.id}>{TIERS.map(t => <option key={t}>{t}</option>)}</select></td>
114 − <td><select aria-label={`Role of ${u.email}`} value={u.role || 'user'} onChange={e => patch(u, 'role', e.target.value)} disabled={busyId === u.id}>{ROLES.map(t => <option key={t}>{t}</option>)}</select></td>
115 − <td><select aria-label={`Status of ${u.email}`} value={u.status || 'active'} onChange={e => patch(u, 'status', e.target.value)} disabled={busyId === u.id} className={`adm-status-${u.status}`}>{STATUSES.map(t => <option key={t}>{t}</option>)}</select></td>
116 − <td>{u.created_at ? String(u.created_at).slice(0, 10) : '—'}</td>
117 − <td>{u.last_login_at ? String(u.last_login_at).slice(0, 16).replace('T', ' ') : <span className="muted">never</span>}</td>
118 − <td className="dash-actions">
119 − {u.status === 'invited' && <button type="button" className="btn btn-sm" onClick={() => resend(u)} disabled={busyId === u.id}>Invitation link</button>}
120 − </td>
121 − </tr>
122 − ))}
137 + {users.length === 0 && <tr><td colSpan={9} className="muted">No user matches.</td></tr>}
138 + {users.map(u => {
139 + const self = u.id === me?.id
140 + const gone = u.status === 'deleted'
141 + return (
142 + <tr key={u.id} className={`${busyId === u.id ? 'adm-busy' : ''} ${gone ? 'dash-row-muted' : ''}`}>
143 + <td className="mono dash-wrap">{u.email}{self && <span className="dash-subtle"> (you)</span>}{u.locked_until && <span className="dash-badge disabled" title={`Locked until ${u.locked_until}`}> locked</span>}</td>
144 + <td className="dash-wrap">{u.name}</td>
145 + <td><select aria-label={`Tier of ${u.email}`} value={u.tier || 'free'} onChange={e => patch(u, 'tier', e.target.value)} disabled={busyId === u.id || gone}>{TIERS.map(t => <option key={t}>{t}</option>)}</select></td>
146 + <td><select aria-label={`Role of ${u.email}`} value={u.role || 'user'} onChange={e => patch(u, 'role', e.target.value)} disabled={busyId === u.id || gone || self}>{ROLES.map(t => <option key={t}>{t}</option>)}</select></td>
147 + <td>{gone ? <span className="dash-badge revoked">deleted</span> : <select aria-label={`Status of ${u.email}`} value={u.status || 'active'} onChange={e => patch(u, 'status', e.target.value)} disabled={busyId === u.id || self} className={`adm-status-${u.status}`}>{STATUSES.map(t => <option key={t}>{t}</option>)}</select>}</td>
148 + <td className="num mono">{n(u.keys_active)}</td>
149 + <td>{fmtDate(u.created_at)}</td>
150 + <td>{u.last_login_at ? ago(u.last_login_at) : <span className="muted">never</span>}</td>
151 + <td className="dash-actions">
152 + <button type="button" className="btn btn-sm" onClick={() => setSelected(selected === u.id ? null : u.id)} aria-expanded={selected === u.id} data-testid={`detail-${u.id}`}>{selected === u.id ? 'Hide' : 'Details'}</button>
153 + {u.status === 'invited' && <button type="button" className="btn btn-sm" onClick={() => resend(u)} disabled={busyId === u.id}>Invitation link</button>}
154 + {!gone && !self && confirmDelete !== u.id && <button type="button" className="btn btn-sm dash-danger" onClick={() => setConfirmDelete(u.id)} disabled={busyId === u.id}>Delete</button>}
155 + {confirmDelete === u.id && <Confirm question={`Delete ${u.email}? Keys revoked, sessions signed out, e-mail removed.`} yesLabel="Yes, delete" busy={busyId === u.id} onYes={() => remove(u)} onCancel={() => setConfirmDelete(null)} testId="confirm-delete-user" />}
156 + </td>
157 + </tr>
158 + )
159 + })}
123 160 </tbody>
124 161 </table>
125 162 </div>
126 163 )}
127 − <p className="muted adm-hint">{users ? `${users.length} user${users.length === 1 ? '' : 's'}` : ''}</p>
164 + {selected != null && <UserDetail id={selected} onClose={() => setSelected(null)} onChanged={load} />}
165 + </div>
166 + )
167 +}
168 +
169 +function UserDetail({ id, onClose, onChanged }) {
170 + const toast = useToast()
171 + const [d, setD] = useState(null)
172 + const [err, setErr] = useState(null)
173 + const [busy, setBusy] = useState(false)
174 + const [confirm, setConfirm] = useState(null) // { kind: 'revoke', keyId }
175 + const [newKey, setNewKey] = useState(null)
176 + const load = useCallback(async () => {
177 + try { setD(await apiData(`/v1/admin/users/${id}`)); setErr(null) } catch (e) { setErr(e) }
178 + }, [id])
179 + useEffect(() => { setD(null); setNewKey(null); load() }, [load])
180 + const act = async (fn, okMsg) => {
181 + setBusy(true)
182 + try { const r = await fn(); if (okMsg) toast.success(okMsg); await load(); onChanged?.(); return r } catch (e) { toast.error(e) } finally { setBusy(false); setConfirm(null) }
183 + }
184 + const revokeKey = kid => act(() => apiData(`/v1/admin/users/${id}/keys/${kid}`, { method: 'DELETE' }), 'Key revoked.')
185 + const createKey = async () => { const k = await act(() => apiData(`/v1/admin/users/${id}/keys`, { method: 'POST', body: { name: 'admin-issued' } })); if (k?.key) setNewKey(k) }
186 + const resetLink = () => act(() => apiData(`/v1/admin/users/${id}/reset-password`, { method: 'POST' }), 'Reset link issued.')
187 + if (err) return <div className="dash-drawer"><ErrorNotice error={err} onRetry={load} /><button type="button" className="btn btn-sm" onClick={onClose}>Close</button></div>
188 + if (!d) return <div className="dash-drawer" data-testid="user-detail"><Skeleton lines={5} /></div>
189 + const u = d.user
190 + const totals = d.usage?.totals || {}
191 + return (
192 + <div className="dash-drawer" data-testid="user-detail" aria-label={`Details of ${u.email}`}>
193 + <div className="dash-drawer-head">
194 + <h2>{u.name || u.email} <span className={`dash-badge ${u.status}`}>{u.status}</span> <span className={`dash-badge ${u.role === 'admin' ? 'admin' : ''}`}>{u.role}</span></h2>
195 + <button type="button" className="btn btn-sm" onClick={onClose}>Close</button>
196 + </div>
197 + <dl className="dash-kv">
198 + <dt>E-mail</dt><dd className="mono">{u.email}{u.email_verified ? '' : ' (unverified)'}</dd>
199 + <dt>Tier</dt><dd>{u.tier}</dd>
200 + <dt>Created</dt><dd>{fmtDateTime(u.created_at)}</dd>
201 + <dt>Last login</dt><dd>{u.last_login_at ? fmtDateTime(u.last_login_at) : 'never'}</dd>
202 + <dt>Usage · 7 days</dt><dd data-testid="detail-usage">{n(totals.requests)} requests · {n(totals.rows)} rows · {n(totals.status_429)} × 429</dd>
203 + <dt>Quota alerts</dt><dd>{u.quota_alerts ? 'on' : 'off'}</dd>
204 + {d.pending_invite_link && <><dt>Pending invitation</dt><dd className="dash-key-row"><code className="dash-key dash-mono-sm" data-testid="pending-invite">{d.pending_invite_link}</code><PgCopyButton text={d.pending_invite_link} label="Copy" small /></dd></>}
205 + {d.pending_reset_link && <><dt>Pending reset link</dt><dd className="dash-key-row"><code className="dash-key dash-mono-sm">{d.pending_reset_link}</code><PgCopyButton text={d.pending_reset_link} label="Copy" small /></dd></>}
206 + </dl>
207 + {newKey && (
208 + <PgCallout kind="warn" title={`New key for ${u.email} — shown once`}>
209 + <div className="dash-key-row"><code className="dash-key" data-testid="admin-new-key">{newKey.key}</code><PgCopyButton text={newKey.key} label="Copy key" /></div>
210 + <p className="dash-subtle">Hand it over securely; only its hash is stored. <button type="button" className="btn btn-sm" onClick={() => setNewKey(null)}>Hide</button></p>
211 + </PgCallout>
212 + )}
213 + <h3 className="dash-subtle">API keys</h3>
214 + <div className="dash-table-wrap">
215 + <table className="dash-table" data-testid="detail-keys">
216 + <thead><tr><th>Name</th><th>Prefix</th><th>Status</th><th>Tier override</th><th>Created</th><th>Expires</th><th>Last used</th><th>Actions</th></tr></thead>
217 + <tbody>
218 + {(d.keys || []).length === 0 && <tr><td colSpan={8} className="muted">No key.</td></tr>}
219 + {(d.keys || []).map(k => (
220 + <tr key={k.id} className={k.status !== 'active' ? 'dash-row-muted' : ''}>
221 + <td>{k.name}{k.note && <div className="dash-subtle">{k.note}</div>}</td>
222 + <td className="mono">{k.prefix}…</td>
223 + <td><span className={`dash-badge ${k.expired ? 'revoked' : k.status}`}>{k.expired && k.status === 'active' ? 'expired' : k.status}</span></td>
224 + <td>{k.tier_override || <span className="muted">—</span>}</td>
225 + <td>{fmtDate(k.created_at)}</td>
226 + <td>{k.expires_at ? fmtDate(k.expires_at) : <span className="muted">never</span>}</td>
227 + <td>{ago(k.last_used_at)}</td>
228 + <td className="dash-actions">
229 + {k.status === 'active' && confirm?.keyId !== k.id && <button type="button" className="btn btn-sm dash-danger" onClick={() => setConfirm({ keyId: k.id })} disabled={busy}>Revoke</button>}
230 + {confirm?.keyId === k.id && <Confirm question="Revoke this key?" yesLabel="Yes, revoke" busy={busy} onYes={() => revokeKey(k.id)} onCancel={() => setConfirm(null)} testId="confirm-admin-revoke" />}
231 + </td>
232 + </tr>
233 + ))}
234 + </tbody>
235 + </table>
236 + </div>
237 + {u.status !== 'deleted' && (
238 + <div className="dash-inline-actions">
239 + <button type="button" className="btn btn-sm" onClick={createKey} disabled={busy}>Create a key for this user</button>
240 + <button type="button" className="btn btn-sm" onClick={resetLink} disabled={busy || u.status === 'disabled'}>Send a password-reset link</button>
241 + </div>
242 + )}
128 243 </div>
129 244 )
130 245 }
modified hfmarketdata/web/src/pages/admin/admin.css +3 −0
@@ -10,3 +10,6 @@
10 10 .adm-status-disabled { color: var(--danger); }
11 11 .adm-status-invited { color: var(--warn); }
12 12 .adm-meta { font-size: 11.5px; max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
13 +.adm-link { background: none; border: 0; padding: 0; color: var(--accent-2); cursor: pointer; font: inherit; font-family: var(--mono); font-size: inherit; }
14 +.adm-link:hover { text-decoration: underline; }
15 +.adm-invite-row select { padding: 6px 8px; font-size: 13px; }
modified hfmarketdata/web/src/pages/auth/Auth.jsx +50 −35
@@ -1,35 +1,35 @@
1 1 // Auth pages — mode: signin | signup | verify | reset | invite. Cookie-session endpoints under /v1/auth.
2 −import React, { useEffect, useState } from 'react'
2 +import React, { useEffect, useRef, useState } from 'react'
3 3 import { Link, useNavigate, useSearchParams } from 'react-router-dom'
4 −import { api, CONTACT_EMAIL } from '../../app/api.js'
4 +import { api, CONTACT_EMAIL, friendlyError } from '../../app/api.js'
5 5 import { useAuth } from '../../app/auth.jsx'
6 6 import PgCallout from '../../components/PgCallout.jsx'
7 7 import './auth.css'
8 8
9 +export { friendlyError }
10 +
9 11 const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
10 12 const MIN_PW = 10
11 13
12 −const ERRORS = {
13 − INVALID_CREDENTIALS: 'Incorrect e-mail or password.',
14 − EMAIL_TAKEN: 'An account already exists for this e-mail. Try signing in or resetting your password.',
15 − INVALID_TOKEN: 'This link is invalid or has expired. Request a new one.',
16 − RATE_LIMIT_EXCEEDED: 'Too many attempts. Please wait a moment and try again.',
17 − AUTH_REQUIRED: 'Please sign in.',
18 − VALIDATION_ERROR: 'Please check the highlighted fields.',
19 −}
20 −export const friendlyError = e => {
21 − if (!e) return ''
22 − if (e.status === 404 && !e.code) return 'Accounts are not enabled on this server yet (coming soon).'
23 − if (e.status === 0 || e.name === 'TypeError') return 'Network error — please check your connection.'
24 − return ERRORS[e.code] || e.message || 'Something went wrong.'
25 −}
26 −
27 14 function useSafeNext() {
28 15 const [sp] = useSearchParams()
29 16 const next = sp.get('next') || '/dashboard'
30 17 return next.startsWith('/') && !next.startsWith('//') ? next : '/dashboard'
31 18 }
32 19
20 +/** Read `?token=` once and scrub it from the address bar / history so it never leaks through referrers or logs. */
21 +function useScrubbedToken() {
22 + const [sp] = useSearchParams()
23 + const ref = useRef(sp.get('token') || '')
24 + useEffect(() => {
25 + if (ref.current && typeof window !== 'undefined') {
26 + const url = new URL(window.location.href)
27 + if (url.searchParams.has('token')) { url.searchParams.delete('token'); window.history.replaceState(null, '', url.pathname + (url.search || '') + url.hash) }
28 + }
29 + }, [])
30 + return ref.current
31 +}
32 +
33 33 function Field({ id, label, type = 'text', value, onChange, error, autoComplete, hint, autoFocus, minLength }) {
34 34 return (
35 35 <div className="auth-field">
@@ -47,14 +47,13 @@ function PasswordField(props) {
47 47 return (
48 48 <div className="auth-pw">
49 49 <Field {...props} type={show ? 'text' : 'password'} />
50 − <button type="button" className="auth-pw-toggle" onClick={() => setShow(s => !s)} aria-pressed={show}>{show ? 'Hide' : 'Show'}</button>
50 + <button type="button" className="auth-pw-toggle" onClick={() => setShow(s => !s)} aria-pressed={show} aria-label={show ? 'Hide password' : 'Show password'}>{show ? 'Hide' : 'Show'}</button>
51 51 </div>
52 52 )
53 53 }
54 54
55 55 export default function Auth({ mode }) {
56 − const [sp] = useSearchParams()
57 − const token = sp.get('token') || ''
56 + const token = useScrubbedToken()
58 57 const titles = { signin: 'Sign in', signup: 'Create your free account', verify: 'Verify your e-mail', reset: token ? 'Choose a new password' : 'Reset your password', invite: 'Accept your invitation' }
59 58 useEffect(() => { document.title = `${titles[mode]} — HF Market Data` }, [mode, token]) // eslint-disable-line react-hooks/exhaustive-deps
60 59 return (
@@ -96,7 +95,7 @@ function SignIn() {
96 95 }
97 96 return (
98 97 <form onSubmit={submit} noValidate className="auth-form" data-testid="signin-form">
99 − {error && <PgCallout kind="danger">{friendlyError(error)}</PgCallout>}
98 + {error && <PgCallout kind="danger">{friendlyError(error)}{error.code === 'EMAIL_NOT_VERIFIED' && <> <Link to="/signup">Re-send the link</Link>.</>}</PgCallout>}
100 99 <Field id="email" label="E-mail" type="email" value={email} onChange={setEmail} error={errs.email} autoComplete="email" autoFocus />
101 100 <PasswordField id="password" label="Password" value={password} onChange={setPassword} error={errs.password} autoComplete="current-password" />
102 101 <div className="auth-row">
@@ -134,7 +133,7 @@ function SignUp() {
134 133 return (
135 134 <div className="auth-success" data-testid="signup-success">
136 135 <PgCallout kind="success" title="Check your inbox">
137 − We sent a verification link to <strong>{email}</strong>. Click it to activate your account, then sign in and create your first API key.
136 + If <strong>{email}</strong> can receive mail, we sent it a link: click it to activate your account, then create your first API key. Already registered? The message tells you how to sign in or reset your password.
138 137 </PgCallout>
139 138 <p className="muted">No e-mail after a few minutes? Check your spam folder or write to <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>.</p>
140 139 <Link to="/signin" className="btn">Go to sign in</Link>
@@ -155,20 +154,27 @@ function SignUp() {
155 154 }
156 155
157 156 function Verify({ token }) {
157 + const { refresh } = useAuth()
158 158 const [state, setState] = useState(token ? 'busy' : 'missing')
159 + const [kind, setKind] = useState('verify')
159 160 const [error, setError] = useState(null)
160 161 useEffect(() => {
161 162 if (!token) return
162 163 let alive = true
163 − api(`/v1/auth/verify?token=${encodeURIComponent(token)}`).then(() => alive && setState('ok')).catch(e => { if (alive) { setError(e); setState('error') } })
164 + // POST (never GET): mail scanners that pre-fetch links cannot burn the token, and it never reaches server logs.
165 + api('/v1/auth/verify', { method: 'POST', body: { token } })
166 + .then(async r => { if (!alive) return; setKind(r.data?.data?.kind || 'verify'); await refresh(); if (alive) setState('ok') })
167 + .catch(e => { if (alive) { setError(e); setState('error') } })
164 168 return () => { alive = false }
165 − }, [token])
169 + }, [token]) // eslint-disable-line react-hooks/exhaustive-deps
166 170 if (state === 'busy') return <p className="muted" aria-busy="true">Verifying your e-mail…</p>
167 171 if (state === 'ok') {
168 172 return (
169 173 <div className="auth-success" data-testid="verify-success">
170 − <PgCallout kind="success" title="E-mail verified">Your account is active. Sign in to create your first API key.</PgCallout>
171 − <Link to="/signin" className="btn btn-primary">Sign in</Link>
174 + <PgCallout kind="success" title={kind === 'email_change' ? 'E-mail address updated' : 'E-mail verified'}>
175 + {kind === 'email_change' ? 'Your account now uses the new address. Other sessions were signed out.' : 'Your account is active and you are signed in. Create your first API key from the dashboard.'}
176 + </PgCallout>
177 + <Link to={kind === 'email_change' ? '/dashboard/account' : '/dashboard/keys'} className="btn btn-primary" data-testid="verify-go">Go to dashboard</Link>
172 178 </div>
173 179 )
174 180 }
@@ -177,7 +183,7 @@ function Verify({ token }) {
177 183 <PgCallout kind="danger" title={state === 'missing' ? 'Missing token' : 'Verification failed'}>
178 184 {state === 'missing' ? 'Open the link from your verification e-mail.' : friendlyError(error)}
179 185 </PgCallout>
180 − <p className="muted">Need a new link? Sign up again with the same e-mail or contact <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>.</p>
186 + <p className="muted">Need a new link? Sign up again with the same e-mail — we re-send it without changing anything — or contact <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>.</p>
181 187 <Link to="/signup" className="btn">Back to sign up</Link>
182 188 </div>
183 189 )
@@ -214,15 +220,16 @@ function ResetRequest() {
214 220 )
215 221 }
216 222
217 −function NewPasswordForm({ endpoint, token, successTitle, successText, testId }) {
223 +function NewPasswordForm({ endpoint, token, successTitle, successText, testId, withRevokeKeys = false }) {
218 224 const { refresh } = useAuth()
219 225 const navigate = useNavigate()
220 226 const [pw, setPw] = useState('')
221 227 const [pw2, setPw2] = useState('')
228 + const [revokeKeys, setRevokeKeys] = useState(true)
222 229 const [errs, setErrs] = useState({})
223 230 const [busy, setBusy] = useState(false)
224 231 const [error, setError] = useState(null)
225 − const [done, setDone] = useState(false)
232 + const [done, setDone] = useState(null)
226 233 const submit = async e => {
227 234 e.preventDefault()
228 235 const v = {}
@@ -232,17 +239,19 @@ function NewPasswordForm({ endpoint, token, successTitle, successText, testId })
232 239 if (Object.keys(v).length) return
233 240 setBusy(true); setError(null)
234 241 try {
235 − await api(endpoint, { method: 'POST', body: { token, password: pw } })
236 − setDone(true)
237 − if (endpoint.endsWith('accept-invite')) { await refresh(); navigate('/dashboard', { replace: true }) }
242 + const body = { token, password: pw }
243 + if (withRevokeKeys) body.revoke_keys = revokeKeys
244 + const r = await api(endpoint, { method: 'POST', body })
245 + setDone(r.data?.data || {})
246 + if (endpoint.endsWith('accept-invite')) { await refresh(); navigate('/dashboard', { replace: true }) } else await refresh()
238 247 } catch (e2) { setError(e2) } finally { setBusy(false) }
239 248 }
240 249 if (!token) return <PgCallout kind="danger" title="Missing token">Open the link from your e-mail.</PgCallout>
241 250 if (done) {
242 251 return (
243 252 <div className="auth-success" data-testid={testId}>
244 − <PgCallout kind="success" title={successTitle}>{successText}</PgCallout>
245 − <Link to="/signin" className="btn btn-primary">Sign in</Link>
253 + <PgCallout kind="success" title={successTitle}>{successText}{withRevokeKeys && done.keys_revoked > 0 ? ` ${done.keys_revoked} API key${done.keys_revoked === 1 ? ' was' : 's were'} revoked — create a new one from the dashboard.` : ''}</PgCallout>
254 + <Link to="/dashboard" className="btn btn-primary">Go to dashboard</Link>
246 255 </div>
247 256 )
248 257 }
@@ -251,12 +260,18 @@ function NewPasswordForm({ endpoint, token, successTitle, successText, testId })
251 260 {error && <PgCallout kind="danger">{friendlyError(error)}</PgCallout>}
252 261 <PasswordField id="pw" label="New password" value={pw} onChange={setPw} error={errs.pw} autoComplete="new-password" hint={`At least ${MIN_PW} characters.`} autoFocus minLength={MIN_PW} />
253 262 <PasswordField id="pw2" label="Confirm password" value={pw2} onChange={setPw2} error={errs.pw2} autoComplete="new-password" />
263 + {withRevokeKeys && (
264 + <label className="auth-check">
265 + <input type="checkbox" checked={revokeKeys} onChange={e => setRevokeKeys(e.target.checked)} data-testid="revoke-keys" />
266 + <span>Also revoke my API keys (recommended if you think they may have leaked). Every browser session is signed out in any case.</span>
267 + </label>
268 + )}
254 269 <button className="btn btn-primary auth-submit" type="submit" disabled={busy}>{busy ? 'Saving…' : 'Set password'}</button>
255 270 </form>
256 271 )
257 272 }
258 273
259 −const ResetSet = ({ token }) => <NewPasswordForm endpoint="/v1/auth/reset" token={token} successTitle="Password updated" successText="You can now sign in with your new password." testId="reset-done" />
274 +const ResetSet = ({ token }) => <NewPasswordForm endpoint="/v1/auth/reset" token={token} successTitle="Password updated" successText="You are signed in with your new password." testId="reset-done" withRevokeKeys />
260 275 const Invite = ({ token }) => (
261 276 <>
262 277 <p className="muted">You were invited to HF Market Data. Choose a password to activate your account.</p>
modified hfmarketdata/web/src/pages/auth/auth.css +2 −0
@@ -15,3 +15,5 @@
15 15 .auth-foot { font-size: 13px; margin: 0; }
16 16 .auth-success { display: flex; flex-direction: column; gap: 14px; align-items: flex-start; }
17 17 .auth-success .pg-callout { width: 100%; }
18 +.auth-check { display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: var(--fg-1); }
19 +.auth-check input { margin-top: 3px; }
modified hfmarketdata/web/src/pages/dashboard/Account.jsx +195 −32
@@ -1,52 +1,215 @@
1 +// Account: profile (name), tier, password change, e-mail change (confirmation link), quota alerts,
2 +// "sign out everywhere", account deletion. All mutations need the cookie session (never an API key).
1 3 import React, { useState } from 'react'
2 −import { api, CONTACT_EMAIL, TIERS } from '../../app/api.js'
4 +import { useNavigate } from 'react-router-dom'
5 +import { CONTACT_EMAIL, HIGH_USAGE_MAILTO, TIERS, apiData, friendlyError } from '../../app/api.js'
3 6 import { useAuth } from '../../app/auth.jsx'
4 7 import PgCallout from '../../components/PgCallout.jsx'
8 +import { Confirm, Field, PasswordInput, useToast } from './ui.jsx'
5 9
6 −export default function Account() {
7 − const { user } = useAuth()
8 − const [sent, setSent] = useState(false)
9 − const [err, setErr] = useState(null)
10 +const MIN_PW = 10
11 +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
12 +
13 +function useAction() {
10 14 const [busy, setBusy] = useState(false)
11 − const tier = TIERS.find(t => t.id === (user.tier || 'free')) || TIERS[1]
12 − const requestReset = async () => {
13 − setBusy(true); setErr(null)
14 − try { await api('/v1/auth/forgot', { method: 'POST', body: { email: user.email } }); setSent(true) } catch (e) { setErr(e) } finally { setBusy(false) }
15 + const [error, setError] = useState(null)
16 + const run = async fn => {
17 + setBusy(true); setError(null)
18 + try { return await fn() } catch (e) { setError(e); return undefined } finally { setBusy(false) }
15 19 }
20 + return { busy, error, run, setError }
21 +}
22 +
23 +export default function Account() {
24 + const { user, refresh, setUser } = useAuth()
25 + const toast = useToast()
26 + const tier = TIERS.find(t => t.id === (user.tier || 'free')) || TIERS[1]
16 27 return (
17 28 <div className="dash-section">
18 29 <h1>Account</h1>
19 30 <div className="dash-cards">
20 − <div className="card dash-card">
21 − <h2>Profile</h2>
22 − <dl className="dash-dl">
23 − <dt>Name</dt><dd>{user.name || <span className="muted">—</span>}</dd>
24 − <dt>E-mail</dt><dd>{user.email}</dd>
25 − <dt>Role</dt><dd>{user.role || 'user'}</dd>
26 − <dt>Member since</dt><dd>{user.created_at ? String(user.created_at).slice(0, 10) : '—'}</dd>
27 − </dl>
28 − <p className="muted">To change your name or e-mail, write to <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>.</p>
29 − </div>
30 − <div className="card dash-card">
31 − <h2>Password</h2>
32 − <p className="muted">We e-mail you a one-hour link to choose a new password (same flow as “Forgot your password”).</p>
33 − {sent ? <PgCallout kind="success">Reset link sent to <strong>{user.email}</strong>.</PgCallout>
34 − : <button type="button" className="btn" onClick={requestReset} disabled={busy} data-testid="change-password">{busy ? 'Sending…' : 'Send password reset link'}</button>}
35 − {err && <PgCallout kind="danger">{err.message}</PgCallout>}
36 − </div>
31 + <ProfileCard user={user} refresh={refresh} toast={toast} />
37 32 <div className="card dash-card">
38 33 <h2>Tier</h2>
39 34 <p><strong>{tier.name}</strong></p>
40 35 <ul className="dash-ul">
41 − <li>{tier.requests.toLocaleString()} requests / {tier.window}</li>
42 − <li>{tier.rows.toLocaleString()} rows / {tier.window}</li>
43 − <li>{tier.maxRows.toLocaleString()} rows per request</li>
36 + <li>{tier.requests.toLocaleString('en-US')} requests / {tier.window}</li>
37 + <li>{tier.rows.toLocaleString('en-US')} rows / {tier.window}</li>
38 + <li>{tier.maxRows.toLocaleString('en-US')} rows per request</li>
44 39 </ul>
45 − {tier.id !== 'high_usage' && tier.id !== 'unlimited' && (
46 − <a className="btn btn-primary" href={`mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent('HF Market Data — high usage tier')}&body=${encodeURIComponent(`Hello,\n\nI would like the high-usage tier for ${user.email}.\nUse case: `)}`}>Need more? {CONTACT_EMAIL}</a>
47 − )}
40 + {tier.id === 'free' && <a className="btn btn-primary" href={HIGH_USAGE_MAILTO} data-testid="cta-more">Need more? {CONTACT_EMAIL}</a>}
41 + <p className="muted">Everything is free — higher limits are granted on request.</p>
48 42 </div>
43 + <PasswordCard toast={toast} />
44 + <EmailCard user={user} toast={toast} />
45 + <AlertsCard user={user} setUser={setUser} toast={toast} />
46 + <SessionsCard toast={toast} />
47 + <DeleteCard user={user} setUser={setUser} />
49 48 </div>
50 49 </div>
51 50 )
52 51 }
52 +
53 +function ProfileCard({ user, refresh, toast }) {
54 + const [name, setName] = useState(user.name || '')
55 + const { busy, error, run } = useAction()
56 + const save = async e => {
57 + e.preventDefault()
58 + const ok = await run(async () => { await apiData('/v1/me', { method: 'PATCH', body: { name: name.trim() } }); await refresh(); return true })
59 + if (ok) toast.success('Name updated.')
60 + }
61 + return (
62 + <div className="card dash-card">
63 + <h2>Profile</h2>
64 + <dl className="dash-dl">
65 + <dt>E-mail</dt><dd>{user.email}{user.email_verified === false && <span className="dash-badge invited"> unverified</span>}</dd>
66 + <dt>Role</dt><dd>{user.role || 'user'}</dd>
67 + <dt>Member since</dt><dd>{user.created_at ? String(user.created_at).slice(0, 10) : '—'}</dd>
68 + <dt>Last sign-in</dt><dd>{user.last_login_at ? String(user.last_login_at).replace('T', ' ').slice(0, 16) + ' UTC' : '—'}</dd>
69 + </dl>
70 + <form onSubmit={save} className="dash-form" aria-label="Display name">
71 + {error && <PgCallout kind="danger">{friendlyError(error)}</PgCallout>}
72 + <Field label="Display name" id="acc-name">
73 + <input id="acc-name" value={name} onChange={e => setName(e.target.value)} maxLength={200} autoComplete="name" />
74 + </Field>
75 + <div><button type="submit" className="btn" disabled={busy || name.trim() === (user.name || '')}>{busy ? 'Saving…' : 'Save name'}</button></div>
76 + </form>
77 + </div>
78 + )
79 +}
80 +
81 +function PasswordCard({ toast }) {
82 + const [cur, setCur] = useState('')
83 + const [pw, setPw] = useState('')
84 + const [pw2, setPw2] = useState('')
85 + const [errs, setErrs] = useState({})
86 + const { busy, error, run } = useAction()
87 + const submit = async e => {
88 + e.preventDefault()
89 + const v = {}
90 + if (!cur) v.cur = 'Enter your current password.'
91 + if (pw.length < MIN_PW) v.pw = `Use at least ${MIN_PW} characters.`
92 + if (pw2 !== pw) v.pw2 = 'Passwords do not match.'
93 + setErrs(v)
94 + if (Object.keys(v).length) return
95 + const ok = await run(async () => { await apiData('/v1/me/password', { method: 'POST', body: { current_password: cur, new_password: pw } }); return true })
96 + if (ok) { setCur(''); setPw(''); setPw2(''); toast.success('Password changed. Other browsers were signed out.') }
97 + }
98 + return (
99 + <div className="card dash-card">
100 + <h2>Password</h2>
101 + <p className="muted">Changing it signs out every other browser and sends you a notice.</p>
102 + <form onSubmit={submit} noValidate className="dash-form" data-testid="password-form">
103 + {error && <PgCallout kind="danger">{friendlyError(error)}</PgCallout>}
104 + <Field label="Current password" id="pw-cur" error={errs.cur}>{(id, a11y) => <PasswordInput id={id} value={cur} onChange={setCur} autoComplete="current-password" {...a11y} />}</Field>
105 + <Field label="New password" id="pw-new" error={errs.pw} hint={`At least ${MIN_PW} characters.`}>{(id, a11y) => <PasswordInput id={id} value={pw} onChange={setPw} autoComplete="new-password" minLength={MIN_PW} {...a11y} />}</Field>
106 + <Field label="Confirm new password" id="pw-new2" error={errs.pw2}>{(id, a11y) => <PasswordInput id={id} value={pw2} onChange={setPw2} autoComplete="new-password" {...a11y} />}</Field>
107 + <div><button type="submit" className="btn btn-primary" disabled={busy} data-testid="change-password">{busy ? 'Saving…' : 'Change password'}</button></div>
108 + </form>
109 + </div>
110 + )
111 +}
112 +
113 +function EmailCard({ user, toast }) {
114 + const [email, setEmail] = useState('')
115 + const [pw, setPw] = useState('')
116 + const [errs, setErrs] = useState({})
117 + const [sentTo, setSentTo] = useState(null)
118 + const { busy, error, run } = useAction()
119 + const submit = async e => {
120 + e.preventDefault()
121 + const v = {}
122 + if (!EMAIL_RE.test(email)) v.email = 'Enter a valid e-mail address.'
123 + else if (email.trim().toLowerCase() === user.email) v.email = 'This is already your e-mail.'
124 + if (!pw) v.pw = 'Enter your password.'
125 + setErrs(v)
126 + if (Object.keys(v).length) return
127 + const ok = await run(async () => { await apiData('/v1/me/email', { method: 'POST', body: { new_email: email.trim(), password: pw } }); return true })
128 + if (ok) { setSentTo(email.trim()); setEmail(''); setPw(''); toast.success('Confirmation link sent.') }
129 + }
130 + return (
131 + <div className="card dash-card">
132 + <h2>E-mail address</h2>
133 + <p className="muted">We send a confirmation link to the new address; the change applies when you open it, and every session is signed out.</p>
134 + {sentTo && <PgCallout kind="success" title="Check the new inbox">A confirmation link was sent to <strong>{sentTo}</strong> (valid 48 hours). Your account keeps <strong>{user.email}</strong> until you open it.</PgCallout>}
135 + <form onSubmit={submit} noValidate className="dash-form" data-testid="email-form">
136 + {error && <PgCallout kind="danger">{friendlyError(error)}</PgCallout>}
137 + <Field label="New e-mail" id="em-new" error={errs.email}>{(id, a11y) => <input id={id} type="email" value={email} onChange={e => setEmail(e.target.value)} autoComplete="email" required {...a11y} />}</Field>
138 + <Field label="Current password" id="em-pw" error={errs.pw}>{(id, a11y) => <PasswordInput id={id} value={pw} onChange={setPw} autoComplete="current-password" {...a11y} />}</Field>
139 + <div><button type="submit" className="btn" disabled={busy} data-testid="change-email">{busy ? 'Sending…' : 'Send confirmation link'}</button></div>
140 + </form>
141 + </div>
142 + )
143 +}
144 +
145 +function AlertsCard({ user, setUser, toast }) {
146 + const { busy, error, run } = useAction()
147 + const toggle = async e => {
148 + const enabled = e.target.checked
149 + const before = user.quota_alerts
150 + setUser(u => ({ ...u, quota_alerts: enabled })) // optimistic; reverted on error
151 + const d = await run(() => apiData('/v1/me', { method: 'PATCH', body: { quota_alerts: enabled } }))
152 + if (d) { setUser(u => ({ ...u, quota_alerts: d.quota_alerts })); toast.success(enabled ? 'Quota alerts on.' : 'Quota alerts off.') } else setUser(u => ({ ...u, quota_alerts: before }))
153 + }
154 + return (
155 + <div className="card dash-card">
156 + <h2>Quota alerts</h2>
157 + {error && <PgCallout kind="danger">{friendlyError(error)}</PgCallout>}
158 + <label className="dash-check">
159 + <input type="checkbox" checked={user.quota_alerts !== false} onChange={toggle} disabled={busy} data-testid="quota-alerts" />
160 + <span>E-mail me when a key reaches 80 % or 100 % of its rows quota, or gets its first 429 of the day (at most one e-mail per day).</span>
161 + </label>
162 + </div>
163 + )
164 +}
165 +
166 +function SessionsCard({ toast }) {
167 + const [confirm, setConfirm] = useState(false)
168 + const { busy, error, run } = useAction()
169 + const revoke = async () => {
170 + const ok = await run(async () => { await apiData('/v1/me/sessions/revoke-all', { method: 'POST' }); return true })
171 + setConfirm(false)
172 + if (ok) toast.success('Every other browser was signed out. This one stays signed in.')
173 + }
174 + return (
175 + <div className="card dash-card">
176 + <h2>Sessions</h2>
177 + <p className="muted">Signs out every browser and device where you are signed in — except this one. API keys are not affected.</p>
178 + {error && <PgCallout kind="danger">{friendlyError(error)}</PgCallout>}
179 + {confirm ? <Confirm question="Sign out everywhere else?" yesLabel="Yes, sign out everywhere" danger={false} busy={busy} onYes={revoke} onCancel={() => setConfirm(false)} testId="confirm-revoke-all" />
180 + : <button type="button" className="btn" onClick={() => setConfirm(true)} data-testid="revoke-all">Sign out everywhere</button>}
181 + </div>
182 + )
183 +}
184 +
185 +function DeleteCard({ user, setUser }) {
186 + const navigate = useNavigate()
187 + const [open, setOpen] = useState(false)
188 + const [pw, setPw] = useState('')
189 + const [phrase, setPhrase] = useState('')
190 + const { busy, error, run } = useAction()
191 + const canDelete = pw.length > 0 && phrase.trim().toLowerCase() === user.email
192 + const del = async e => {
193 + e.preventDefault()
194 + if (!canDelete) return
195 + const ok = await run(async () => { await apiData('/v1/me', { method: 'DELETE', body: { password: pw } }); return true })
196 + if (ok) { setUser(null); navigate('/', { replace: true }) }
197 + }
198 + return (
199 + <div className="card dash-card dash-danger-zone">
200 + <h2>Delete account</h2>
201 + <p className="muted">Revokes every API key, signs out every session and anonymises your account. Aggregated usage counters are kept anonymously. This cannot be undone.</p>
202 + {!open ? <button type="button" className="btn dash-danger" onClick={() => setOpen(true)} data-testid="delete-open">Delete my account…</button> : (
203 + <form onSubmit={del} noValidate className="dash-form" data-testid="delete-form">
204 + {error && <PgCallout kind="danger">{friendlyError(error)}</PgCallout>}
205 + <Field label={<>Type your e-mail (<code>{user.email}</code>) to confirm</>} id="del-phrase">{(id, a11y) => <input id={id} value={phrase} onChange={e => setPhrase(e.target.value)} autoComplete="off" {...a11y} />}</Field>
206 + <Field label="Current password" id="del-pw">{(id, a11y) => <PasswordInput id={id} value={pw} onChange={setPw} autoComplete="current-password" {...a11y} />}</Field>
207 + <div className="dash-inline-actions">
208 + <button type="submit" className="btn dash-danger" disabled={busy || !canDelete} data-testid="delete-confirm">{busy ? 'Deleting…' : 'Delete permanently'}</button>
209 + <button type="button" className="btn" onClick={() => { setOpen(false); setPw(''); setPhrase('') }}>Cancel</button>
210 + </div>
211 + </form>
212 + )}
213 + </div>
214 + )
215 +}
modified hfmarketdata/web/src/pages/dashboard/Dashboard.jsx +43 −15
@@ -1,13 +1,16 @@
1 −// Dashboard shell: route guard (→ /signin?next=), sidebar, nested sections. Session key lives in memory only.
1 +// Dashboard shell: route guard (→ /signin?next=), sidebar, nested sections, error boundary, toasts.
2 +// Session key lives in memory only.
2 3 import React from 'react'
3 4 import { Navigate, NavLink, Route, Routes, useLocation } from 'react-router-dom'
4 −import { CONTACT_EMAIL } from '../../app/api.js'
5 +import { CONTACT_EMAIL, HIGH_USAGE_MAILTO } from '../../app/api.js'
5 6 import { useAuth } from '../../app/auth.jsx'
6 7 import Account from './Account.jsx'
8 +import ErrorBoundary from './ErrorBoundary.jsx'
7 9 import Keys from './Keys.jsx'
8 10 import Overview from './Overview.jsx'
9 11 import PlaygroundTab from './PlaygroundTab.jsx'
10 12 import { SessionKeyProvider } from './sessionKey.jsx'
13 +import { ErrorNotice, ToastProvider, useToast } from './ui.jsx'
11 14 import Usage from './Usage.jsx'
12 15 import './dashboard.css'
13 16
@@ -16,9 +19,18 @@ const NAV = [
16 19 ]
17 20
18 21 export function RequireAuth({ children, role }) {
19 − const { user, loading } = useAuth()
22 + const { user, loading, error, refresh } = useAuth()
20 23 const location = useLocation()
21 24 if (loading) return <main className="page" aria-busy="true"><p className="muted">Loading your account…</p></main>
25 + if (!user && error) {
26 + return (
27 + <main className="page narrow">
28 + <h1>Cannot reach your account</h1>
29 + <ErrorNotice error={error} onRetry={refresh} />
30 + <p className="muted">The API did not answer (network problem or server error). Your session is untouched — retry in a moment.</p>
31 + </main>
32 + )
33 + }
22 34 if (!user) return <Navigate to={`/signin?next=${encodeURIComponent(location.pathname + location.search)}`} replace />
23 35 if (role && user.role !== role) return <main className="page narrow"><h1>403</h1><p className="muted">This area requires the <code>{role}</code> role.</p></main>
24 36 return children
@@ -28,14 +40,28 @@ export default function Dashboard() {
28 40 return (
29 41 <RequireAuth>
30 42 <SessionKeyProvider>
31 − <DashboardShell />
43 + <ToastProvider>
44 + <DashboardShell />
45 + </ToastProvider>
32 46 </SessionKeyProvider>
33 47 </RequireAuth>
34 48 )
35 49 }
36 50
51 +export function SignOutButton({ className = 'btn btn-ghost' }) {
52 + const { signout } = useAuth()
53 + const toast = useToast()
54 + const [busy, setBusy] = React.useState(false)
55 + const onClick = async () => {
56 + setBusy(true)
57 + try { await signout() } catch (e) { toast.error(e) } finally { setBusy(false) }
58 + }
59 + return <button type="button" className={className} onClick={onClick} disabled={busy} data-testid="signout">{busy ? 'Signing out…' : 'Sign out'}</button>
60 +}
61 +
37 62 function DashboardShell() {
38 − const { user, signout } = useAuth()
63 + const { user } = useAuth()
64 + const location = useLocation()
39 65 return (
40 66 <main className="page dash" data-testid="dashboard">
41 67 <aside className="dash-side">
@@ -51,19 +77,21 @@ function DashboardShell() {
51 77 {user.role === 'admin' && <NavLink to="/admin" className="dash-nav-admin">Admin</NavLink>}
52 78 </nav>
53 79 <div className="dash-side-foot">
54 − <a href={`mailto:${CONTACT_EMAIL}?subject=High%20usage%20tier`} className="muted">Need more? {CONTACT_EMAIL}</a>
55 − <button type="button" className="btn btn-ghost" onClick={signout}>Sign out</button>
80 + {user.tier === 'free' && <a href={HIGH_USAGE_MAILTO} className="muted">Need more? {CONTACT_EMAIL}</a>}
81 + <SignOutButton />
56 82 </div>
57 83 </aside>
58 84 <section className="dash-main">
59 − <Routes>
60 − <Route index element={<Overview />} />
61 − <Route path="keys" element={<Keys />} />
62 − <Route path="usage" element={<Usage />} />
63 − <Route path="playground" element={<PlaygroundTab />} />
64 − <Route path="account" element={<Account />} />
65 − <Route path="*" element={<Navigate to="" replace />} />
66 − </Routes>
85 + <ErrorBoundary resetKey={location.pathname}>
86 + <Routes>
87 + <Route index element={<Overview />} />
88 + <Route path="keys" element={<Keys />} />
89 + <Route path="usage" element={<Usage />} />
90 + <Route path="playground" element={<PlaygroundTab />} />
91 + <Route path="account" element={<Account />} />
92 + <Route path="*" element={<Navigate to="" replace />} />
93 + </Routes>
94 + </ErrorBoundary>
67 95 </section>
68 96 </main>
69 97 )
added hfmarketdata/web/src/pages/dashboard/ErrorBoundary.jsx +38 −0
@@ -0,0 +1,38 @@
1 +// Error boundary for the dashboard / admin sections: a rendering bug in one section must not blank the whole app.
2 +// Wrap it around any lazy page; `resetKey` (e.g. the route path) re-arms it when the user navigates.
3 +import React from 'react'
4 +import { CONTACT_EMAIL } from '../../app/api.js'
5 +
6 +export default class ErrorBoundary extends React.Component {
7 + constructor(props) {
8 + super(props)
9 + this.state = { error: null }
10 + }
11 +
12 + static getDerivedStateFromError(error) {
13 + return { error }
14 + }
15 +
16 + componentDidUpdate(prev) {
17 + if (this.state.error && prev.resetKey !== this.props.resetKey) this.setState({ error: null })
18 + }
19 +
20 + componentDidCatch(error, info) {
21 + if (import.meta.env.DEV) console.error('[dashboard] render error', error, info?.componentStack)
22 + }
23 +
24 + render() {
25 + if (!this.state.error) return this.props.children
26 + return (
27 + <div className="dash-section" role="alert" data-testid="error-boundary">
28 + <h1>Something went wrong</h1>
29 + <p className="muted">This section failed to render. Reloading usually fixes it; if it keeps happening, write to <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a> with the message below.</p>
30 + <pre className="dash-pre">{String(this.state.error?.message || this.state.error)}</pre>
31 + <div className="dash-reveal-actions">
32 + <button type="button" className="btn btn-primary" onClick={() => this.setState({ error: null })}>Try again</button>
33 + <button type="button" className="btn" onClick={() => window.location.reload()}>Reload the page</button>
34 + </div>
35 + </div>
36 + )
37 + }
38 +}
modified hfmarketdata/web/src/pages/dashboard/Keys.jsx +76 −37
@@ -1,119 +1,158 @@
1 −// API keys: list, create (one-time reveal), rotate, revoke. Keys are never persisted client-side.
1 +// API keys: list, create (one-time reveal), rotate, revoke, rename. Keys are never persisted client-side.
2 +// Backend contract: GET /v1/me/keys → [{ id, name, prefix, status, created_at, last_used_at, last_used_ip, expires_at, expired, note, scopes }]
3 +// POST /v1/me/keys { name, note?, expires_in_days? } → 201 { ...key, key: "hfmd_live_…" } (shown once)
2 4 import React, { useCallback, useEffect, useState } from 'react'
3 5 import { useNavigate } from 'react-router-dom'
4 −import { api } from '../../app/api.js'
6 +import { apiData } from '../../app/api.js'
5 7 import PgCallout from '../../components/PgCallout.jsx'
6 8 import PgCopyButton from '../../components/PgCopyButton.jsx'
7 −import { ago } from './Overview.jsx'
8 9 import { useSessionKey } from './sessionKey.jsx'
10 +import { Confirm, ErrorNotice, Field, Skeleton, ago, fmtDate, useToast } from './ui.jsx'
9 11
10 −const fmtDate = ts => (ts ? String(ts).slice(0, 10) : '—')
11 −const errText = e => (e?.status === 404 && !e.code ? 'API key management is not deployed on this server yet — coming soon.' : e?.message || 'Request failed.')
12 +const EXPIRY = [['', 'Never'], ['30', '30 days'], ['90', '90 days'], ['365', '1 year']]
12 13
13 14 export default function Keys() {
14 15 const [keys, setKeys] = useState(null)
15 16 const [err, setErr] = useState(null)
16 17 const [name, setName] = useState('')
18 + const [note, setNote] = useState('')
19 + const [expiry, setExpiry] = useState('')
17 20 const [busy, setBusy] = useState(false)
18 21 const [revealed, setRevealed] = useState(null) // { key, name, action }
19 22 const [confirm, setConfirm] = useState(null) // { id, action }
23 + const [editing, setEditing] = useState(null) // { id, name, note }
20 24 const session = useSessionKey()
21 25 const navigate = useNavigate()
26 + const toast = useToast()
22 27
23 28 const load = useCallback(async () => {
24 29 try {
25 − const r = await api('/v1/me/keys')
26 − const d = r.data
27 − setKeys(Array.isArray(d) ? d : Array.isArray(d?.data) ? d.data : Array.isArray(d?.keys) ? d.keys : [])
30 + const d = await apiData('/v1/me/keys')
31 + setKeys(Array.isArray(d) ? d : [])
28 32 setErr(null)
29 − } catch (e) { setErr(e); setKeys([]) }
33 + } catch (e) { setErr(e); setKeys(k => k || []) }
30 34 }, [])
31 35 useEffect(() => { load() }, [load])
32 36
37 + const forgetIfSession = id => { if (session.key && keys?.find(k => k.id === id && session.key.startsWith(k.prefix))) session.setKey('') }
38 +
33 39 const create = async e => {
34 40 e.preventDefault()
35 41 setBusy(true); setErr(null)
36 42 try {
37 − const r = await api('/v1/me/keys', { method: 'POST', body: { name: name.trim() || 'default' } })
38 − const d = r.data?.data || r.data
43 + const body = { name: name.trim() || 'default' }
44 + if (note.trim()) body.note = note.trim()
45 + if (expiry) body.expires_in_days = Number(expiry)
46 + const d = await apiData('/v1/me/keys', { method: 'POST', body })
39 47 setRevealed({ key: d.key, name: d.name || name || 'default', action: 'created' })
40 − setName('')
48 + setName(''); setNote(''); setExpiry('')
41 49 await load()
42 50 } catch (e2) { setErr(e2) } finally { setBusy(false) }
43 51 }
44 52 const rotate = async id => {
45 53 setBusy(true); setErr(null)
46 54 try {
47 − const r = await api(`/v1/me/keys/${id}/rotate`, { method: 'POST' })
48 − const d = r.data?.data || r.data
55 + const d = await apiData(`/v1/me/keys/${id}/rotate`, { method: 'POST' })
49 56 setRevealed({ key: d.key, name: d.name || '', action: 'rotated' })
50 − if (session.key && keys?.find(k => k.id === id && session.key.startsWith(k.prefix))) session.setKey('')
57 + forgetIfSession(id)
51 58 await load()
59 + toast.success('Key rotated — the old key is revoked.')
52 60 } catch (e2) { setErr(e2) } finally { setBusy(false); setConfirm(null) }
53 61 }
54 62 const revoke = async id => {
55 63 setBusy(true); setErr(null)
56 64 try {
57 − await api(`/v1/me/keys/${id}`, { method: 'DELETE' })
58 − if (session.key && keys?.find(k => k.id === id && session.key.startsWith(k.prefix))) session.setKey('')
65 + await apiData(`/v1/me/keys/${id}`, { method: 'DELETE' })
66 + forgetIfSession(id)
59 67 await load()
68 + toast.success('Key revoked.')
60 69 } catch (e2) { setErr(e2) } finally { setBusy(false); setConfirm(null) }
61 70 }
71 + const saveEdit = async e => {
72 + e.preventDefault()
73 + setBusy(true); setErr(null)
74 + try {
75 + await apiData(`/v1/me/keys/${editing.id}`, { method: 'PATCH', body: { name: editing.name.trim() || 'default', note: editing.note } })
76 + setEditing(null)
77 + await load()
78 + } catch (e2) { setErr(e2) } finally { setBusy(false) }
79 + }
62 80 const useInPlayground = () => { session.setKey(revealed.key, revealed.name); setRevealed(null); navigate('/dashboard/playground') }
63 81
64 82 return (
65 83 <div className="dash-section">
66 84 <h1>API keys</h1>
67 − <p className="muted">Send your key as <code>Authorization: Bearer hfmd_live_…</code> (or <code>?api_key=</code>). Only the prefix is stored server-side: the full key is shown once, at creation.</p>
68 − {err && <PgCallout kind={err.status === 404 && !err.code ? 'warn' : 'danger'}>{errText(err)}</PgCallout>}
85 + <p className="muted">Send your key as <code>Authorization: Bearer hfmd_live_…</code>. Only the prefix is stored server-side: the full key is shown once, at creation. Keys can read your account (<code>GET /v1/me</code>, usage, limits) but never change it — that needs this signed-in browser.</p>
86 + <ErrorNotice error={err} onRetry={load} />
69 87
70 88 {revealed && (
71 89 <div className="dash-reveal card" role="dialog" aria-labelledby="dash-reveal-title" data-testid="key-reveal">
72 90 <h2 id="dash-reveal-title">Key {revealed.action}{revealed.name ? ` — ${revealed.name}` : ''}</h2>
73 − <PgCallout kind="warn" title="Store it now">This is the only time the full key is displayed. We keep a hash, not the key — if you lose it, rotate it.</PgCallout>
91 + <PgCallout kind="warn" title="Store it now — it will not be shown again">We keep a hash, not the key. If you lose it, rotate it. Never paste it in an e-mail, a ticket or a public repository.</PgCallout>
74 92 <div className="dash-key-row">
75 93 <code className="dash-key" data-testid="key-value">{revealed.key}</code>
76 94 <PgCopyButton text={revealed.key} label="Copy key" />
77 95 </div>
78 96 <div className="dash-reveal-actions">
79 97 <button type="button" className="btn btn-primary" onClick={useInPlayground}>Use in playground (this session)</button>
80 − <button type="button" className="btn" onClick={() => setRevealed(null)}>I stored it, close</button>
98 + <button type="button" className="btn" onClick={() => setRevealed(null)} data-testid="key-reveal-close">I stored it, close</button>
81 99 </div>
82 100 </div>
83 101 )}
84 102
85 − <form className="dash-create" onSubmit={create}>
86 − <label htmlFor="key-name">New key name</label>
87 − <input id="key-name" value={name} onChange={e => setName(e.target.value)} placeholder="e.g. research laptop" maxLength={60} />
88 − <button type="submit" className="btn btn-primary" disabled={busy} data-testid="create-key">+ Create key</button>
103 + <form className="card dash-form" onSubmit={create} aria-label="Create a key">
104 + <h2>Create a key</h2>
105 + <div className="dash-form-row">
106 + <Field label="New key name" id="key-name">
107 + <input id="key-name" value={name} onChange={e => setName(e.target.value)} placeholder="e.g. research laptop" maxLength={100} autoComplete="off" />
108 + </Field>
109 + <Field label="Expires" id="key-expiry" hint="Optional lifetime.">
110 + <select id="key-expiry" value={expiry} onChange={e => setExpiry(e.target.value)}>{EXPIRY.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select>
111 + </Field>
112 + </div>
113 + <Field label="Note (optional)" id="key-note" hint="Where the key lives, what it is for.">
114 + <input id="key-note" value={note} onChange={e => setNote(e.target.value)} maxLength={500} autoComplete="off" />
115 + </Field>
116 + <div><button type="submit" className="btn btn-primary" disabled={busy} data-testid="create-key">+ Create key</button></div>
89 117 </form>
90 118
91 − {keys === null ? <p className="muted">Loading…</p> : (
119 + {keys === null ? <div className="card"><Skeleton lines={3} /></div> : (
92 120 <div className="dash-table-wrap">
93 121 <table className="dash-table" data-testid="keys-table">
94 − <thead><tr><th>Name</th><th>Prefix</th><th>Created</th><th>Last used</th><th>Status</th><th className="dash-actions-th">Actions</th></tr></thead>
122 + <thead><tr><th>Name</th><th>Prefix</th><th>Created</th><th>Expires</th><th>Last used</th><th>Status</th><th className="dash-actions-th">Actions</th></tr></thead>
95 123 <tbody>
96 − {keys.length === 0 && <tr><td colSpan={6} className="muted">No key yet — create your first one above.</td></tr>}
124 + {keys.length === 0 && <tr><td colSpan={7} className="muted">No key yet — create your first one above.</td></tr>}
97 125 {keys.map(k => (
98 − <tr key={k.id} className={k.status !== 'active' ? 'dash-row-muted' : ''}>
99 − <td>{k.name}</td>
126 + <tr key={k.id} className={k.status !== 'active' || k.expired ? 'dash-row-muted' : ''}>
127 + <td className="dash-wrap">
128 + {editing?.id === k.id ? (
129 + <form onSubmit={saveEdit} className="dash-inline-actions" aria-label={`Edit ${k.name}`}>
130 + <input value={editing.name} onChange={e => setEditing(v => ({ ...v, name: e.target.value }))} aria-label="Name" maxLength={100} />
131 + <input value={editing.note} onChange={e => setEditing(v => ({ ...v, note: e.target.value }))} aria-label="Note" placeholder="Note" maxLength={500} />
132 + <button type="submit" className="btn btn-sm btn-primary" disabled={busy}>Save</button>
133 + <button type="button" className="btn btn-sm" onClick={() => setEditing(null)}>Cancel</button>
134 + </form>
135 + ) : (
136 + <>{k.name}{k.note && <div className="dash-subtle">{k.note}</div>}</>
137 + )}
138 + </td>
100 139 <td className="mono">{k.prefix}…</td>
101 140 <td>{fmtDate(k.created_at)}</td>
102 − <td>{ago(k.last_used_at)}</td>
103 − <td><span className={`dash-badge ${k.status}`}>{k.status}</span></td>
141 + <td>{k.expires_at ? <span className={k.expired ? 'dash-danger' : ''}>{fmtDate(k.expires_at)}{k.expired ? ' (expired)' : ''}</span> : <span className="muted">never</span>}</td>
142 + <td>{ago(k.last_used_at)}{k.last_used_ip && <div className="dash-subtle mono" title="Hashed client address">ip·{String(k.last_used_ip).slice(0, 8)}</div>}</td>
143 + <td><span className={`dash-badge ${k.expired ? 'revoked' : k.status}`}>{k.expired && k.status === 'active' ? 'expired' : k.status}</span></td>
104 144 <td className="dash-actions">
105 145 {k.status === 'active' && confirm?.id !== k.id && (
106 146 <>
147 + <button type="button" className="btn btn-sm" onClick={() => setEditing({ id: k.id, name: k.name, note: k.note || '' })} disabled={busy}>Edit</button>
107 148 <button type="button" className="btn btn-sm" onClick={() => setConfirm({ id: k.id, action: 'rotate' })} disabled={busy}>Rotate</button>
108 149 <button type="button" className="btn btn-sm dash-danger" onClick={() => setConfirm({ id: k.id, action: 'revoke' })} disabled={busy}>Revoke</button>
109 150 </>
110 151 )}
111 152 {confirm?.id === k.id && (
112 − <span className="dash-confirm" role="alertdialog" aria-label={`Confirm ${confirm.action}`}>
113 − {confirm.action === 'rotate' ? 'Revoke this key and issue a new one?' : 'Revoke permanently? Requests with this key will fail.'}
114 − <button type="button" className="btn btn-sm dash-danger" onClick={() => (confirm.action === 'rotate' ? rotate(k.id) : revoke(k.id))} disabled={busy} data-testid={`confirm-${confirm.action}`}>Yes, {confirm.action}</button>
115 − <button type="button" className="btn btn-sm" onClick={() => setConfirm(null)}>Cancel</button>
116 − </span>
153 + <Confirm question={confirm.action === 'rotate' ? 'Revoke this key and issue a new one?' : 'Revoke permanently? Requests with this key will fail.'}
154 + yesLabel={`Yes, ${confirm.action}`} busy={busy} testId={`confirm-${confirm.action}`}
155 + onYes={() => (confirm.action === 'rotate' ? rotate(k.id) : revoke(k.id))} onCancel={() => setConfirm(null)} />
117 156 )}
118 157 </td>
119 158 </tr>
modified hfmarketdata/web/src/pages/dashboard/Overview.jsx +68 −31
@@ -1,55 +1,92 @@
1 −import React, { useEffect, useState } from 'react'
1 +// Overview: today's totals (requests, rows, 429s — from the 24 h series), active keys, live quota per key.
2 +import React, { useCallback, useEffect, useState } from 'react'
2 3 import { Link } from 'react-router-dom'
3 −import { api, TIERS } from '../../app/api.js'
4 +import { apiData, TIERS } from '../../app/api.js'
4 5 import { useAuth } from '../../app/auth.jsx'
5 −import PgCallout from '../../components/PgCallout.jsx'
6 +import { ErrorNotice, QuotaBar, Skeleton, StatSkeleton, ago, n, todayUtc } from './ui.jsx'
6 7
7 −const n = v => (v == null ? '—' : Number(v).toLocaleString())
8 −export const ago = ts => {
9 − if (!ts) return 'never'
10 − const s = Math.max(0, Math.floor((Date.now() - Date.parse(ts)) / 1000))
11 − if (s < 60) return 'just now'
12 − if (s < 3600) return `${Math.floor(s / 60)} min ago`
13 − if (s < 86400) return `${Math.floor(s / 3600)} h ago`
14 − return `${Math.floor(s / 86400)} d ago`
8 +export { ago } from './ui.jsx'
9 +
10 +export function todayTotals(points) {
11 + const today = todayUtc()
12 + const out = { requests: 0, rows: 0, status_429: 0, last: null }
13 + for (const p of points || []) {
14 + if (!String(p.t).startsWith(today)) continue
15 + out.requests += Number(p.requests) || 0
16 + out.rows += Number(p.rows) || 0
17 + out.status_429 += Number(p.status_429) || 0
18 + }
19 + for (const p of points || []) if ((Number(p.requests) || 0) > 0 && (!out.last || p.t > out.last)) out.last = p.t
20 + return out
15 21 }
16 22
17 23 export default function Overview() {
18 24 const { user } = useAuth()
19 25 const [usage, setUsage] = useState(null)
20 26 const [keys, setKeys] = useState(null)
27 + const [live, setLive] = useState(null)
21 28 const [err, setErr] = useState(null)
22 − useEffect(() => {
29 + const [tick, setTick] = useState(0)
30 + const load = useCallback(() => {
23 31 let alive = true
24 − api('/v1/me/usage?range=24h').then(r => alive && setUsage(r.data?.data || r.data)).catch(e => alive && setErr(e))
25 − api('/v1/me/keys').then(r => alive && setKeys(Array.isArray(r.data) ? r.data : Array.isArray(r.data?.data) ? r.data.data : [])).catch(() => alive && setKeys([]))
32 + setErr(null)
33 + Promise.all([apiData('/v1/me/usage?range=24h'), apiData('/v1/me/keys'), apiData('/v1/me/limits')])
34 + .then(([u, k, l]) => { if (alive) { setUsage(u); setKeys(Array.isArray(k) ? k : []); setLive(l) } })
35 + .catch(e => alive && setErr(e))
26 36 return () => { alive = false }
27 37 }, [])
38 + useEffect(load, [load, tick])
39 + const limits = user.limits || {}
28 40 const tier = TIERS.find(t => t.id === (user.tier || 'free')) || TIERS[1]
29 − const limits = user.limits || { window: tier.window, requests: tier.requests, rows: tier.rows, max_rows: tier.maxRows }
30 − const today = new Date().toISOString().slice(0, 10)
31 − const series = usage?.series || []
32 − const todaySeries = series.filter(s => String(s.ts).startsWith(today))
33 − const sum = k => todaySeries.reduce((a, s) => a + (Number(s[k]) || 0), 0)
34 − const lastTs = series.filter(s => (Number(s.requests) || 0) > 0).map(s => s.ts).sort().pop()
35 − const lastKey = (keys || []).map(k => k.last_used_at).filter(Boolean).sort().pop()
36 − const last = [lastTs, lastKey].filter(Boolean).sort().pop()
41 + const window_ = limits.window_seconds ? (limits.window_seconds >= 3600 ? 'hour' : 'minute') : tier.window
42 + const today = usage ? todayTotals(usage.points) : null
43 + const active = (keys || []).filter(k => k.status === 'active')
44 + const lastKey = active.map(k => k.last_used_at).filter(Boolean).sort().pop()
45 + const last = [today?.last, lastKey].filter(Boolean).sort().pop()
46 + const loading = !usage && !err
37 47 return (
38 48 <div className="dash-section">
39 − <h1>Overview</h1>
40 − {err && err.status === 404 && <PgCallout kind="warn">Usage endpoints are not deployed on this server yet — coming soon.</PgCallout>}
41 − <div className="dash-stats">
42 − <div className="dash-stat"><span className="dash-stat-label">Tier</span><span className="dash-stat-value">{user.tier || 'free'}</span><span className="muted">{n(limits.requests)} req / {limits.window || tier.window} · {n(limits.max_rows)} rows / request</span></div>
43 − <div className="dash-stat"><span className="dash-stat-label">Requests today</span><span className="dash-stat-value">{usage ? n(sum('requests')) : '—'}</span><span className="muted">{usage && sum('status_429') > 0 ? `${n(sum('status_429'))} × 429` : `${n(limits.requests)} / ${limits.window || tier.window} allowed`}</span></div>
44 − <div className="dash-stat"><span className="dash-stat-label">Rows today</span><span className="dash-stat-value">{usage ? n(sum('rows')) : '—'}</span><span className="muted">{n(limits.rows)} / {limits.window || tier.window} allowed</span></div>
45 − <div className="dash-stat"><span className="dash-stat-label">Last request</span><span className="dash-stat-value">{ago(last)}</span><span className="muted">{last ? new Date(last).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : 'No request recorded yet'}</span></div>
49 + <div className="dash-head-row">
50 + <h1>Overview</h1>
51 + <button type="button" className="btn btn-sm" onClick={() => setTick(t => t + 1)} aria-label="Refresh">Refresh</button>
46 52 </div>
53 + <ErrorNotice error={err} onRetry={() => setTick(t => t + 1)} />
54 + {loading ? <StatSkeleton /> : (
55 + <div className="dash-stats" data-testid="overview-stats">
56 + <div className="dash-stat"><span className="dash-stat-label">Tier</span><span className="dash-stat-value">{user.tier || 'free'}</span><span className="muted">{n(limits.requests ?? tier.requests)} req / {window_} · {n(limits.max_rows_per_request ?? tier.maxRows)} rows / request</span></div>
57 + <div className="dash-stat"><span className="dash-stat-label">Requests today</span><span className="dash-stat-value" data-testid="stat-requests">{today ? n(today.requests) : '—'}</span><span className="muted">UTC day · {n(limits.rows ?? tier.rows)} rows / {window_} allowed</span></div>
58 + <div className="dash-stat"><span className="dash-stat-label">Rows today</span><span className="dash-stat-value" data-testid="stat-rows">{today ? n(today.rows) : '—'}</span><span className="muted">Parquet counts ½ row</span></div>
59 + <div className="dash-stat"><span className="dash-stat-label">429 today</span><span className={`dash-stat-value ${today?.status_429 ? 'dash-danger' : ''}`} data-testid="stat-429">{today ? n(today.status_429) : '—'}</span><span className="muted">{today?.status_429 ? 'Rate limit hit — see Usage' : 'No rate limit hit'}</span></div>
60 + <div className="dash-stat"><span className="dash-stat-label">Active keys</span><span className="dash-stat-value" data-testid="stat-keys">{keys ? n(active.length) : '—'}</span><span className="muted">Last request {ago(last)}</span></div>
61 + </div>
62 + )}
63 +
64 + <h2>Quota now</h2>
65 + {!live && !err ? <div className="card"><Skeleton lines={3} /></div> : live && live.keys.length === 0 ? (
66 + <div className="card dash-card"><p className="muted">No active key — create one to see its live counters here.</p><Link to="keys" className="btn btn-primary">Create a key</Link></div>
67 + ) : live && (
68 + <div className="dash-cards" data-testid="quota-now">
69 + {live.keys.map(k => (
70 + <div key={k.key_id} className="card dash-quota-card">
71 + <h3>{k.name} <code className="dash-mono-sm">{k.prefix}…</code> <span className="dash-badge">{k.tier}</span></h3>
72 + {k.redis === false ? <p className="muted">Counters unavailable (rate limiter offline).</p> : (
73 + <>
74 + <QuotaBar label={`Requests / ${k.window_seconds >= 3600 ? 'hour' : 'min'}`} used={k.requests.limit - k.requests.remaining} limit={k.requests.limit} />
75 + <QuotaBar label={`Rows / ${k.window_seconds >= 3600 ? 'hour' : 'min'}`} used={k.rows.limit - k.rows.remaining} limit={k.rows.limit} />
76 + <span className="dash-subtle">Window resets {k.requests.reset ? ago(new Date(k.requests.reset * 1000).toISOString()).replace(' ago', '').replace('just now', 'now') : '—'} · max {n(k.max_rows_per_request)} rows / request{k.expires_at ? ` · expires ${String(k.expires_at).slice(0, 10)}` : ''}</span>
77 + </>
78 + )}
79 + </div>
80 + ))}
81 + </div>
82 + )}
83 +
47 84 <div className="dash-cards">
48 85 <div className="card dash-card">
49 86 <h2>API keys</h2>
50 − {keys === null ? <p className="muted">Loading…</p> : keys.length === 0
87 + {keys === null ? <Skeleton lines={1} /> : active.length === 0
51 88 ? <p className="muted">You have no key yet. Create one to unlock the free-tier limits and the WebSocket stream.</p>
52 − : <p className="muted">{keys.filter(k => k.status === 'active').length} active key{keys.filter(k => k.status === 'active').length === 1 ? '' : 's'}.</p>}
89 + : <p className="muted">{active.length} active key{active.length === 1 ? '' : 's'}.</p>}
53 90 <Link to="keys" className="btn">Manage keys</Link>
54 91 </div>
55 92 <div className="card dash-card">
modified hfmarketdata/web/src/pages/dashboard/PlaygroundTab.jsx +4 −5
@@ -1,7 +1,7 @@
1 1 // Dashboard playground: same component, with a session-only key injected (pasted or freshly created).
2 2 import React, { useEffect, useState } from 'react'
3 3 import { Link } from 'react-router-dom'
4 −import { api } from '../../app/api.js'
4 +import { apiData, friendlyError } from '../../app/api.js'
5 5 import PgCallout from '../../components/PgCallout.jsx'
6 6 import Playground from '../../playground/Playground.jsx'
7 7 import { maskKey, useSessionKey } from './sessionKey.jsx'
@@ -12,7 +12,7 @@ export default function PlaygroundTab() {
12 12 const [keys, setKeys] = useState(null)
13 13 const [busy, setBusy] = useState(false)
14 14 const [err, setErr] = useState(null)
15 − useEffect(() => { let alive = true; api('/v1/me/keys').then(r => alive && setKeys(Array.isArray(r.data) ? r.data : Array.isArray(r.data?.data) ? r.data.data : [])).catch(() => alive && setKeys([])); return () => { alive = false } }, [])
15 + useEffect(() => { let alive = true; apiData('/v1/me/keys').then(d => alive && setKeys(Array.isArray(d) ? d : [])).catch(() => alive && setKeys([])); return () => { alive = false } }, [])
16 16
17 17 const usePasted = e => {
18 18 e.preventDefault()
@@ -23,10 +23,9 @@ export default function PlaygroundTab() {
23 23 const createAndUse = async () => {
24 24 setBusy(true); setErr(null)
25 25 try {
26 − const r = await api('/v1/me/keys', { method: 'POST', body: { name: `playground ${new Date().toISOString().slice(0, 10)}` } })
27 − const d = r.data?.data || r.data
26 + const d = await apiData('/v1/me/keys', { method: 'POST', body: { name: `playground ${new Date().toISOString().slice(0, 10)}` } })
28 27 session.setKey(d.key, d.name)
29 − } catch (e) { setErr(e.status === 404 && !e.code ? 'Key management is not deployed on this server yet.' : e.message) } finally { setBusy(false) }
28 + } catch (e) { setErr(friendlyError(e)) } finally { setBusy(false) }
30 29 }
31 30
32 31 return (
modified hfmarketdata/web/src/pages/dashboard/Usage.jsx +33 −23
@@ -1,16 +1,14 @@
1 −// Usage: 24h / 7d / 30d, SVG charts, per-day table, CSV export of the series.
1 +// Usage: 24h / 7d / 30d, per-key filter, SVG charts, per-day table, server-side CSV export.
2 +// Backend contract: GET /v1/me/usage?range=&key_id= → { points: [{ t, requests, rows, status_429, bytes, rows_parquet }], totals, principals }
2 3 import React, { useEffect, useMemo, useState } from 'react'
3 −import { api } from '../../app/api.js'
4 +import { BASE_URL, apiData } from '../../app/api.js'
4 5 import DashChart from '../../components/DashChart.jsx'
5 −import PgCallout from '../../components/PgCallout.jsx'
6 +import { ErrorNotice, Skeleton, n } from './ui.jsx'
6 7
7 8 const RANGES = ['24h', '7d', '30d']
8 −const n = v => Number(v || 0).toLocaleString()
9 9
10 −export function toCsv(series) {
11 − const cols = ['ts', 'requests', 'rows', 'status_429']
12 − return [cols.join(','), ...series.map(s => cols.map(c => s[c] ?? '').join(','))].join('\n')
13 −}
10 +/** DashChart reads `ts`; the API sends `t`. */
11 +export const toSeries = points => (points || []).map(p => ({ ts: p.t, ...p })).sort((a, b) => String(a.ts).localeCompare(String(b.ts)))
14 12
15 13 export function byDay(series) {
16 14 const m = new Map()
@@ -25,21 +23,26 @@ export function byDay(series) {
25 23
26 24 export default function Usage() {
27 25 const [range, setRange] = useState('7d')
26 + const [keyId, setKeyId] = useState('')
27 + const [keys, setKeys] = useState(null)
28 28 const [data, setData] = useState(null)
29 29 const [err, setErr] = useState(null)
30 30 const [loading, setLoading] = useState(true)
31 + const [tick, setTick] = useState(0)
32 + useEffect(() => { let alive = true; apiData('/v1/me/keys').then(k => alive && setKeys(Array.isArray(k) ? k : [])).catch(() => alive && setKeys([])); return () => { alive = false } }, [])
31 33 useEffect(() => {
32 34 let alive = true
33 35 setLoading(true)
34 − api(`/v1/me/usage?range=${range}`).then(r => { if (alive) { setData(r.data?.data || r.data); setErr(null) } }).catch(e => alive && setErr(e)).finally(() => alive && setLoading(false))
36 + const q = new URLSearchParams({ range })
37 + if (keyId) q.set('key_id', keyId)
38 + apiData(`/v1/me/usage?${q}`).then(d => { if (alive) { setData(d); setErr(null) } }).catch(e => alive && setErr(e)).finally(() => alive && setLoading(false))
35 39 return () => { alive = false }
36 − }, [range])
37 − const series = useMemo(() => (data?.series || []).slice().sort((a, b) => String(a.ts).localeCompare(String(b.ts))), [data])
40 + }, [range, keyId, tick])
41 + const series = useMemo(() => toSeries(data?.points), [data])
38 42 const days = useMemo(() => byDay(series), [series])
39 − const csvUrl = useMemo(() => (series.length ? URL.createObjectURL(new Blob([toCsv(series)], { type: 'text/csv' })) : null), [series])
40 − useEffect(() => () => { if (csvUrl) URL.revokeObjectURL(csvUrl) }, [csvUrl])
41 − const totals = data?.totals || { requests: series.reduce((a, s) => a + (Number(s.requests) || 0), 0), rows: series.reduce((a, s) => a + (Number(s.rows) || 0), 0) }
43 + const totals = data?.totals || { requests: 0, rows: 0, status_429: 0 }
42 44 const xFormat = range === '24h' ? ts => String(ts).slice(11, 16) : ts => String(ts).slice(5, 10)
45 + const csvHref = `${BASE_URL}/v1/me/usage.csv?range=${range}${keyId ? `&key_id=${keyId}` : ''}`
43 46 return (
44 47 <div className="dash-section">
45 48 <div className="dash-head-row">
@@ -47,27 +50,34 @@ export default function Usage() {
47 50 <div className="dash-range" role="radiogroup" aria-label="Range">
48 51 {RANGES.map(r => <button key={r} type="button" role="radio" aria-checked={range === r} className={`dash-range-btn ${range === r ? 'active' : ''}`} onClick={() => setRange(r)}>{r}</button>)}
49 52 </div>
50 − {csvUrl && <a className="btn btn-sm" href={csvUrl} download={`hfmd-usage-${range}.csv`}>Export CSV</a>}
53 + <label className="dash-key-select">Key
54 + <select value={keyId} onChange={e => setKeyId(e.target.value)} aria-label="Key" data-testid="usage-key">
55 + <option value="">All keys</option>
56 + {(keys || []).map(k => <option key={k.id} value={k.id}>{k.name} · {k.prefix}…{k.status !== 'active' ? ` (${k.status})` : ''}</option>)}
57 + </select>
58 + </label>
59 + <a className="btn btn-sm" href={csvHref} download={`hfmd-usage-${range}${keyId ? `-key${keyId}` : ''}.csv`} data-testid="usage-csv">Export CSV</a>
51 60 </div>
52 − {err && <PgCallout kind={err.status === 404 && !err.code ? 'warn' : 'danger'}>{err.status === 404 && !err.code ? 'Usage endpoints are not deployed on this server yet — coming soon.' : err.message}</PgCallout>}
61 + <ErrorNotice error={err} onRetry={() => setTick(t => t + 1)} />
53 62 <div className="dash-stats dash-stats-3">
54 − <div className="dash-stat"><span className="dash-stat-label">Requests · {range}</span><span className="dash-stat-value">{n(totals.requests)}</span></div>
55 − <div className="dash-stat"><span className="dash-stat-label">Rows · {range}</span><span className="dash-stat-value">{n(totals.rows)}</span></div>
56 − <div className="dash-stat"><span className="dash-stat-label">429 responses</span><span className="dash-stat-value">{n(series.reduce((a, s) => a + (Number(s.status_429) || 0), 0))}</span></div>
63 + <div className="dash-stat"><span className="dash-stat-label">Requests · {range}</span><span className="dash-stat-value">{data ? n(totals.requests) : '—'}</span></div>
64 + <div className="dash-stat"><span className="dash-stat-label">Rows · {range}</span><span className="dash-stat-value">{data ? n(totals.rows) : '—'}</span></div>
65 + <div className="dash-stat"><span className="dash-stat-label">429 responses</span><span className="dash-stat-value">{data ? n(totals.status_429) : '—'}</span></div>
57 66 </div>
58 − {loading && !data ? <p className="muted" aria-busy="true">Loading…</p> : (
67 + {loading && !data ? <div className="card"><Skeleton lines={4} height={18} /></div> : (
59 68 <>
60 69 <div className="dash-charts">
61 − <div className="card"><DashChart series={series} valueKey="requests" label={`Requests per ${range === '24h' ? 'minute' : 'interval'}`} xFormat={xFormat} /></div>
70 + <div className="card"><DashChart series={series} valueKey="requests" label={`Requests per ${range === '24h' ? 'minute' : range === '7d' ? 'hour' : 'day'}`} xFormat={xFormat} /></div>
62 71 <div className="card"><DashChart series={series} valueKey="rows" label="Rows" color="var(--accent-2)" xFormat={xFormat} /></div>
72 + {totals.status_429 > 0 && <div className="card"><DashChart series={series} valueKey="status_429" label="429 responses" color="var(--danger)" xFormat={xFormat} /></div>}
63 73 </div>
64 74 <h2>Per day</h2>
65 75 <div className="dash-table-wrap">
66 76 <table className="dash-table" data-testid="usage-table">
67 77 <thead><tr><th>Day (UTC)</th><th className="num">Requests</th><th className="num">Rows</th><th className="num">429</th></tr></thead>
68 78 <tbody>
69 − {days.length === 0 && <tr><td colSpan={4} className="muted">No usage in this range.</td></tr>}
70 − {days.map(d => <tr key={d.day}><td className="mono">{d.day}</td><td className="num mono">{n(d.requests)}</td><td className="num mono">{n(d.rows)}</td><td className="num mono">{n(d.status_429)}</td></tr>)}
79 + {days.filter(d => d.requests || d.rows || d.status_429).length === 0 && <tr><td colSpan={4} className="muted">No usage in this range.</td></tr>}
80 + {days.filter(d => d.requests || d.rows || d.status_429).map(d => <tr key={d.day}><td className="mono">{d.day}</td><td className="num mono">{n(d.requests)}</td><td className="num mono">{n(d.rows)}</td><td className="num mono">{n(d.status_429)}</td></tr>)}
71 81 </tbody>
72 82 </table>
73 83 </div>
modified hfmarketdata/web/src/pages/dashboard/dashboard.css +45 −0
@@ -64,3 +64,48 @@
64 64 .dash-pg-p { margin: 0; font-size: 13px; }
65 65 .dash-pg-form { display: flex; gap: 8px; flex-wrap: wrap; }
66 66 .dash-pg-form input { flex: 1; min-width: 220px; }
67 +
68 +/* ---- added with the accounts hardening (2026-09): skeletons, toasts, quotas, forms, drawer ---- */
69 +.dash-skel { display: flex; flex-direction: column; gap: 8px; width: 100%; }
70 +.dash-skel-line { display: block; border-radius: 4px; background: linear-gradient(90deg, var(--bg-2) 25%, var(--bg-3) 50%, var(--bg-2) 75%); background-size: 200% 100%; animation: dash-shimmer 1.2s linear infinite; }
71 +@keyframes dash-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
72 +.dash-toasts { position: fixed; right: 16px; bottom: 16px; z-index: 90; display: flex; flex-direction: column; gap: 8px; max-width: min(420px, calc(100vw - 32px)); }
73 +.dash-toast { display: flex; gap: 12px; align-items: flex-start; padding: 10px 12px; border-radius: var(--radius); border: 1px solid var(--line-2); background: var(--bg-1); box-shadow: var(--shadow); font-size: 13.5px; }
74 +.dash-toast-danger { border-color: color-mix(in srgb, var(--danger) 55%, transparent); background: color-mix(in srgb, var(--danger) 10%, var(--bg-1)); }
75 +.dash-toast-success { border-color: color-mix(in srgb, var(--accent) 55%, transparent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-1)); }
76 +.dash-toast-x { margin-left: auto; background: none; border: 0; color: var(--fg-2); cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px; }
77 +.dash-quota { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; }
78 +.dash-quota-head { display: flex; justify-content: space-between; gap: 8px; }
79 +.dash-quota-track { height: 8px; border-radius: 4px; background: var(--bg-3); overflow: hidden; }
80 +.dash-quota-fill { height: 100%; border-radius: 4px; background: var(--accent); transition: width .3s; }
81 +.dash-quota-warn { background: var(--warn); }
82 +.dash-quota-danger { background: var(--danger); }
83 +.dash-quota-card { display: flex; flex-direction: column; gap: 10px; }
84 +.dash-quota-card h3 { margin: 0; font-size: 15px; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
85 +.dash-form { display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: 440px; }
86 +.dash-form-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
87 +.dash-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; flex: 1; }
88 +.dash-field label { font-size: 13px; color: var(--fg-1); }
89 +.dash-field input, .dash-field select, .dash-field textarea { width: 100%; }
90 +.dash-field input[aria-invalid="true"] { border-color: var(--danger); }
91 +.dash-field-err { color: var(--danger); font-size: 12px; }
92 +.dash-pw { position: relative; }
93 +.dash-pw input { padding-right: 60px; width: 100%; }
94 +.dash-pw-toggle { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: none; border: 0; color: var(--fg-2); cursor: pointer; font: inherit; font-size: 12px; padding: 4px; }
95 +.dash-check { display: flex; gap: 8px; align-items: flex-start; font-size: 13.5px; }
96 +.dash-check input { margin-top: 3px; }
97 +.dash-danger-zone { border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
98 +.dash-inline-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
99 +.dash-key-select { display: inline-flex; gap: 8px; align-items: center; font-size: 13px; }
100 +.dash-mono-sm { font-size: 12px; }
101 +.dash-drawer { border: 1px solid var(--line-2); border-radius: var(--radius-lg); background: var(--bg-1); padding: 18px; display: flex; flex-direction: column; gap: 14px; }
102 +.dash-drawer-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
103 +.dash-drawer-head h2 { flex: 1; margin: 0; overflow-wrap: anywhere; }
104 +.dash-kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; font-size: 13px; margin: 0; }
105 +.dash-kv dt { color: var(--fg-2); }
106 +.dash-kv dd { margin: 0; overflow-wrap: anywhere; }
107 +.dash-banner { border: 1px solid color-mix(in srgb, var(--warn) 55%, transparent); background: color-mix(in srgb, var(--warn) 10%, var(--bg-1)); border-radius: var(--radius); padding: 10px 14px; font-size: 13.5px; }
108 +.dash-table td.dash-wrap { overflow-wrap: anywhere; }
109 +.dash-filters { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
110 +.dash-filters input, .dash-filters select { padding: 6px 10px; font-size: 13px; }
111 +.dash-subtle { font-size: 12px; color: var(--fg-2); }
added hfmarketdata/web/src/pages/dashboard/ui.jsx +125 −0
@@ -0,0 +1,125 @@
1 +// Small UI helpers shared by the dashboard and the admin area: skeletons, toasts, confirmations, form fields,
2 +// number/date formatting. Styles live in dashboard.css (dash-* classes, theme.css variables only).
3 +import React, { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
4 +import PgCallout from '../../components/PgCallout.jsx'
5 +import { friendlyError } from '../../app/api.js'
6 +
7 +export const n = v => (v == null || Number.isNaN(Number(v)) ? '—' : Number(v).toLocaleString('en-US'))
8 +export const pct = (used, limit) => (limit ? Math.min(100, Math.round((used / limit) * 100)) : 0)
9 +export const fmtDate = ts => (ts ? String(ts).slice(0, 10) : '—')
10 +export const fmtDateTime = ts => (ts ? String(ts).replace('T', ' ').slice(0, 16) + ' UTC' : '—')
11 +export const ago = ts => {
12 + if (!ts) return 'never'
13 + const s = Math.max(0, Math.floor((Date.now() - Date.parse(ts)) / 1000))
14 + if (s < 60) return 'just now'
15 + if (s < 3600) return `${Math.floor(s / 60)} min ago`
16 + if (s < 86400) return `${Math.floor(s / 3600)} h ago`
17 + return `${Math.floor(s / 86400)} d ago`
18 +}
19 +export const todayUtc = () => new Date().toISOString().slice(0, 10)
20 +
21 +/** Grey placeholder block while data loads. */
22 +export function Skeleton({ lines = 3, height = 14, className = '' }) {
23 + return (
24 + <div className={`dash-skel ${className}`} aria-busy="true" aria-live="polite" data-testid="skeleton">
25 + {Array.from({ length: lines }, (_, i) => <span key={i} className="dash-skel-line" style={{ height, width: `${100 - (i % 3) * 18}%` }} />)}
26 + </div>
27 + )
28 +}
29 +
30 +export function StatSkeleton({ count = 4 }) {
31 + return (
32 + <div className="dash-stats" aria-busy="true">
33 + {Array.from({ length: count }, (_, i) => <div key={i} className="dash-stat"><Skeleton lines={2} height={18} /></div>)}
34 + </div>
35 + )
36 +}
37 +
38 +/** Error / network banner with retry. */
39 +export function ErrorNotice({ error, onRetry, children }) {
40 + if (!error) return null
41 + return (
42 + <PgCallout kind={error.status === 404 && !error.code ? 'warn' : 'danger'} action={onRetry ? <button type="button" className="btn btn-sm" onClick={onRetry}>Retry</button> : null}>
43 + {children || friendlyError(error)}
44 + </PgCallout>
45 + )
46 +}
47 +
48 +// ---------------------------------------------------------------------------------------------- toasts
49 +const ToastCtx = createContext({ push: () => {} })
50 +
51 +export function ToastProvider({ children }) {
52 + const [items, setItems] = useState([])
53 + const push = useCallback((message, kind = 'info', ttl = 5000) => {
54 + const id = Math.random().toString(36).slice(2)
55 + setItems(t => [...t, { id, message, kind }])
56 + if (ttl) setTimeout(() => setItems(t => t.filter(x => x.id !== id)), ttl)
57 + }, [])
58 + const value = useMemo(() => ({ push, error: e => push(friendlyError(e), 'danger', 8000), success: m => push(m, 'success') }), [push])
59 + return (
60 + <ToastCtx.Provider value={value}>
61 + {children}
62 + <div className="dash-toasts" aria-live="polite" aria-atomic="false">
63 + {items.map(t => (
64 + <div key={t.id} role={t.kind === 'danger' ? 'alert' : 'status'} className={`dash-toast dash-toast-${t.kind}`} data-testid="toast">
65 + <span>{t.message}</span>
66 + <button type="button" className="dash-toast-x" aria-label="Dismiss" onClick={() => setItems(x => x.filter(y => y.id !== t.id))}>×</button>
67 + </div>
68 + ))}
69 + </div>
70 + </ToastCtx.Provider>
71 + )
72 +}
73 +export const useToast = () => useContext(ToastCtx)
74 +
75 +// ---------------------------------------------------------------------------------------- confirmation
76 +/** Inline confirmation (no window.confirm): renders the question + Yes / Cancel, focus goes to Cancel. */
77 +export function Confirm({ question, yesLabel = 'Yes', danger = true, busy, onYes, onCancel, testId }) {
78 + const ref = useRef(null)
79 + useEffect(() => { ref.current?.focus() }, [])
80 + return (
81 + <span className="dash-confirm" role="alertdialog" aria-label={question}>
82 + <span>{question}</span>
83 + <button type="button" className={`btn btn-sm ${danger ? 'dash-danger' : 'btn-primary'}`} onClick={onYes} disabled={busy} data-testid={testId}>{yesLabel}</button>
84 + <button type="button" className="btn btn-sm" onClick={onCancel} ref={ref} disabled={busy}>Cancel</button>
85 + </span>
86 + )
87 +}
88 +
89 +// ------------------------------------------------------------------------------------------- form fields
90 +export function Field({ label, hint, error, children, id: forcedId }) {
91 + const auto = useId()
92 + const id = forcedId || auto
93 + return (
94 + <div className="dash-field">
95 + <label htmlFor={id}>{label}</label>
96 + {typeof children === 'function' ? children(id, { 'aria-invalid': !!error, 'aria-describedby': error ? `${id}-err` : hint ? `${id}-hint` : undefined }) : children}
97 + {hint && !error && <small id={`${id}-hint`} className="muted">{hint}</small>}
98 + {error && <small id={`${id}-err`} className="dash-field-err" role="alert">{error}</small>}
99 + </div>
100 + )
101 +}
102 +
103 +export function PasswordInput({ id, value, onChange, autoComplete = 'current-password', minLength, placeholder, ...rest }) {
104 + const [show, setShow] = useState(false)
105 + return (
106 + <div className="dash-pw">
107 + <input id={id} type={show ? 'text' : 'password'} value={value} onChange={e => onChange(e.target.value)} autoComplete={autoComplete} minLength={minLength} placeholder={placeholder} required {...rest} />
108 + <button type="button" className="dash-pw-toggle" onClick={() => setShow(s => !s)} aria-pressed={show} aria-label={show ? 'Hide password' : 'Show password'}>{show ? 'Hide' : 'Show'}</button>
109 + </div>
110 + )
111 +}
112 +
113 +/** Progress bar for a quota (used / limit). */
114 +export function QuotaBar({ label, used, limit, unit = '' }) {
115 + const p = pct(used, limit)
116 + const tone = p >= 100 ? 'danger' : p >= 80 ? 'warn' : 'ok'
117 + return (
118 + <div className="dash-quota" data-testid="quota-bar">
119 + <div className="dash-quota-head"><span>{label}</span><span className="mono muted">{n(used)} / {n(limit)}{unit} · {p}%</span></div>
120 + <div className="dash-quota-track" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={p} aria-label={label}>
121 + <div className={`dash-quota-fill dash-quota-${tone}`} style={{ width: `${p}%` }} />
122 + </div>
123 + </div>
124 + )
125 +}
126