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>
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 | ||