The Teams admin screen was written against four CSS classes that do not exist anywhere in the project — `.table`, `.kv`, `.list` and `.notice` — and against `.btn-ghost` / `.btn` used without the `.btn` box they depend on. The result rendered as unstyled UA tables and bare browser buttons sitting flush against unpadded panels, and looked nothing like the rest of the admin panel. Nothing here changes behaviour, data or routes; it is presentation only. - Tables become `adm-table` / `adm-th` / `adm-td` inside `panel-flat`, the markup the other fourteen admin views use, and scroll rather than clip when a row is wider than the shell (a status badge and the action buttons are both nowrap by design, so a narrow viewport can always overflow one). - Buttons take the full `btn btn-primary btn-sq` / `btn btn-ghost btn-sq` triplet. `.btn` carries the padding, border and radius; the variants carry only colour, so a bare `.btn-ghost` had none of the box and a bare `.btn` fell back to the UA's light button face. - `.panel` supplies no padding, so every panel now sets it explicitly at 22px, as ModulesAdmin and EmptyState already do. - Headings become `h2.display`, and the in-page `<h1>Teams</h1>` goes away in favour of AdminLayout's topbar title — which needed `/admin/teams` adding to TITLES, the reason the bar read "ADMIN". - Status pills use the existing `badge-pub` / `badge-moderator` / `badge-ban` / `badge-draft` modifiers. `.badge` alone declares no border, so the old inline `borderColor` was inert. - The bridge and voice panels drop their private palette (`#e08b77` / `#8fbf7a` / `#e0b877`) for the site's `#d98b84` / `#7fd0a4` / `#e0b070`, and a literal `rgba(255,255,255,.12)` rule and a `borderRadius: 4` for `var(--line-soft)` and `var(--radius-input)`. Walked live against the dev DB as an admin: sync panel, review queue, all Teams, the forum ledger, the bridge draft form and its acknowledgement dialog. Client 288/288, server 1162/1162, client build clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
491 lines
20 KiB
JavaScript
491 lines
20 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
|
import { dateTime } from '../../../lib/format.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 shard 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">{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 }
|