ACCOUNT_DELETION.md phase 2 (core): a member deletes their own account on
the website, and the admin route runs the same procedure.
- DELETE /api/v1/auth/me/account: typed username, password always (AD7),
and a 2FA or recovery code when 2FA is on. A wrong password or code is
400, not 401 (AD12), so the app's refresh-on-401 never replays it.
409 password_required and last_admin; the demo's shared logins are
locked.
- auth/accountDeletion.js: module hooks first, then one transaction that
reserves a staff username (reserved_usernames, AD9) and deletes the row,
then a Teams reconcile, the audit entry (no username) and a notice to
the old address (new protected template auth.account-deleted).
- MODULE_API 1.20.0: api.onUserDeleting({ userId, deletedBy }), before the
delete, 10 s limit, cannot veto (AD10, AD11).
- utils/displayAuthor.js: a deleted account's username snapshots read
"[deleted player]" to members and the stored name, marked, to staff
(AD5, AD6). Used by the forum, the Reports queue, the guest list and
the Team digest. A deleted account drops off Forum guests (AD13).
- Reserved names refuse at every username write, answered as "taken".
- Web: Delete my account on the player and staff Account pages; staff
views mark "(deleted account)".
- OpenAPI regenerated; route manifest and engagement manifest regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
492 lines
20 KiB
JavaScript
492 lines
20 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
|
import { dateTime } from '../../../lib/format.js'
|
|
import { rowName } from '../../../lib/deletedAccount.js'
|
|
import {
|
|
freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE,
|
|
} from '../../../lib/teamAdmin.js'
|
|
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
|
import { api } from '../../../api/client.js'
|
|
import TeamIntegrations from './TeamIntegrations.jsx'
|
|
import TeamVoice from './TeamVoice.jsx'
|
|
|
|
// Admin → Teams (docs/website/TEAMS.md §2.4, §2.8, §2.9).
|
|
//
|
|
// Three panels, in the order an operator needs them:
|
|
//
|
|
// 1. **Sync state**, verbatim, including the last error. The screen's first job
|
|
// is to make "the game server has no Teams" and "core has not been able to ask for
|
|
// two hours" impossible to confuse — they render almost identically
|
|
// otherwise, and one is fine while the other is an outage.
|
|
// 2. **The review queue** — Teams auto-hidden because their name matched the
|
|
// impersonation list, each showing which term matched.
|
|
// 3. **The approval queue** — what moderators have asked to publish.
|
|
//
|
|
// Everything that decides what a row SAYS lives in lib/teamAdmin.js, which is
|
|
// plain JS and has tests; this file renders it.
|
|
|
|
// Tones map onto the badge modifiers the rest of the admin panel already uses,
|
|
// rather than onto inline colours. `.badge` on its own carries no border or
|
|
// background — those live on the modifier — so a bare `className="badge"` with an
|
|
// inline `borderColor` renders borderless, which is what this screen used to do.
|
|
const TONE_BADGE = { ok: 'badge-pub', warn: 'badge-moderator', bad: 'badge-ban', idle: 'badge-draft' }
|
|
|
|
// The same three tones as text, for the places a badge would be wrong (a verbatim
|
|
// error line). House palette — the values every other admin view uses.
|
|
const TONE_TEXT = { ok: '#7fd0a4', warn: '#e0b070', bad: '#d98b84', idle: 'var(--muted)' }
|
|
|
|
const PANEL = { padding: 22, marginBottom: 22 }
|
|
const HEADING = { margin: '0 0 12px', fontSize: '1.2rem', color: 'var(--head)' }
|
|
const KV_VALUE = { margin: 0, fontSize: '0.88rem', color: 'var(--text)' }
|
|
const SCROLLER = { overflowX: 'auto' }
|
|
const BLURB = { margin: '0 0 14px', color: 'var(--muted)', fontSize: '0.85rem', lineHeight: 1.6 }
|
|
|
|
function Pill({ tone, children }) {
|
|
return <span className={`badge ${TONE_BADGE[tone] || 'badge-draft'}`}>{children}</span>
|
|
}
|
|
|
|
// ── Sync state ─────────────────────────────────────────────────────────────
|
|
|
|
function SyncPanel({ sync, syncState, onResync, busy }) {
|
|
const freshness = freshnessOf(sync)
|
|
return (
|
|
<section className="panel" style={PANEL}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
|
|
<h2 className="display" style={{ ...HEADING, margin: 0 }}>Sync</h2>
|
|
<Pill tone={freshness.tone}>{freshness.label}</Pill>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-sq"
|
|
onClick={onResync}
|
|
disabled={busy || !sync.configured}
|
|
>
|
|
{busy ? 'Resyncing…' : 'Resync now'}
|
|
</button>
|
|
</div>
|
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.85rem' }}>{freshness.detail}</p>
|
|
|
|
{syncState && (
|
|
<dl
|
|
style={{
|
|
display: 'grid', gridTemplateColumns: 'auto minmax(0, 1fr)', gap: '9px 20px',
|
|
margin: '16px 0 0', alignItems: 'baseline',
|
|
}}
|
|
>
|
|
<dt className="field-label" style={{ margin: 0 }}>Module</dt>
|
|
<dd className="sans" style={KV_VALUE}>{syncState.moduleId}</dd>
|
|
<dt className="field-label" style={{ margin: 0 }}>Last attempt</dt>
|
|
<dd className="sans" style={KV_VALUE}>{dateTime(syncState.lastAttemptAt) || 'never'}</dd>
|
|
<dt className="field-label" style={{ margin: 0 }}>Last success</dt>
|
|
<dd className="sans" style={KV_VALUE}>{dateTime(syncState.lastSuccessAt) || 'never'}</dd>
|
|
<dt className="field-label" style={{ margin: 0 }}>Consecutive failures</dt>
|
|
<dd className="sans" style={KV_VALUE}>{syncState.consecutiveFailures}</dd>
|
|
{syncState.lastError && (
|
|
<>
|
|
{/* Verbatim. An operator debugging a stale projection needs what the
|
|
provider actually said, not a friendlier paraphrase of it. */}
|
|
<dt className="field-label" style={{ margin: 0 }}>Last error</dt>
|
|
<dd className="sans" style={{ ...KV_VALUE, color: TONE_TEXT.bad }}>{syncState.lastError}</dd>
|
|
</>
|
|
)}
|
|
{syncState.pendingEmptySince && (
|
|
<>
|
|
<dt className="field-label" style={{ margin: 0 }}>Empty answer held</dt>
|
|
<dd className="sans" style={KV_VALUE}>
|
|
since {dateTime(syncState.pendingEmptySince)} — an authoritative but empty list is
|
|
applied only if the next answer agrees.
|
|
</dd>
|
|
</>
|
|
)}
|
|
</dl>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ── The reserved-name review queue ─────────────────────────────────────────
|
|
|
|
function ReviewQueue({ rows, role, onAct, busy }) {
|
|
if (!rows.length) return null
|
|
return (
|
|
<section className="panel" style={PANEL}>
|
|
<h2 className="display" style={HEADING}>Names to review</h2>
|
|
<p className="sans" style={BLURB}>
|
|
These Teams are hidden from every public surface because their name matched a reserved term.
|
|
They work normally for their own members. {GATED_NOTE}
|
|
</p>
|
|
<div className="panel-flat" style={SCROLLER}>
|
|
<table className="adm-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="adm-th">Name</th>
|
|
<th className="adm-th">Matched</th>
|
|
<th className="adm-th">Members</th>
|
|
<th className="adm-th">Created</th>
|
|
<th className="adm-th" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row) => (
|
|
<tr key={row.id}>
|
|
<td className="adm-td" style={{ color: 'var(--head)' }}>{row.name}</td>
|
|
<td className="adm-td"><Pill tone="bad">{row.hidden_term}</Pill></td>
|
|
<td className="adm-td">{row.member_count}</td>
|
|
<td className="adm-td dim">{dateTime(row.created_at)}</td>
|
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sq"
|
|
disabled={busy}
|
|
onClick={() => onAct(row.id, 'unhide')}
|
|
>
|
|
{gateLabelFor(role, 'Publish')}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ── The approval queue ─────────────────────────────────────────────────────
|
|
|
|
function RequestQueue({ rows, role, onDecide, busy }) {
|
|
if (!rows.length) return null
|
|
const canDecide = role === 'admin'
|
|
return (
|
|
<section className="panel" style={PANEL}>
|
|
<h2 className="display" style={HEADING}>Awaiting approval</h2>
|
|
<p className="sans" style={BLURB}>
|
|
{canDecide
|
|
? 'Approving publishes the name; rejecting keeps the record and changes nothing.'
|
|
: 'Only an admin can decide these. Your own requests stay here until one does.'}
|
|
</p>
|
|
<div className="panel-flat" style={SCROLLER}>
|
|
<table className="adm-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="adm-th">Request</th>
|
|
<th className="adm-th">Requested</th>
|
|
<th className="adm-th">Reason</th>
|
|
{canDecide && <th className="adm-th" />}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row) => (
|
|
<tr key={row.id}>
|
|
<td className="adm-td" style={{ color: 'var(--head)' }}>{describeRequest(row)}</td>
|
|
<td className="adm-td dim">{dateTime(row.requested_at)}</td>
|
|
<td className="adm-td dim">{row.reason ? `“${row.reason}”` : '—'}</td>
|
|
{canDecide && (
|
|
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sq"
|
|
disabled={busy}
|
|
onClick={() => onDecide(row.id, 'approved')}
|
|
>
|
|
Approve
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-sq"
|
|
style={{ marginLeft: 8 }}
|
|
disabled={busy}
|
|
onClick={() => onDecide(row.id, 'rejected')}
|
|
>
|
|
Reject
|
|
</button>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ── One Team ───────────────────────────────────────────────────────────────
|
|
|
|
function TeamRow({ team, role, onAct, busy, onLedger }) {
|
|
const status = statusOf(team)
|
|
return (
|
|
<tr>
|
|
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
|
{team.displayName}
|
|
{team.displayNameOverride && (
|
|
<div className="dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
|
|
shown instead of “{team.name}”
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="adm-td"><Pill tone={status.tone}>{status.label}</Pill></td>
|
|
<td className="adm-td">{team.memberCount}</td>
|
|
<td className="adm-td">{team.linkedCount}</td>
|
|
<td className="adm-td">{team.onlineCount}</td>
|
|
<td className="adm-td dim">{dateTime(team.rosterSyncedAt) || 'never'}</td>
|
|
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
|
{team.status === 'active' && (team.hidden
|
|
? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sq"
|
|
disabled={busy}
|
|
onClick={() => onAct(team.id, 'unhide')}
|
|
>
|
|
{gateLabelFor(role, 'Publish')}
|
|
</button>
|
|
)
|
|
: (
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-sq"
|
|
disabled={busy}
|
|
onClick={() => onAct(team.id, 'hide')}
|
|
>
|
|
Hide
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost btn-sq"
|
|
onClick={() => onLedger(team)}
|
|
style={{ marginLeft: 8 }}
|
|
>
|
|
Forum log
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* One Team's forum moderation ledger (TEAMS.md §5.3).
|
|
*
|
|
* The route and the API method have existed since phase 4 and nothing rendered
|
|
* them, which made the ledger a table only a DB client could read. The column
|
|
* that earns the screen is `actorRole`: it records WHICH authority was exercised,
|
|
* so a leader's ordinary housekeeping stays distinguishable from a staff
|
|
* intervention after the fact.
|
|
*
|
|
* **This is deliberately not merged with the site's mod_actions/appeals pair.**
|
|
* That one is Discord-sanction-shaped and bot-owned; routing a guild leader
|
|
* locking a thread through it would make ordinary housekeeping an appealable
|
|
* sanction with a reversal path into the bot. Every STAFF-exercised action here
|
|
* additionally writes activity_log, so the site's accountability trail sees it —
|
|
* the two are cross-referenced, not merged.
|
|
*/
|
|
function ForumLedger({ team, onClose }) {
|
|
const [rows, setRows] = useState(null)
|
|
const [error, setError] = useState('')
|
|
|
|
useEffect(() => {
|
|
let active = true
|
|
api.admin.teamForumModeration(team.id)
|
|
// `{ entries }`, and the rows are the ledger table's own snake_case
|
|
// columns — this endpoint serves them unmapped, unlike the Team payloads
|
|
// above it. Reading them as they are, rather than accepting three possible
|
|
// shapes, is what makes a change to that endpoint fail here instead of
|
|
// rendering an empty table.
|
|
.then((res) => { if (active) setRows(res.entries) })
|
|
.catch((err) => { if (active) setError(err.message || 'Could not load the forum log.') })
|
|
return () => { active = false }
|
|
}, [team.id])
|
|
|
|
return (
|
|
<section className="panel" style={PANEL}>
|
|
<header
|
|
style={{
|
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
gap: 14, flexWrap: 'wrap', marginBottom: 12,
|
|
}}
|
|
>
|
|
<h2 className="display" style={{ ...HEADING, margin: 0 }}>Forum log — {team.displayName}</h2>
|
|
<button type="button" className="btn btn-ghost btn-sq" onClick={onClose}>Close</button>
|
|
</header>
|
|
{error && <ErrorState message={error} />}
|
|
{!rows && !error && <Loading />}
|
|
{rows && rows.length === 0 && (
|
|
<p className="sans" style={{ ...BLURB, margin: 0 }}>Nothing has been moderated in this forum.</p>
|
|
)}
|
|
{rows && rows.length > 0 && (
|
|
<div className="panel-flat" style={SCROLLER}>
|
|
<table className="adm-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="adm-th">When</th>
|
|
<th className="adm-th">Action</th>
|
|
<th className="adm-th">Target</th>
|
|
<th className="adm-th">By</th>
|
|
<th className="adm-th">As</th>
|
|
<th className="adm-th">Reason</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((r) => (
|
|
<tr key={r.id}>
|
|
<td className="adm-td dim">{dateTime(r.created_at)}</td>
|
|
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.action}</td>
|
|
<td className="adm-td dim">{r.target_type} #{r.target_id}</td>
|
|
<td className="adm-td">{rowName(r.actor_user_id, r.actor_username)}</td>
|
|
<td className="adm-td">
|
|
{/* The distinction the whole ledger exists to preserve. */}
|
|
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
|
|
</td>
|
|
<td className="adm-td dim">{r.reason || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ── The screen ─────────────────────────────────────────────────────────────
|
|
|
|
export default function TeamsAdmin() {
|
|
const { user } = useAuth()
|
|
const role = user ? user.role : null
|
|
|
|
const [data, setData] = useState(null)
|
|
const [review, setReview] = useState([])
|
|
const [requests, setRequests] = useState([])
|
|
const [error, setError] = useState('')
|
|
const [notice, setNotice] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
const [ledgerTeam, setLedgerTeam] = useState(null)
|
|
|
|
const load = useCallback(async () => {
|
|
setError('')
|
|
try {
|
|
const [teams, reviewQueue, requestQueue] = await Promise.all([
|
|
api.admin.listTeams(),
|
|
api.admin.teamReviewQueue(),
|
|
api.admin.teamRequests('pending'),
|
|
])
|
|
setData(teams)
|
|
setReview(reviewQueue.teams || [])
|
|
setRequests(requestQueue.requests || [])
|
|
} catch (err) {
|
|
setError(err.message || 'Could not load Teams.')
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
async function run(fn, pendingMessage) {
|
|
setBusy(true)
|
|
setNotice('')
|
|
setError('')
|
|
try {
|
|
const result = await fn()
|
|
// The server decides whether an action applied or was filed, from the
|
|
// caller's live role. Saying so plainly is what stops a moderator thinking
|
|
// nothing happened.
|
|
if (result && result.pending) setNotice(pendingMessage)
|
|
await load()
|
|
} catch (err) {
|
|
setError(err.message || 'That did not work.')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const act = (id, action) => run(
|
|
() => (action === 'hide' ? api.admin.hideTeam(id) : api.admin.unhideTeam(id)),
|
|
'Filed for approval. Nothing has changed publicly until an admin approves it.',
|
|
)
|
|
|
|
const decide = (id, status) => run(
|
|
() => api.admin.decideTeamRequest(id, status),
|
|
'',
|
|
)
|
|
|
|
const resync = () => run(async () => {
|
|
const result = await api.admin.resyncTeams()
|
|
// A refusal is the normal, designed outcome when the provider cannot answer,
|
|
// so it is reported as a result rather than thrown as an error.
|
|
if (!result.ok) setError(`Resync refused: ${result.reason}. Nothing was changed.`)
|
|
else if (result.quarantined) {
|
|
setNotice('The provider answered with an empty list. It is being held for confirmation, not applied.')
|
|
}
|
|
return null
|
|
}, '')
|
|
|
|
if (error && !data) return <ErrorState message={error} />
|
|
if (!data) return <Loading />
|
|
|
|
return (
|
|
<div>
|
|
{/* No page <h1>: AdminLayout's topbar already titles the page, as it does for
|
|
every other admin screen. This one used to render its own, which is why
|
|
"Teams" appeared twice — once in Cinzel in the bar and once in the body
|
|
in whatever the UA picked for an unstyled heading. */}
|
|
{error && <ErrorState message={error} />}
|
|
{notice && (
|
|
<div className="note sans" style={{ fontSize: '0.85rem', marginBottom: 22 }}>{notice}</div>
|
|
)}
|
|
|
|
{ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => setLedgerTeam(null)} />}
|
|
|
|
{/* Admin-only, matching the server (§7.2). Rendered for a moderator it would
|
|
be a panel every action in fails 403 — the role gate is the server's, and
|
|
this is only how the screen agrees with it. */}
|
|
{role === 'admin' && <TeamIntegrations />}
|
|
{role === 'admin' && <TeamVoice />}
|
|
|
|
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
|
|
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
|
|
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
|
|
|
|
<section className="panel" style={PANEL}>
|
|
<h2 className="display" style={HEADING}>All Teams</h2>
|
|
{!data.teams.length && (
|
|
<p className="sans" style={{ ...BLURB, margin: 0 }}>
|
|
{data.configured
|
|
? 'No Teams in the projection yet.'
|
|
: 'No installed module supplies Teams, so there is nothing to show.'}
|
|
</p>
|
|
)}
|
|
{data.teams.length > 0 && (
|
|
<div className="panel-flat" style={SCROLLER}>
|
|
<table className="adm-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="adm-th">Name</th>
|
|
<th className="adm-th">Status</th>
|
|
<th className="adm-th">Members</th>
|
|
<th className="adm-th">Linked</th>
|
|
<th className="adm-th">Online</th>
|
|
<th className="adm-th">Roster confirmed</th>
|
|
<th className="adm-th" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.teams.map((team) => (
|
|
<TeamRow
|
|
key={team.id}
|
|
team={team}
|
|
role={role}
|
|
onAct={act}
|
|
busy={busy}
|
|
onLedger={setLedgerTeam}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export { leadershipOf }
|