Files
website/client/src/routes/admin/views/TeamsAdmin.jsx
wtclaude d1ae8f943f
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 49s
PR Checks / server-tests (pull_request) Successful in 6m14s
feat(account): self-service account deletion, MODULE_API 1.20.0
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
2026-10-29 18:32:19 -05:00

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 }