web: pages compte (connexion, inscription, vérification, réinitialisation, invitation), tableau de bord (aperçu, clés API à révélation unique, usage 24h/7j/30j + CSV, playground avec clé de session, compte) et administration (utilisateurs, usage global, journal d'audit)
16 changed files +1,126 −7
added
hfmarketdata/web/src/components/DashChart.jsx
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +// Lightweight SVG bar chart for usage series (no dependency). Accessible: title + data table fallback via aria. | |
| 2 | +import React, { useId, useState } from 'react' | |
| 3 | + | |
| 4 | +const fmt = v => (v >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(1)}k` : String(v)) | |
| 5 | + | |
| 6 | +export default function DashChart({ series, valueKey, label, color = 'var(--accent)', height = 160, xFormat }) { | |
| 7 | + const id = useId() | |
| 8 | + const [hover, setHover] = useState(null) | |
| 9 | + const W = 640, H = height, padL = 44, padB = 22, padT = 8 | |
| 10 | + const vals = series.map(s => Number(s[valueKey]) || 0) | |
| 11 | + const max = Math.max(1, ...vals) | |
| 12 | + const n = series.length | |
| 13 | + const bw = n ? (W - padL) / n : 0 | |
| 14 | + const ticks = [0, 0.5, 1].map(f => Math.round(max * f)) | |
| 15 | + const fx = xFormat || (ts => String(ts).slice(5, 16).replace('T', ' ')) | |
| 16 | + if (!n) return <p className="muted dash-chart-empty">No data for this range.</p> | |
| 17 | + return ( | |
| 18 | + <figure className="dash-chart" aria-labelledby={`${id}-t`}> | |
| 19 | + <figcaption id={`${id}-t`} className="dash-chart-title">{label}{hover != null && <span className="mono muted"> · {fx(series[hover].ts)} → {Number(vals[hover]).toLocaleString()}</span>}</figcaption> | |
| 20 | + <svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${label}: ${n} points, max ${max.toLocaleString()}`} onMouseLeave={() => setHover(null)}> | |
| 21 | + {ticks.map(t => { | |
| 22 | + const y = padT + (H - padT - padB) * (1 - t / max) | |
| 23 | + return <g key={t}><line x1={padL} x2={W} y1={y} y2={y} stroke="var(--line)" strokeWidth="1" /><text x={padL - 6} y={y + 4} textAnchor="end" fontSize="10" fill="var(--fg-2)">{fmt(t)}</text></g> | |
| 24 | + })} | |
| 25 | + {series.map((s, i) => { | |
| 26 | + const v = vals[i] | |
| 27 | + const h = (H - padT - padB) * (v / max) | |
| 28 | + const x = padL + i * bw | |
| 29 | + return <rect key={i} x={x + bw * 0.15} y={H - padB - h} width={Math.max(1, bw * 0.7)} height={h} fill={color} opacity={hover === i ? 1 : 0.75} onMouseEnter={() => setHover(i)} onFocus={() => setHover(i)} tabIndex={-1} /> | |
| 30 | + })} | |
| 31 | + {[0, Math.floor(n / 2), n - 1].filter((v, i, a) => a.indexOf(v) === i).map(i => ( | |
| 32 | + <text key={i} x={padL + i * bw + bw / 2} y={H - 6} textAnchor={i === 0 ? 'start' : i === n - 1 ? 'end' : 'middle'} fontSize="10" fill="var(--fg-2)">{fx(series[i].ts)}</text> | |
| 33 | + ))} | |
| 34 | + </svg> | |
| 35 | + </figure> | |
| 36 | + ) | |
| 37 | +} | |
modified
hfmarketdata/web/src/pages/admin/Admin.jsx
+32 −2
@@ -1,5 +1,35 @@ | ||
| 1 | +// Admin area (role === 'admin'): users, global usage, audit log. | |
| 1 | 2 | import React from 'react' |
| 2 | −// Placeholder — implemented by the owning agent (see App.jsx header). Keep the default export name. | |
| 3 | +import { Navigate, NavLink, Route, Routes } from 'react-router-dom' | |
| 4 | +import { RequireAuth } from '../dashboard/Dashboard.jsx' | |
| 5 | +import '../dashboard/dashboard.css' | |
| 6 | +import AdminAudit from './AdminAudit.jsx' | |
| 7 | +import AdminUsage from './AdminUsage.jsx' | |
| 8 | +import AdminUsers from './AdminUsers.jsx' | |
| 9 | +import './admin.css' | |
| 10 | + | |
| 3 | 11 | export default function Admin() { |
| 4 | − return <main className="page"><h1>Admin</h1><p className="muted">Coming soon.</p></main> | |
| 12 | + return ( | |
| 13 | + <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> | |
| 33 | + </RequireAuth> | |
| 34 | + ) | |
| 5 | 35 | } |
added
hfmarketdata/web/src/pages/admin/AdminAudit.jsx
+49 −0
@@ -0,0 +1,49 @@ | ||
| 1 | +// Audit log: GET /v1/admin/audit → [{ts, actor, action, target, meta}]. | |
| 2 | +import React, { useEffect, useMemo, useState } from 'react' | |
| 3 | +import { api } from '../../app/api.js' | |
| 4 | +import PgCallout from '../../components/PgCallout.jsx' | |
| 5 | +import { errText } from './AdminUsers.jsx' | |
| 6 | + | |
| 7 | +const list = r => (Array.isArray(r) ? r : Array.isArray(r?.data) ? r.data : Array.isArray(r?.items) ? r.items : []) | |
| 8 | + | |
| 9 | +export default function AdminAudit() { | |
| 10 | + const [rows, setRows] = useState(null) | |
| 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]) | |
| 22 | + return ( | |
| 23 | + <div className="dash-section"> | |
| 24 | + <div className="dash-head-row"> | |
| 25 | + <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> | |
| 46 | + </div> | |
| 47 | + </div> | |
| 48 | + ) | |
| 49 | +} | |
added
hfmarketdata/web/src/pages/admin/AdminUsage.jsx
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +// Global usage: totals, top principals, series (shape rendered defensively — the accounts module is built in parallel). | |
| 2 | +import React, { useEffect, useMemo, useState } from 'react' | |
| 3 | +import { api } from '../../app/api.js' | |
| 4 | +import DashChart from '../../components/DashChart.jsx' | |
| 5 | +import PgCallout from '../../components/PgCallout.jsx' | |
| 6 | +import { errText } from './AdminUsers.jsx' | |
| 7 | + | |
| 8 | +const n = v => (v == null ? '—' : Number(v).toLocaleString()) | |
| 9 | +const RANGES = ['24h', '7d', '30d'] | |
| 10 | + | |
| 11 | +export default function AdminUsage() { | |
| 12 | + const [range, setRange] = useState('7d') | |
| 13 | + const [data, setData] = useState(null) | |
| 14 | + const [err, setErr] = useState(null) | |
| 15 | + useEffect(() => { | |
| 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)) | |
| 18 | + 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 || [] | |
| 22 | + const totals = data?.totals || {} | |
| 23 | + const xFormat = range === '24h' ? ts => String(ts).slice(11, 16) : ts => String(ts).slice(5, 10) | |
| 24 | + return ( | |
| 25 | + <div className="dash-section"> | |
| 26 | + <div className="dash-head-row"> | |
| 27 | + <h1>Global usage</h1> | |
| 28 | + <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 | + </div> | |
| 31 | + </div> | |
| 32 | + {err && <PgCallout kind={err.status === 404 && !err.code ? 'warn' : 'danger'}>{errText(err)}</PgCallout>} | |
| 33 | + <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> | |
| 38 | + </div> | |
| 39 | + {series.length > 0 && ( | |
| 40 | + <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> | |
| 43 | + </div> | |
| 44 | + )} | |
| 45 | + <h2>Top principals</h2> | |
| 46 | + <div className="dash-table-wrap"> | |
| 47 | + <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 | + <tbody> | |
| 50 | + {top.length === 0 && <tr><td colSpan={6} className="muted">{data ? 'No usage recorded.' : 'Loading…'}</td></tr>} | |
| 51 | + {top.map((t, i) => ( | |
| 52 | + <tr key={t.principal || i}> | |
| 53 | + <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> | |
| 56 | + <td className="num mono">{n(t.requests)}</td> | |
| 57 | + <td className="num mono">{n(t.rows)}</td> | |
| 58 | + <td className="num mono">{n(t.status_429)}</td> | |
| 59 | + </tr> | |
| 60 | + ))} | |
| 61 | + </tbody> | |
| 62 | + </table> | |
| 63 | + </div> | |
| 64 | + </div> | |
| 65 | + ) | |
| 66 | +} | |
added
hfmarketdata/web/src/pages/admin/AdminUsers.jsx
+130 −0
@@ -0,0 +1,130 @@ | ||
| 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' | |
| 4 | +import PgCallout from '../../components/PgCallout.jsx' | |
| 5 | +import PgCopyButton from '../../components/PgCopyButton.jsx' | |
| 6 | + | |
| 7 | +const TIERS = ['free', 'high_usage'] | |
| 8 | +const ROLES = ['user', 'admin'] | |
| 9 | +const STATUSES = ['invited', 'active', 'disabled'] | |
| 10 | +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.') | |
| 13 | + | |
| 14 | +export default function AdminUsers() { | |
| 15 | + const [users, setUsers] = useState(null) | |
| 16 | + const [err, setErr] = useState(null) | |
| 17 | + const [q, setQ] = useState('') | |
| 18 | + const [busyId, setBusyId] = useState(null) | |
| 19 | + const [invite, setInvite] = useState({ name: '', email: '' }) | |
| 20 | + const [inviteErr, setInviteErr] = useState('') | |
| 21 | + const [invited, setInvited] = useState(null) | |
| 22 | + const [inviteBusy, setInviteBusy] = useState(false) | |
| 23 | + | |
| 24 | + 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 | + }, []) | |
| 27 | + useEffect(() => { load() }, [load]) | |
| 28 | + | |
| 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 | + const patch = async (u, field, value) => { | |
| 35 | + if (u[field] === value) return | |
| 36 | + setBusyId(u.id); setErr(null) | |
| 37 | + 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) } | |
| 42 | + } | |
| 43 | + const resend = async u => { | |
| 44 | + setBusyId(u.id); setErr(null) | |
| 45 | + 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) } | |
| 50 | + } | |
| 51 | + const submitInvite = async e => { | |
| 52 | + e.preventDefault() | |
| 53 | + if (invite.name.trim().length < 2) { setInviteErr('Enter a name.'); return } | |
| 54 | + if (!EMAIL_RE.test(invite.email)) { setInviteErr('Enter a valid e-mail.'); return } | |
| 55 | + setInviteErr(''); setInviteBusy(true); setErr(null) | |
| 56 | + 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: '' }) | |
| 61 | + await load() | |
| 62 | + } catch (e2) { setErr(e2) } finally { setInviteBusy(false) } | |
| 63 | + } | |
| 64 | + | |
| 65 | + return ( | |
| 66 | + <div className="dash-section"> | |
| 67 | + <div className="dash-head-row"> | |
| 68 | + <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" /> | |
| 70 | + </div> | |
| 71 | + {err && <PgCallout kind={err.status === 404 && !err.code ? 'warn' : 'danger'}>{errText(err)}</PgCallout>} | |
| 72 | + | |
| 73 | + {invited && ( | |
| 74 | + <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 && ( | |
| 83 | + <> | |
| 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> | |
| 86 | + </> | |
| 87 | + )} | |
| 88 | + <button type="button" className="btn" onClick={() => setInvited(null)}>Close</button> | |
| 89 | + </div> | |
| 90 | + )} | |
| 91 | + | |
| 92 | + <form className="card adm-invite" onSubmit={submitInvite} aria-label="Invite a new user"> | |
| 93 | + <h2>Invite a user</h2> | |
| 94 | + <div className="adm-invite-row"> | |
| 95 | + <label>Name<input value={invite.name} onChange={e => setInvite(v => ({ ...v, name: e.target.value }))} autoComplete="off" /></label> | |
| 96 | + <label>E-mail<input type="email" value={invite.email} onChange={e => setInvite(v => ({ ...v, email: e.target.value }))} autoComplete="off" /></label> | |
| 97 | + <button type="submit" className="btn btn-primary" disabled={inviteBusy} data-testid="invite-submit">{inviteBusy ? 'Inviting…' : 'Create account + invite'}</button> | |
| 98 | + </div> | |
| 99 | + {inviteErr && <p className="pg-err" role="alert">{inviteErr}</p>} | |
| 100 | + <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 | + </form> | |
| 102 | + | |
| 103 | + {users === null ? <p className="muted">Loading…</p> : ( | |
| 104 | + <div className="dash-table-wrap"> | |
| 105 | + <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> | |
| 107 | + <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 | + ))} | |
| 123 | + </tbody> | |
| 124 | + </table> | |
| 125 | + </div> | |
| 126 | + )} | |
| 127 | + <p className="muted adm-hint">{users ? `${users.length} user${users.length === 1 ? '' : 's'}` : ''}</p> | |
| 128 | + </div> | |
| 129 | + ) | |
| 130 | +} | |
added
hfmarketdata/web/src/pages/admin/admin.css
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +.adm-search { min-width: 260px; padding: 6px 10px; font-size: 13px; } | |
| 2 | +.adm-invite { display: flex; flex-direction: column; gap: 10px; } | |
| 3 | +.adm-invite h2 { margin: 0; } | |
| 4 | +.adm-invite-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; } | |
| 5 | +.adm-invite-row label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--fg-1); } | |
| 6 | +.adm-invite-row input { min-width: 220px; } | |
| 7 | +.adm-hint { font-size: 12.5px; margin: 0; } | |
| 8 | +.adm-users select { padding: 4px 6px; font-size: 12.5px; } | |
| 9 | +.adm-busy { opacity: .6; } | |
| 10 | +.adm-status-disabled { color: var(--danger); } | |
| 11 | +.adm-status-invited { color: var(--warn); } | |
| 12 | +.adm-meta { font-size: 11.5px; max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
modified
hfmarketdata/web/src/pages/auth/Auth.jsx
+263 −3
@@ -1,5 +1,265 @@ | ||
| 1 | −import React from 'react' | |
| 2 | −// Placeholder — implemented by web-app. Props: mode = signin | signup | verify | reset | invite | |
| 1 | +// Auth pages — mode: signin | signup | verify | reset | invite. Cookie-session endpoints under /v1/auth. | |
| 2 | +import React, { useEffect, useState } from 'react' | |
| 3 | +import { Link, useNavigate, useSearchParams } from 'react-router-dom' | |
| 4 | +import { api, CONTACT_EMAIL } from '../../app/api.js' | |
| 5 | +import { useAuth } from '../../app/auth.jsx' | |
| 6 | +import PgCallout from '../../components/PgCallout.jsx' | |
| 7 | +import './auth.css' | |
| 8 | + | |
| 9 | +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ | |
| 10 | +const MIN_PW = 10 | |
| 11 | + | |
| 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 | +function useSafeNext() { | |
| 28 | + const [sp] = useSearchParams() | |
| 29 | + const next = sp.get('next') || '/dashboard' | |
| 30 | + return next.startsWith('/') && !next.startsWith('//') ? next : '/dashboard' | |
| 31 | +} | |
| 32 | + | |
| 33 | +function Field({ id, label, type = 'text', value, onChange, error, autoComplete, hint, autoFocus, minLength }) { | |
| 34 | + return ( | |
| 35 | + <div className="auth-field"> | |
| 36 | + <label htmlFor={id}>{label}</label> | |
| 37 | + <input id={id} type={type} value={value} onChange={e => onChange(e.target.value)} autoComplete={autoComplete} autoFocus={autoFocus} minLength={minLength} | |
| 38 | + aria-invalid={!!error} aria-describedby={error ? `${id}-err` : hint ? `${id}-hint` : undefined} className={error ? 'invalid' : ''} required /> | |
| 39 | + {hint && !error && <small id={`${id}-hint`} className="muted">{hint}</small>} | |
| 40 | + {error && <small id={`${id}-err`} className="auth-err" role="alert">{error}</small>} | |
| 41 | + </div> | |
| 42 | + ) | |
| 43 | +} | |
| 44 | + | |
| 45 | +function PasswordField(props) { | |
| 46 | + const [show, setShow] = useState(false) | |
| 47 | + return ( | |
| 48 | + <div className="auth-pw"> | |
| 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> | |
| 51 | + </div> | |
| 52 | + ) | |
| 53 | +} | |
| 54 | + | |
| 3 | 55 | export default function Auth({ mode }) { |
| 4 | − return <main className="page narrow"><h1>{mode}</h1><p className="muted">Coming soon.</p></main> | |
| 56 | + const [sp] = useSearchParams() | |
| 57 | + const token = sp.get('token') || '' | |
| 58 | + 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 | + useEffect(() => { document.title = `${titles[mode]} — HF Market Data` }, [mode, token]) // eslint-disable-line react-hooks/exhaustive-deps | |
| 60 | + return ( | |
| 61 | + <main className="page narrow auth-page"> | |
| 62 | + <div className="auth-card card"> | |
| 63 | + <h1 className="auth-title">{titles[mode]}</h1> | |
| 64 | + {mode === 'signin' && <SignIn />} | |
| 65 | + {mode === 'signup' && <SignUp />} | |
| 66 | + {mode === 'verify' && <Verify token={token} />} | |
| 67 | + {mode === 'reset' && (token ? <ResetSet token={token} /> : <ResetRequest />)} | |
| 68 | + {mode === 'invite' && <Invite token={token} />} | |
| 69 | + </div> | |
| 70 | + </main> | |
| 71 | + ) | |
| 72 | +} | |
| 73 | + | |
| 74 | +function SignIn() { | |
| 75 | + const { refresh } = useAuth() | |
| 76 | + const navigate = useNavigate() | |
| 77 | + const next = useSafeNext() | |
| 78 | + const [email, setEmail] = useState('') | |
| 79 | + const [password, setPassword] = useState('') | |
| 80 | + const [errs, setErrs] = useState({}) | |
| 81 | + const [busy, setBusy] = useState(false) | |
| 82 | + const [error, setError] = useState(null) | |
| 83 | + const submit = async e => { | |
| 84 | + e.preventDefault() | |
| 85 | + const v = {} | |
| 86 | + if (!EMAIL_RE.test(email)) v.email = 'Enter a valid e-mail address.' | |
| 87 | + if (!password) v.password = 'Enter your password.' | |
| 88 | + setErrs(v) | |
| 89 | + if (Object.keys(v).length) return | |
| 90 | + setBusy(true); setError(null) | |
| 91 | + try { | |
| 92 | + await api('/v1/auth/login', { method: 'POST', body: { email: email.trim(), password } }) | |
| 93 | + await refresh() | |
| 94 | + navigate(next, { replace: true }) | |
| 95 | + } catch (err) { setError(err) } finally { setBusy(false) } | |
| 96 | + } | |
| 97 | + return ( | |
| 98 | + <form onSubmit={submit} noValidate className="auth-form" data-testid="signin-form"> | |
| 99 | + {error && <PgCallout kind="danger">{friendlyError(error)}</PgCallout>} | |
| 100 | + <Field id="email" label="E-mail" type="email" value={email} onChange={setEmail} error={errs.email} autoComplete="email" autoFocus /> | |
| 101 | + <PasswordField id="password" label="Password" value={password} onChange={setPassword} error={errs.password} autoComplete="current-password" /> | |
| 102 | + <div className="auth-row"> | |
| 103 | + <Link to="/reset">Forgot your password?</Link> | |
| 104 | + </div> | |
| 105 | + <button className="btn btn-primary auth-submit" type="submit" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button> | |
| 106 | + <p className="muted auth-foot">No account yet? <Link to={`/signup${next !== '/dashboard' ? `?next=${encodeURIComponent(next)}` : ''}`}>Create a free account</Link> — 120 requests / minute, 50 000 rows per request.</p> | |
| 107 | + </form> | |
| 108 | + ) | |
| 109 | +} | |
| 110 | + | |
| 111 | +function SignUp() { | |
| 112 | + const [name, setName] = useState('') | |
| 113 | + const [email, setEmail] = useState('') | |
| 114 | + const [password, setPassword] = useState('') | |
| 115 | + const [errs, setErrs] = useState({}) | |
| 116 | + const [busy, setBusy] = useState(false) | |
| 117 | + const [error, setError] = useState(null) | |
| 118 | + const [done, setDone] = useState(false) | |
| 119 | + const submit = async e => { | |
| 120 | + e.preventDefault() | |
| 121 | + const v = {} | |
| 122 | + if (name.trim().length < 2) v.name = 'Enter your name.' | |
| 123 | + if (!EMAIL_RE.test(email)) v.email = 'Enter a valid e-mail address.' | |
| 124 | + if (password.length < MIN_PW) v.password = `Use at least ${MIN_PW} characters.` | |
| 125 | + setErrs(v) | |
| 126 | + if (Object.keys(v).length) return | |
| 127 | + setBusy(true); setError(null) | |
| 128 | + try { | |
| 129 | + await api('/v1/auth/signup', { method: 'POST', body: { email: email.trim(), name: name.trim(), password } }) | |
| 130 | + setDone(true) | |
| 131 | + } catch (err) { setError(err) } finally { setBusy(false) } | |
| 132 | + } | |
| 133 | + if (done) { | |
| 134 | + return ( | |
| 135 | + <div className="auth-success" data-testid="signup-success"> | |
| 136 | + <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. | |
| 138 | + </PgCallout> | |
| 139 | + <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 | + <Link to="/signin" className="btn">Go to sign in</Link> | |
| 141 | + </div> | |
| 142 | + ) | |
| 143 | + } | |
| 144 | + return ( | |
| 145 | + <form onSubmit={submit} noValidate className="auth-form" data-testid="signup-form"> | |
| 146 | + {error && <PgCallout kind="danger">{friendlyError(error)}</PgCallout>} | |
| 147 | + <Field id="name" label="Name" value={name} onChange={setName} error={errs.name} autoComplete="name" autoFocus /> | |
| 148 | + <Field id="email" label="E-mail" type="email" value={email} onChange={setEmail} error={errs.email} autoComplete="email" /> | |
| 149 | + <PasswordField id="password" label="Password" value={password} onChange={setPassword} error={errs.password} autoComplete="new-password" hint={`At least ${MIN_PW} characters.`} minLength={MIN_PW} /> | |
| 150 | + <button className="btn btn-primary auth-submit" type="submit" disabled={busy}>{busy ? 'Creating…' : 'Create free account'}</button> | |
| 151 | + <p className="muted auth-foot">Free tier: 120 requests / minute · 1 000 000 rows / minute · 50 000 rows per request. Need more? <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>.</p> | |
| 152 | + <p className="muted auth-foot">Already registered? <Link to="/signin">Sign in</Link></p> | |
| 153 | + </form> | |
| 154 | + ) | |
| 155 | +} | |
| 156 | + | |
| 157 | +function Verify({ token }) { | |
| 158 | + const [state, setState] = useState(token ? 'busy' : 'missing') | |
| 159 | + const [error, setError] = useState(null) | |
| 160 | + useEffect(() => { | |
| 161 | + if (!token) return | |
| 162 | + let alive = true | |
| 163 | + api(`/v1/auth/verify?token=${encodeURIComponent(token)}`).then(() => alive && setState('ok')).catch(e => { if (alive) { setError(e); setState('error') } }) | |
| 164 | + return () => { alive = false } | |
| 165 | + }, [token]) | |
| 166 | + if (state === 'busy') return <p className="muted" aria-busy="true">Verifying your e-mail…</p> | |
| 167 | + if (state === 'ok') { | |
| 168 | + return ( | |
| 169 | + <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> | |
| 172 | + </div> | |
| 173 | + ) | |
| 174 | + } | |
| 175 | + return ( | |
| 176 | + <div className="auth-success"> | |
| 177 | + <PgCallout kind="danger" title={state === 'missing' ? 'Missing token' : 'Verification failed'}> | |
| 178 | + {state === 'missing' ? 'Open the link from your verification e-mail.' : friendlyError(error)} | |
| 179 | + </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> | |
| 181 | + <Link to="/signup" className="btn">Back to sign up</Link> | |
| 182 | + </div> | |
| 183 | + ) | |
| 184 | +} | |
| 185 | + | |
| 186 | +function ResetRequest() { | |
| 187 | + const [email, setEmail] = useState('') | |
| 188 | + const [err, setErr] = useState('') | |
| 189 | + const [busy, setBusy] = useState(false) | |
| 190 | + const [error, setError] = useState(null) | |
| 191 | + const [done, setDone] = useState(false) | |
| 192 | + const submit = async e => { | |
| 193 | + e.preventDefault() | |
| 194 | + if (!EMAIL_RE.test(email)) { setErr('Enter a valid e-mail address.'); return } | |
| 195 | + setErr(''); setBusy(true); setError(null) | |
| 196 | + try { await api('/v1/auth/forgot', { method: 'POST', body: { email: email.trim() } }); setDone(true) } catch (e2) { setError(e2) } finally { setBusy(false) } | |
| 197 | + } | |
| 198 | + if (done) { | |
| 199 | + return ( | |
| 200 | + <div className="auth-success" data-testid="reset-requested"> | |
| 201 | + <PgCallout kind="success" title="Check your inbox">If an account exists for <strong>{email}</strong>, we sent a link to choose a new password. The link expires after one hour.</PgCallout> | |
| 202 | + <Link to="/signin" className="btn">Back to sign in</Link> | |
| 203 | + </div> | |
| 204 | + ) | |
| 205 | + } | |
| 206 | + return ( | |
| 207 | + <form onSubmit={submit} noValidate className="auth-form" data-testid="reset-request-form"> | |
| 208 | + {error && <PgCallout kind="danger">{friendlyError(error)}</PgCallout>} | |
| 209 | + <p className="muted">Enter your e-mail and we will send you a link to choose a new password.</p> | |
| 210 | + <Field id="email" label="E-mail" type="email" value={email} onChange={setEmail} error={err} autoComplete="email" autoFocus /> | |
| 211 | + <button className="btn btn-primary auth-submit" type="submit" disabled={busy}>{busy ? 'Sending…' : 'Send reset link'}</button> | |
| 212 | + <p className="muted auth-foot"><Link to="/signin">Back to sign in</Link></p> | |
| 213 | + </form> | |
| 214 | + ) | |
| 215 | +} | |
| 216 | + | |
| 217 | +function NewPasswordForm({ endpoint, token, successTitle, successText, testId }) { | |
| 218 | + const { refresh } = useAuth() | |
| 219 | + const navigate = useNavigate() | |
| 220 | + const [pw, setPw] = useState('') | |
| 221 | + const [pw2, setPw2] = useState('') | |
| 222 | + const [errs, setErrs] = useState({}) | |
| 223 | + const [busy, setBusy] = useState(false) | |
| 224 | + const [error, setError] = useState(null) | |
| 225 | + const [done, setDone] = useState(false) | |
| 226 | + const submit = async e => { | |
| 227 | + e.preventDefault() | |
| 228 | + const v = {} | |
| 229 | + if (pw.length < MIN_PW) v.pw = `Use at least ${MIN_PW} characters.` | |
| 230 | + if (pw2 !== pw) v.pw2 = 'Passwords do not match.' | |
| 231 | + setErrs(v) | |
| 232 | + if (Object.keys(v).length) return | |
| 233 | + setBusy(true); setError(null) | |
| 234 | + 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 }) } | |
| 238 | + } catch (e2) { setError(e2) } finally { setBusy(false) } | |
| 239 | + } | |
| 240 | + if (!token) return <PgCallout kind="danger" title="Missing token">Open the link from your e-mail.</PgCallout> | |
| 241 | + if (done) { | |
| 242 | + return ( | |
| 243 | + <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> | |
| 246 | + </div> | |
| 247 | + ) | |
| 248 | + } | |
| 249 | + return ( | |
| 250 | + <form onSubmit={submit} noValidate className="auth-form"> | |
| 251 | + {error && <PgCallout kind="danger">{friendlyError(error)}</PgCallout>} | |
| 252 | + <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 | + <PasswordField id="pw2" label="Confirm password" value={pw2} onChange={setPw2} error={errs.pw2} autoComplete="new-password" /> | |
| 254 | + <button className="btn btn-primary auth-submit" type="submit" disabled={busy}>{busy ? 'Saving…' : 'Set password'}</button> | |
| 255 | + </form> | |
| 256 | + ) | |
| 5 | 257 | } |
| 258 | + | |
| 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" /> | |
| 260 | +const Invite = ({ token }) => ( | |
| 261 | + <> | |
| 262 | + <p className="muted">You were invited to HF Market Data. Choose a password to activate your account.</p> | |
| 263 | + <NewPasswordForm endpoint="/v1/auth/accept-invite" token={token} successTitle="Welcome" successText="Your account is active." testId="invite-done" /> | |
| 264 | + </> | |
| 265 | +) | |
added
hfmarketdata/web/src/pages/auth/auth.css
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +.auth-page { display: flex; align-items: flex-start; justify-content: center; } | |
| 2 | +.auth-card { width: 100%; max-width: 440px; padding: 28px 28px 24px; margin-top: 24px; } | |
| 3 | +.auth-title { margin: 0 0 18px; font-size: 24px; } | |
| 4 | +.auth-form { display: flex; flex-direction: column; gap: 14px; } | |
| 5 | +.auth-field { display: flex; flex-direction: column; gap: 5px; } | |
| 6 | +.auth-field label { font-size: 13px; color: var(--fg-1); } | |
| 7 | +.auth-field input { width: 100%; } | |
| 8 | +.auth-field input.invalid { border-color: var(--danger); } | |
| 9 | +.auth-err { color: var(--danger); font-size: 12px; } | |
| 10 | +.auth-pw { position: relative; } | |
| 11 | +.auth-pw input { padding-right: 60px; } | |
| 12 | +.auth-pw-toggle { position: absolute; right: 8px; top: 27px; background: none; border: 0; color: var(--fg-2); cursor: pointer; font: inherit; font-size: 12px; padding: 4px; } | |
| 13 | +.auth-row { display: flex; justify-content: flex-end; font-size: 13px; margin-top: -6px; } | |
| 14 | +.auth-submit { justify-content: center; margin-top: 4px; } | |
| 15 | +.auth-foot { font-size: 13px; margin: 0; } | |
| 16 | +.auth-success { display: flex; flex-direction: column; gap: 14px; align-items: flex-start; } | |
| 17 | +.auth-success .pg-callout { width: 100%; } | |
added
hfmarketdata/web/src/pages/dashboard/Account.jsx
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +import React, { useState } from 'react' | |
| 2 | +import { api, CONTACT_EMAIL, TIERS } from '../../app/api.js' | |
| 3 | +import { useAuth } from '../../app/auth.jsx' | |
| 4 | +import PgCallout from '../../components/PgCallout.jsx' | |
| 5 | + | |
| 6 | +export default function Account() { | |
| 7 | + const { user } = useAuth() | |
| 8 | + const [sent, setSent] = useState(false) | |
| 9 | + const [err, setErr] = useState(null) | |
| 10 | + 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 | + } | |
| 16 | + return ( | |
| 17 | + <div className="dash-section"> | |
| 18 | + <h1>Account</h1> | |
| 19 | + <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> | |
| 37 | + <div className="card dash-card"> | |
| 38 | + <h2>Tier</h2> | |
| 39 | + <p><strong>{tier.name}</strong></p> | |
| 40 | + <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> | |
| 44 | + </ul> | |
| 45 | + {tier.id !== 'high_usage' && ( | |
| 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 | + )} | |
| 48 | + </div> | |
| 49 | + </div> | |
| 50 | + </div> | |
| 51 | + ) | |
| 52 | +} | |
modified
hfmarketdata/web/src/pages/dashboard/Dashboard.jsx
+67 −2
@@ -1,5 +1,70 @@ | ||
| 1 | +// Dashboard shell: route guard (→ /signin?next=), sidebar, nested sections. Session key lives in memory only. | |
| 1 | 2 | import React from 'react' |
| 2 | −// Placeholder — implemented by the owning agent (see App.jsx header). Keep the default export name. | |
| 3 | +import { Navigate, NavLink, Route, Routes, useLocation } from 'react-router-dom' | |
| 4 | +import { CONTACT_EMAIL } from '../../app/api.js' | |
| 5 | +import { useAuth } from '../../app/auth.jsx' | |
| 6 | +import Account from './Account.jsx' | |
| 7 | +import Keys from './Keys.jsx' | |
| 8 | +import Overview from './Overview.jsx' | |
| 9 | +import PlaygroundTab from './PlaygroundTab.jsx' | |
| 10 | +import { SessionKeyProvider } from './sessionKey.jsx' | |
| 11 | +import Usage from './Usage.jsx' | |
| 12 | +import './dashboard.css' | |
| 13 | + | |
| 14 | +const NAV = [ | |
| 15 | + ['', 'Overview'], ['keys', 'API keys'], ['usage', 'Usage'], ['playground', 'Playground'], ['account', 'Account'], | |
| 16 | +] | |
| 17 | + | |
| 18 | +export function RequireAuth({ children, role }) { | |
| 19 | + const { user, loading } = useAuth() | |
| 20 | + const location = useLocation() | |
| 21 | + if (loading) return <main className="page" aria-busy="true"><p className="muted">Loading your account…</p></main> | |
| 22 | + if (!user) return <Navigate to={`/signin?next=${encodeURIComponent(location.pathname + location.search)}`} replace /> | |
| 23 | + 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 | + return children | |
| 25 | +} | |
| 26 | + | |
| 3 | 27 | export default function Dashboard() { |
| 4 | − return <main className="page"><h1>Dashboard</h1><p className="muted">Coming soon.</p></main> | |
| 28 | + return ( | |
| 29 | + <RequireAuth> | |
| 30 | + <SessionKeyProvider> | |
| 31 | + <DashboardShell /> | |
| 32 | + </SessionKeyProvider> | |
| 33 | + </RequireAuth> | |
| 34 | + ) | |
| 35 | +} | |
| 36 | + | |
| 37 | +function DashboardShell() { | |
| 38 | + const { user, signout } = useAuth() | |
| 39 | + return ( | |
| 40 | + <main className="page dash" data-testid="dashboard"> | |
| 41 | + <aside className="dash-side"> | |
| 42 | + <div className="dash-user"> | |
| 43 | + <div className="dash-user-name">{user.name || user.email}</div> | |
| 44 | + <div className="muted dash-user-mail">{user.email}</div> | |
| 45 | + <div className="dash-tier">Tier: <strong>{user.tier || 'free'}</strong></div> | |
| 46 | + </div> | |
| 47 | + <nav className="dash-nav" aria-label="Dashboard"> | |
| 48 | + {NAV.map(([to, label]) => ( | |
| 49 | + <NavLink key={to} to={to} end={to === ''} className={({ isActive }) => (isActive ? 'active' : '')}>{label}</NavLink> | |
| 50 | + ))} | |
| 51 | + {user.role === 'admin' && <NavLink to="/admin" className="dash-nav-admin">Admin</NavLink>} | |
| 52 | + </nav> | |
| 53 | + <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> | |
| 56 | + </div> | |
| 57 | + </aside> | |
| 58 | + <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> | |
| 67 | + </section> | |
| 68 | + </main> | |
| 69 | + ) | |
| 5 | 70 | } |
added
hfmarketdata/web/src/pages/dashboard/Keys.jsx
+122 −0
@@ -0,0 +1,122 @@ | ||
| 1 | +// API keys: list, create (one-time reveal), rotate, revoke. Keys are never persisted client-side. | |
| 2 | +import React, { useCallback, useEffect, useState } from 'react' | |
| 3 | +import { useNavigate } from 'react-router-dom' | |
| 4 | +import { api } from '../../app/api.js' | |
| 5 | +import PgCallout from '../../components/PgCallout.jsx' | |
| 6 | +import PgCopyButton from '../../components/PgCopyButton.jsx' | |
| 7 | +import { ago } from './Overview.jsx' | |
| 8 | +import { useSessionKey } from './sessionKey.jsx' | |
| 9 | + | |
| 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 | + | |
| 13 | +export default function Keys() { | |
| 14 | + const [keys, setKeys] = useState(null) | |
| 15 | + const [err, setErr] = useState(null) | |
| 16 | + const [name, setName] = useState('') | |
| 17 | + const [busy, setBusy] = useState(false) | |
| 18 | + const [revealed, setRevealed] = useState(null) // { key, name, action } | |
| 19 | + const [confirm, setConfirm] = useState(null) // { id, action } | |
| 20 | + const session = useSessionKey() | |
| 21 | + const navigate = useNavigate() | |
| 22 | + | |
| 23 | + const load = useCallback(async () => { | |
| 24 | + try { const r = await api('/v1/me/keys'); setKeys(r.data?.data || r.data || []); setErr(null) } catch (e) { setErr(e); setKeys([]) } | |
| 25 | + }, []) | |
| 26 | + useEffect(() => { load() }, [load]) | |
| 27 | + | |
| 28 | + const create = async e => { | |
| 29 | + e.preventDefault() | |
| 30 | + setBusy(true); setErr(null) | |
| 31 | + try { | |
| 32 | + const r = await api('/v1/me/keys', { method: 'POST', body: { name: name.trim() || 'default' } }) | |
| 33 | + const d = r.data?.data || r.data | |
| 34 | + setRevealed({ key: d.key, name: d.name || name || 'default', action: 'created' }) | |
| 35 | + setName('') | |
| 36 | + await load() | |
| 37 | + } catch (e2) { setErr(e2) } finally { setBusy(false) } | |
| 38 | + } | |
| 39 | + const rotate = async id => { | |
| 40 | + setBusy(true); setErr(null) | |
| 41 | + try { | |
| 42 | + const r = await api(`/v1/me/keys/${id}/rotate`, { method: 'POST' }) | |
| 43 | + const d = r.data?.data || r.data | |
| 44 | + setRevealed({ key: d.key, name: d.name || '', action: 'rotated' }) | |
| 45 | + if (session.key && keys?.find(k => k.id === id && session.key.startsWith(k.prefix))) session.setKey('') | |
| 46 | + await load() | |
| 47 | + } catch (e2) { setErr(e2) } finally { setBusy(false); setConfirm(null) } | |
| 48 | + } | |
| 49 | + const revoke = async id => { | |
| 50 | + setBusy(true); setErr(null) | |
| 51 | + try { | |
| 52 | + await api(`/v1/me/keys/${id}`, { method: 'DELETE' }) | |
| 53 | + if (session.key && keys?.find(k => k.id === id && session.key.startsWith(k.prefix))) session.setKey('') | |
| 54 | + await load() | |
| 55 | + } catch (e2) { setErr(e2) } finally { setBusy(false); setConfirm(null) } | |
| 56 | + } | |
| 57 | + const useInPlayground = () => { session.setKey(revealed.key, revealed.name); setRevealed(null); navigate('/dashboard/playground') } | |
| 58 | + | |
| 59 | + return ( | |
| 60 | + <div className="dash-section"> | |
| 61 | + <h1>API keys</h1> | |
| 62 | + <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> | |
| 63 | + {err && <PgCallout kind={err.status === 404 && !err.code ? 'warn' : 'danger'}>{errText(err)}</PgCallout>} | |
| 64 | + | |
| 65 | + {revealed && ( | |
| 66 | + <div className="dash-reveal card" role="dialog" aria-labelledby="dash-reveal-title" data-testid="key-reveal"> | |
| 67 | + <h2 id="dash-reveal-title">Key {revealed.action}{revealed.name ? ` — ${revealed.name}` : ''}</h2> | |
| 68 | + <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> | |
| 69 | + <div className="dash-key-row"> | |
| 70 | + <code className="dash-key" data-testid="key-value">{revealed.key}</code> | |
| 71 | + <PgCopyButton text={revealed.key} label="Copy key" /> | |
| 72 | + </div> | |
| 73 | + <div className="dash-reveal-actions"> | |
| 74 | + <button type="button" className="btn btn-primary" onClick={useInPlayground}>Use in playground (this session)</button> | |
| 75 | + <button type="button" className="btn" onClick={() => setRevealed(null)}>I stored it, close</button> | |
| 76 | + </div> | |
| 77 | + </div> | |
| 78 | + )} | |
| 79 | + | |
| 80 | + <form className="dash-create" onSubmit={create}> | |
| 81 | + <label htmlFor="key-name">New key name</label> | |
| 82 | + <input id="key-name" value={name} onChange={e => setName(e.target.value)} placeholder="e.g. research laptop" maxLength={60} /> | |
| 83 | + <button type="submit" className="btn btn-primary" disabled={busy} data-testid="create-key">+ Create key</button> | |
| 84 | + </form> | |
| 85 | + | |
| 86 | + {keys === null ? <p className="muted">Loading…</p> : ( | |
| 87 | + <div className="dash-table-wrap"> | |
| 88 | + <table className="dash-table" data-testid="keys-table"> | |
| 89 | + <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> | |
| 90 | + <tbody> | |
| 91 | + {keys.length === 0 && <tr><td colSpan={6} className="muted">No key yet — create your first one above.</td></tr>} | |
| 92 | + {keys.map(k => ( | |
| 93 | + <tr key={k.id} className={k.status !== 'active' ? 'dash-row-muted' : ''}> | |
| 94 | + <td>{k.name}</td> | |
| 95 | + <td className="mono">{k.prefix}…</td> | |
| 96 | + <td>{fmtDate(k.created_at)}</td> | |
| 97 | + <td>{ago(k.last_used_at)}</td> | |
| 98 | + <td><span className={`dash-badge ${k.status}`}>{k.status}</span></td> | |
| 99 | + <td className="dash-actions"> | |
| 100 | + {k.status === 'active' && confirm?.id !== k.id && ( | |
| 101 | + <> | |
| 102 | + <button type="button" className="btn btn-sm" onClick={() => setConfirm({ id: k.id, action: 'rotate' })} disabled={busy}>Rotate</button> | |
| 103 | + <button type="button" className="btn btn-sm dash-danger" onClick={() => setConfirm({ id: k.id, action: 'revoke' })} disabled={busy}>Revoke</button> | |
| 104 | + </> | |
| 105 | + )} | |
| 106 | + {confirm?.id === k.id && ( | |
| 107 | + <span className="dash-confirm" role="alertdialog" aria-label={`Confirm ${confirm.action}`}> | |
| 108 | + {confirm.action === 'rotate' ? 'Revoke this key and issue a new one?' : 'Revoke permanently? Requests with this key will fail.'} | |
| 109 | + <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> | |
| 110 | + <button type="button" className="btn btn-sm" onClick={() => setConfirm(null)}>Cancel</button> | |
| 111 | + </span> | |
| 112 | + )} | |
| 113 | + </td> | |
| 114 | + </tr> | |
| 115 | + ))} | |
| 116 | + </tbody> | |
| 117 | + </table> | |
| 118 | + </div> | |
| 119 | + )} | |
| 120 | + </div> | |
| 121 | + ) | |
| 122 | +} | |
added
hfmarketdata/web/src/pages/dashboard/Overview.jsx
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +import React, { useEffect, useState } from 'react' | |
| 2 | +import { Link } from 'react-router-dom' | |
| 3 | +import { api, TIERS } from '../../app/api.js' | |
| 4 | +import { useAuth } from '../../app/auth.jsx' | |
| 5 | +import PgCallout from '../../components/PgCallout.jsx' | |
| 6 | + | |
| 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` | |
| 15 | +} | |
| 16 | + | |
| 17 | +export default function Overview() { | |
| 18 | + const { user } = useAuth() | |
| 19 | + const [usage, setUsage] = useState(null) | |
| 20 | + const [keys, setKeys] = useState(null) | |
| 21 | + const [err, setErr] = useState(null) | |
| 22 | + useEffect(() => { | |
| 23 | + 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(r.data?.data || r.data || [])).catch(() => alive && setKeys([])) | |
| 26 | + return () => { alive = false } | |
| 27 | + }, []) | |
| 28 | + 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() | |
| 37 | + return ( | |
| 38 | + <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> | |
| 46 | + </div> | |
| 47 | + <div className="dash-cards"> | |
| 48 | + <div className="card dash-card"> | |
| 49 | + <h2>API keys</h2> | |
| 50 | + {keys === null ? <p className="muted">Loading…</p> : keys.length === 0 | |
| 51 | + ? <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>} | |
| 53 | + <Link to="keys" className="btn">Manage keys</Link> | |
| 54 | + </div> | |
| 55 | + <div className="card dash-card"> | |
| 56 | + <h2>Quickstart</h2> | |
| 57 | + <pre className="dash-pre">{`curl "https://www.hfmarketdata.io/v1/bars/stock/AAPL?timeframe=1min&limit=5" \\\n -H "Authorization: Bearer $HFMD_API_KEY"`}</pre> | |
| 58 | + <Link to="playground" className="btn">Open the playground</Link> | |
| 59 | + </div> | |
| 60 | + </div> | |
| 61 | + </div> | |
| 62 | + ) | |
| 63 | +} | |
added
hfmarketdata/web/src/pages/dashboard/PlaygroundTab.jsx
+58 −0
@@ -0,0 +1,58 @@ | ||
| 1 | +// Dashboard playground: same component, with a session-only key injected (pasted or freshly created). | |
| 2 | +import React, { useEffect, useState } from 'react' | |
| 3 | +import { Link } from 'react-router-dom' | |
| 4 | +import { api } from '../../app/api.js' | |
| 5 | +import PgCallout from '../../components/PgCallout.jsx' | |
| 6 | +import Playground from '../../playground/Playground.jsx' | |
| 7 | +import { maskKey, useSessionKey } from './sessionKey.jsx' | |
| 8 | + | |
| 9 | +export default function PlaygroundTab() { | |
| 10 | + const session = useSessionKey() | |
| 11 | + const [paste, setPaste] = useState('') | |
| 12 | + const [keys, setKeys] = useState(null) | |
| 13 | + const [busy, setBusy] = useState(false) | |
| 14 | + const [err, setErr] = useState(null) | |
| 15 | + useEffect(() => { let alive = true; api('/v1/me/keys').then(r => alive && setKeys(r.data?.data || r.data || [])).catch(() => alive && setKeys([])); return () => { alive = false } }, []) | |
| 16 | + | |
| 17 | + const usePasted = e => { | |
| 18 | + e.preventDefault() | |
| 19 | + const k = paste.trim() | |
| 20 | + if (!/^hfmd_(live|test)_[A-Za-z0-9]{16,}$/.test(k)) { setErr('That does not look like an HF Market Data key (hfmd_live_…).'); return } | |
| 21 | + setErr(null); session.setKey(k, 'pasted key'); setPaste('') | |
| 22 | + } | |
| 23 | + const createAndUse = async () => { | |
| 24 | + setBusy(true); setErr(null) | |
| 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 | |
| 28 | + 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) } | |
| 30 | + } | |
| 31 | + | |
| 32 | + return ( | |
| 33 | + <div className="dash-section dash-pg"> | |
| 34 | + <div className="dash-head-row"> | |
| 35 | + <h1>Playground</h1> | |
| 36 | + {session.key && ( | |
| 37 | + <div className="dash-pg-key" data-testid="session-key"> | |
| 38 | + Using <code>{maskKey(session.key)}</code>{session.name ? <span className="muted"> ({session.name})</span> : ''} for this session | |
| 39 | + <button type="button" className="btn btn-sm" onClick={() => session.setKey('')}>Forget</button> | |
| 40 | + </div> | |
| 41 | + )} | |
| 42 | + </div> | |
| 43 | + {!session.key && ( | |
| 44 | + <PgCallout kind="info" title="Choose the key to use for this session" className="dash-pg-pick"> | |
| 45 | + <p className="dash-pg-p">Keys are never retrievable after creation, so the browser cannot fetch them for you. Paste one of your keys (kept in memory only, gone when you close the tab) or create a fresh one now.</p> | |
| 46 | + <form className="dash-pg-form" onSubmit={usePasted}> | |
| 47 | + <input className="mono" type="password" autoComplete="off" placeholder="hfmd_live_…" value={paste} onChange={e => setPaste(e.target.value)} aria-label="Paste an API key" data-testid="paste-key" /> | |
| 48 | + <button type="submit" className="btn" disabled={!paste}>Use this key</button> | |
| 49 | + <button type="button" className="btn btn-primary" onClick={createAndUse} disabled={busy} data-testid="create-and-use">{busy ? 'Creating…' : 'Create a key and use it'}</button> | |
| 50 | + </form> | |
| 51 | + {keys && keys.length > 0 && <p className="muted dash-pg-p">You have {keys.filter(k => k.status === 'active').length} active key(s): {keys.filter(k => k.status === 'active').map(k => <code key={k.id}>{k.prefix}…</code>)} — see <Link to="/dashboard/keys">API keys</Link>.</p>} | |
| 52 | + {err && <p className="pg-err" role="alert">{err}</p>} | |
| 53 | + </PgCallout> | |
| 54 | + )} | |
| 55 | + <Playground apiKey={session.key || undefined} embedded /> | |
| 56 | + </div> | |
| 57 | + ) | |
| 58 | +} | |
added
hfmarketdata/web/src/pages/dashboard/Usage.jsx
+78 −0
@@ -0,0 +1,78 @@ | ||
| 1 | +// Usage: 24h / 7d / 30d, SVG charts, per-day table, CSV export of the series. | |
| 2 | +import React, { useEffect, useMemo, useState } from 'react' | |
| 3 | +import { api } from '../../app/api.js' | |
| 4 | +import DashChart from '../../components/DashChart.jsx' | |
| 5 | +import PgCallout from '../../components/PgCallout.jsx' | |
| 6 | + | |
| 7 | +const RANGES = ['24h', '7d', '30d'] | |
| 8 | +const n = v => Number(v || 0).toLocaleString() | |
| 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 | +} | |
| 14 | + | |
| 15 | +export function byDay(series) { | |
| 16 | + const m = new Map() | |
| 17 | + for (const s of series) { | |
| 18 | + const d = String(s.ts).slice(0, 10) | |
| 19 | + const cur = m.get(d) || { day: d, requests: 0, rows: 0, status_429: 0 } | |
| 20 | + cur.requests += Number(s.requests) || 0; cur.rows += Number(s.rows) || 0; cur.status_429 += Number(s.status_429) || 0 | |
| 21 | + m.set(d, cur) | |
| 22 | + } | |
| 23 | + return [...m.values()].sort((a, b) => b.day.localeCompare(a.day)) | |
| 24 | +} | |
| 25 | + | |
| 26 | +export default function Usage() { | |
| 27 | + const [range, setRange] = useState('7d') | |
| 28 | + const [data, setData] = useState(null) | |
| 29 | + const [err, setErr] = useState(null) | |
| 30 | + const [loading, setLoading] = useState(true) | |
| 31 | + useEffect(() => { | |
| 32 | + let alive = true | |
| 33 | + 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)) | |
| 35 | + return () => { alive = false } | |
| 36 | + }, [range]) | |
| 37 | + const series = useMemo(() => (data?.series || []).slice().sort((a, b) => String(a.ts).localeCompare(String(b.ts))), [data]) | |
| 38 | + 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) } | |
| 42 | + const xFormat = range === '24h' ? ts => String(ts).slice(11, 16) : ts => String(ts).slice(5, 10) | |
| 43 | + return ( | |
| 44 | + <div className="dash-section"> | |
| 45 | + <div className="dash-head-row"> | |
| 46 | + <h1>Usage</h1> | |
| 47 | + <div className="dash-range" role="radiogroup" aria-label="Range"> | |
| 48 | + {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 | + </div> | |
| 50 | + {csvUrl && <a className="btn btn-sm" href={csvUrl} download={`hfmd-usage-${range}.csv`}>Export CSV</a>} | |
| 51 | + </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>} | |
| 53 | + <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> | |
| 57 | + </div> | |
| 58 | + {loading && !data ? <p className="muted" aria-busy="true">Loading…</p> : ( | |
| 59 | + <> | |
| 60 | + <div className="dash-charts"> | |
| 61 | + <div className="card"><DashChart series={series} valueKey="requests" label={`Requests per ${range === '24h' ? 'minute' : 'interval'}`} xFormat={xFormat} /></div> | |
| 62 | + <div className="card"><DashChart series={series} valueKey="rows" label="Rows" color="var(--accent-2)" xFormat={xFormat} /></div> | |
| 63 | + </div> | |
| 64 | + <h2>Per day</h2> | |
| 65 | + <div className="dash-table-wrap"> | |
| 66 | + <table className="dash-table" data-testid="usage-table"> | |
| 67 | + <thead><tr><th>Day (UTC)</th><th className="num">Requests</th><th className="num">Rows</th><th className="num">429</th></tr></thead> | |
| 68 | + <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>)} | |
| 71 | + </tbody> | |
| 72 | + </table> | |
| 73 | + </div> | |
| 74 | + </> | |
| 75 | + )} | |
| 76 | + </div> | |
| 77 | + ) | |
| 78 | +} | |
added
hfmarketdata/web/src/pages/dashboard/dashboard.css
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +/* Dashboard (dash-) and admin (adm-) share this sheet's dash-* primitives. */ | |
| 2 | +.dash { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 28px; align-items: start; padding-top: 28px; } | |
| 3 | +@media (max-width: 860px) { .dash { grid-template-columns: 1fr; } .dash-side { position: static; } } | |
| 4 | +.dash-side { position: sticky; top: 72px; display: flex; flex-direction: column; gap: 18px; } | |
| 5 | +.dash-user { padding: 12px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); } | |
| 6 | +.dash-user-name { font-weight: 600; overflow-wrap: anywhere; } | |
| 7 | +.dash-user-mail { font-size: 12px; overflow-wrap: anywhere; } | |
| 8 | +.dash-tier { font-size: 12px; margin-top: 6px; color: var(--fg-1); } | |
| 9 | +.dash-nav { display: flex; flex-direction: column; gap: 2px; } | |
| 10 | +.dash-nav a { color: var(--fg-1); padding: 8px 10px; border-radius: 6px; } | |
| 11 | +.dash-nav a.active, .dash-nav a:hover { background: var(--bg-2); color: var(--fg); text-decoration: none; } | |
| 12 | +.dash-nav-admin { border-top: 1px solid var(--line); margin-top: 6px; padding-top: 10px !important; border-radius: 0 !important; } | |
| 13 | +.dash-side-foot { display: flex; flex-direction: column; gap: 10px; font-size: 12px; } | |
| 14 | +.dash-main { min-width: 0; } | |
| 15 | +.dash-section { display: flex; flex-direction: column; gap: 18px; } | |
| 16 | +.dash-section h1 { margin: 0; font-size: 26px; } | |
| 17 | +.dash-section h2 { margin: 0 0 8px; font-size: 17px; } | |
| 18 | +.dash-head-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } | |
| 19 | +.dash-head-row h1 { flex: 1; } | |
| 20 | +.dash-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; } | |
| 21 | +.dash-stat { display: flex; flex-direction: column; gap: 4px; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); font-size: 12.5px; } | |
| 22 | +.dash-stat-label { color: var(--fg-2); text-transform: uppercase; letter-spacing: .06em; font-size: 11px; } | |
| 23 | +.dash-stat-value { font-size: 24px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; } | |
| 24 | +.dash-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } | |
| 25 | +.dash-card { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; } | |
| 26 | +.dash-card p { margin: 0; } | |
| 27 | +.dash-pre { width: 100%; font-size: 12px; margin: 0; } | |
| 28 | +.dash-dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; margin: 0; font-size: 14px; } | |
| 29 | +.dash-dl dt { color: var(--fg-2); } | |
| 30 | +.dash-dl dd { margin: 0; } | |
| 31 | +.dash-ul { margin: 0; padding-left: 18px; font-size: 14px; color: var(--fg-1); } | |
| 32 | +.dash-table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-1); } | |
| 33 | +.dash-table { font-size: 13.5px; } | |
| 34 | +.dash-table th { background: var(--bg-2); white-space: nowrap; } | |
| 35 | +.dash-table td, .dash-table th { padding: 9px 12px; } | |
| 36 | +.dash-table .num { text-align: right; font-variant-numeric: tabular-nums; } | |
| 37 | +.dash-row-muted { opacity: .55; } | |
| 38 | +.dash-badge { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; padding: 2px 7px; border-radius: 4px; background: var(--bg-2); border: 1px solid var(--line-2); } | |
| 39 | +.dash-badge.active { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); } | |
| 40 | +.dash-badge.revoked, .dash-badge.disabled { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); } | |
| 41 | +.dash-badge.invited { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); } | |
| 42 | +.dash-badge.admin { color: var(--accent-2); border-color: color-mix(in srgb, var(--accent-2) 40%, transparent); } | |
| 43 | +.dash-actions { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } | |
| 44 | +.dash-actions-th { min-width: 200px; } | |
| 45 | +.dash-danger { color: var(--danger); } | |
| 46 | +.dash-confirm { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; font-size: 12.5px; } | |
| 47 | +.dash-create { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; } | |
| 48 | +.dash-create label { font-size: 12px; color: var(--fg-1); display: block; margin-bottom: 4px; width: 100%; } | |
| 49 | +.dash-create input { min-width: 240px; } | |
| 50 | +.dash-reveal { display: flex; flex-direction: column; gap: 12px; border-color: color-mix(in srgb, var(--warn) 50%, transparent); } | |
| 51 | +.dash-key-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } | |
| 52 | +.dash-key { font-size: 14px; padding: 8px 12px; background: var(--bg-2); border: 1px solid var(--line-2); border-radius: 6px; overflow-wrap: anywhere; user-select: all; } | |
| 53 | +.dash-reveal-actions { display: flex; gap: 8px; flex-wrap: wrap; } | |
| 54 | +.dash-range { display: inline-flex; border: 1px solid var(--line-2); border-radius: 6px; overflow: hidden; } | |
| 55 | +.dash-range-btn { background: var(--bg-1); border: 0; color: var(--fg-1); padding: 6px 12px; cursor: pointer; font: inherit; font-size: 13px; } | |
| 56 | +.dash-range-btn.active { background: var(--bg-2); color: var(--fg); } | |
| 57 | +.dash-charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 14px; } | |
| 58 | +.dash-chart { margin: 0; } | |
| 59 | +.dash-chart svg { width: 100%; height: auto; display: block; } | |
| 60 | +.dash-chart-title { font-size: 13px; color: var(--fg-1); margin-bottom: 6px; } | |
| 61 | +.dash-chart-empty { font-size: 13px; margin: 0; } | |
| 62 | +.dash-pg-key { display: inline-flex; gap: 8px; align-items: center; font-size: 13px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg-1); flex-wrap: wrap; } | |
| 63 | +.dash-pg-pick .pg-callout-body { gap: 8px; } | |
| 64 | +.dash-pg-p { margin: 0; font-size: 13px; } | |
| 65 | +.dash-pg-form { display: flex; gap: 8px; flex-wrap: wrap; } | |
| 66 | +.dash-pg-form input { flex: 1; min-width: 220px; } | |
added
hfmarketdata/web/src/pages/dashboard/sessionKey.jsx
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +// Session-only API key for the dashboard playground. Memory only — never localStorage/sessionStorage/cookies. | |
| 2 | +import React, { createContext, useContext, useMemo, useState } from 'react' | |
| 3 | + | |
| 4 | +const Ctx = createContext({ key: '', name: '', setKey: () => {} }) | |
| 5 | + | |
| 6 | +export function SessionKeyProvider({ children }) { | |
| 7 | + const [state, setState] = useState({ key: '', name: '' }) | |
| 8 | + const value = useMemo(() => ({ key: state.key, name: state.name, setKey: (key, name = '') => setState({ key: key || '', name }) }), [state]) | |
| 9 | + return <Ctx.Provider value={value}>{children}</Ctx.Provider> | |
| 10 | +} | |
| 11 | + | |
| 12 | +export const useSessionKey = () => useContext(Ctx) | |
| 13 | + | |
| 14 | +export const maskKey = k => (k ? `${k.slice(0, 14)}…${k.slice(-4)}` : '') | |
| 15 | ||