Engagement Phase 1 (docs/website/ENGAGEMENT.md §1.2a, §3.1, §3.2). A subtraction and a replacement in one commit, because leaving the OAuth2 flow half-wired across a release is worse than either end state. Deleted, per the §1.2a inventory: GET /admin/email/connect/start and /connect/callback, the connectStart/connectCallback controllers with the email_oauth_tx signed cookie, the PKCE verifier and CSRF nonce plumbing, the https://mail.google.com/ scope, the borrowed `google` auth-providers client, the OAuth2 nodemailer transport with its smtp.gmail.com:465 literals, the refresh-token decrypt in the model, and the client's Connect Gmail button, redirect banner and six Gmail error strings. `provider` and `refresh_token_enc` stay as columns under the additive-only discipline, unread. Added: a mail transport registry (server/src/engagement/transports) with `smtp` as the sole registration. `credentialFields` is the single declaration the admin form renders, the sanitizer filters against, and the "is it secret" answer comes from, so adding a transport is a registration rather than four edits. email_config gains transport / credential_enc (one encrypted JSON blob, since the field list is the transport's to declare) / reply_to. All six call sites keep their exact failure contracts: the contact form's mailto fallback, the invite's copyable link, the reset's generic 200, and sendTeamNotification's never-throws. One deliberate behaviour change: `enabled` now gates every sender rather than only isConfigured() — the connect flow used to set it as a side effect, and with a credential form the toggle has to mean what it says. Send-test becomes the real verification. Under OAuth2 the sender came back from Google and was guaranteed to belong to the credential; operator-typed, it can be refused, so failures name the sender and the SPF/DMARC reason (§1.2a consequence 2). G22, the silent degradation: an upgraded deployment backfills to smtp with no credentials and every sink politely does nothing. The admin dashboard now warns when the deprecated Gmail token is present and no replacement credential is, so the one deployment this happens to is told. A fresh install has never had mail and is not nagged. Guardrails: new `npm run check:hosts` (§3.2 rule 4) with its own self-test, wired into pr-checks before the install; routes.manifest and routes.guards regenerated (-2 routes). Co-Authored-By: Claude <noreply@anthropic.com>
193 lines
7.0 KiB
JavaScript
193 lines
7.0 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 { 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) {
|
|
setModeError(
|
|
err.status === 403
|
|
? 'Only an administrator can change the site mode.'
|
|
: 'Could not change the site mode. Try again.',
|
|
)
|
|
} 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' }}
|
|
>
|
|
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', width: 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
|
|
}
|
|
}
|