Files
website/client/src/routes/admin/views/Dashboard.jsx
wtclaude 4ad746c43e
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 43s
PR Checks / client-build (pull_request) Successful in 1m3s
PR Checks / server-tests (pull_request) Successful in 8m37s
feat(demo): DEMO_MODE lock, seed:demo and the demo banner (MODULE_API 1.18.0)
Core half of the public demo (docs/website/DEMO_MODE.md §10 step 1, website#222).
Nothing here runs unless DEMO_MODE=true.

- server/src/demo/: the lock at /api/v1, ahead of every router. It covers every
  Tier A row of §3.3, the shared-login conditions, POST /auth/register (DM16),
  DELETE /auth/me/sessions/:id on a demo account (DM17), and the PUT
  /admin/settings keys that reach a Tier A lock. Any multipart request is refused
  app-wide. noindex and robots.txt. A condition that cannot decide refuses.
- Loader: demoLocks in module.json (writes only, under the module's own mounts),
  ctx.demoMode, and api.onDemoSeed (DM15). MODULE_API 1.18.0 on both halves.
- GET /public/settings carries demo: { resetsAt, logins } on the demo only.
- npm run seed:demo -- <fixture>: upserts the four accounts, sets the site live
  with registration disabled, seeds content on the first run only, calls each
  module's onDemoSeed, and stamps demo_state. It resolves $env: strings and
  refuses to run off the demo.
- Client: the banner, one DEMO_LOCKED toast fed from the request primitive (so
  module screens get it too), and the published logins on both sign-in pages.
  Dashboard and register say the demo message instead of blaming a role.
- OpenAPI: DemoLocked schema, the demo block, and a note that any write may
  answer DEMO_LOCKED on the demo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-23 18:52:09 -05:00

198 lines
7.5 KiB
JavaScript

import { useCallback, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import { isDemoLocked } from '../../../lib/demoNotice.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
import { useAuth } from '../../../contexts/AuthContext.jsx'
export default function Dashboard() {
const { refresh: refreshSite } = useSite()
const { user } = useAuth()
// PUT /admin/site-mode is adminOnly. The dashboard itself is staff-wide, so the
// toggle needs its own gate — same rule the sidebar follows (AdminLayout: never
// show a non-admin a control that would 403).
const isAdmin = user?.role === 'admin'
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(
() => Promise.all([api.admin.dashboard(), api.admin.listPosts(), api.admin.listWiki()]),
[tick],
)
const [busy, setBusy] = useState(false)
const [modeError, setModeError] = useState('')
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load the dashboard." />
const [dash, posts, wiki] = data
const mode = dash.site_mode || 'live'
const isLive = mode === 'live'
const modeDot = isLive ? 'var(--mode-live)' : 'var(--mode-maint)'
const published = posts.filter((p) => p.published).length
const stats = [
{ value: published, label: 'Published posts' },
{ value: posts.length - published, label: 'Drafts' },
{ value: wiki.length, label: 'Wiki pages' },
{ value: dash.counts?.users ?? 0, label: 'Users' },
]
// The rejection was previously unhandled: a refused toggle surfaced only as an
// unhandled promise rejection in the console while the button silently reverted.
async function toggle() {
setBusy(true)
setModeError('')
try {
await api.admin.setSiteMode(isLive ? 'maintenance' : 'live')
await refreshSite()
reload()
} catch (err) {
let message = 'Could not change the site mode. Try again.'
if (isDemoLocked(err)) message = err.message
else if (err.status === 403) message = 'Only an administrator can change the site mode.'
setModeError(message)
} finally {
setBusy(false)
}
}
const changed = dash.last_change || {}
let modeLabel = isLive ? 'Switch to Maintenance' : 'Switch to Live'
if (busy) modeLabel = 'Saving…'
return (
<section>
{/* Operator warnings: things that are quietly not working and would
otherwise be discovered by someone not receiving an email. The list is
normally empty, which is why it sits above the fold rather than in a
panel — see ENGAGEMENT.md §1.2a (G22). */}
{(dash.warnings || []).map((w) => (
<div
key={w.code}
className="sans"
style={{
fontSize: '0.86rem',
lineHeight: 1.5,
borderRadius: 10,
padding: '12px 16px',
marginBottom: 18,
border: '1px solid #7a6440',
background: 'rgba(224,176,112,0.08)',
color: '#e0b070',
}}
>
{w.message}
{w.href && (
<>
{' '}
<a href={w.href} style={{ color: '#e0b070', textDecoration: 'underline' }}>Open settings</a>
</>
)}
</div>
))}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 18,
padding: 24,
border: '1px solid var(--line)',
borderRadius: 12,
background: 'var(--panel-grad)',
marginBottom: 24,
}}
>
<div>
<div className="card-kicker" style={{ marginBottom: 8 }}>
Site mode
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ width: 11, height: 11, borderRadius: '50%', background: modeDot, boxShadow: `0 0 10px ${modeDot}` }} />
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', textTransform: 'capitalize' }}>
{mode}
</span>
</div>
<div className="sans dim" style={{ fontSize: '0.8rem', marginTop: 6 }}>
{changed.by ? `Changed by ${changed.by}` : 'No changes recorded'}
{changed.at ? ` · ${dateTime(changed.at)}` : ''}
</div>
{modeError && (
<div className="sans" style={{ fontSize: '0.8rem', marginTop: 8, color: 'var(--danger, #d98b8b)' }}>
{modeError}
</div>
)}
</div>
{isAdmin && (
<button
onClick={toggle}
disabled={busy}
className="sans"
style={{ border: '1px solid var(--accent)', borderRadius: 999, padding: '11px 24px', background: 'rgba(127,153,189,0.14)', color: '#d8e2ef', fontWeight: 600, fontSize: '0.9rem', cursor: 'pointer' }}
>
{modeLabel}
</button>
)}
</div>
<div className="grid-4" style={{ gap: 14, marginBottom: 28 }}>
{stats.map((s) => (
<div key={s.label} style={{ padding: 20, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)' }}>
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>
{s.value}
</div>
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>
{s.label}
</div>
</div>
))}
</div>
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.25rem', color: 'var(--head)' }}>
Recent activity
</h2>
<div className="panel-flat">
{(dash.recent_activity || []).length === 0 && (
<div className="adm-td" style={{ borderBottom: 'none' }}>No activity yet.</div>
)}
{(dash.recent_activity || []).map((a) => (
<div
key={a.id}
className="sans"
style={{ display: 'flex', gap: 14, alignItems: 'center', padding: '13px 18px', borderBottom: '1px solid var(--line-soft)', fontSize: '0.86rem' }}
>
{/* `minWidth` rather than `width`: the column still lines up for core's
own short action names, and a longer one — a module's namespaced
action, say `rust.account.unlink.staff` — grows the box instead of
overflowing it and printing on top of the detail beside it. Found
on a live dashboard with a module installed. */}
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', minWidth: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
{a.action}
</span>
<span style={{ flex: 1, color: 'var(--text)' }}>{formatDetail(a)}</span>
<span className="dim" style={{ flex: 'none' }}>{ago(a.created_at)}</span>
</div>
))}
</div>
</section>
)
}
// Render the JSON `detail` column in a human-ish way.
export function formatDetail(a) {
if (!a.detail) return a.username ? `by ${a.username}` : '—'
try {
const obj = JSON.parse(a.detail)
return Object.entries(obj)
.map(([k, v]) => `${k}: ${v}`)
.join(', ')
} catch {
return a.detail
}
}