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 {children} } // ── Sync state ───────────────────────────────────────────────────────────── function SyncPanel({ sync, syncState, onResync, busy }) { const freshness = freshnessOf(sync) return (

Sync

{freshness.label}

{freshness.detail}

{syncState && (
Module
{syncState.moduleId}
Last attempt
{dateTime(syncState.lastAttemptAt) || 'never'}
Last success
{dateTime(syncState.lastSuccessAt) || 'never'}
Consecutive failures
{syncState.consecutiveFailures}
{syncState.lastError && ( <> {/* Verbatim. An operator debugging a stale projection needs what the provider actually said, not a friendlier paraphrase of it. */}
Last error
{syncState.lastError}
)} {syncState.pendingEmptySince && ( <>
Empty answer held
since {dateTime(syncState.pendingEmptySince)} — an authoritative but empty list is applied only if the next answer agrees.
)}
)}
) } // ── The reserved-name review queue ───────────────────────────────────────── function ReviewQueue({ rows, role, onAct, busy }) { if (!rows.length) return null return (

Names to review

These Teams are hidden from every public surface because their name matched a reserved term. They work normally for their own members. {GATED_NOTE}

{rows.map((row) => ( ))}
Name Matched Members Created
{row.name} {row.hidden_term} {row.member_count} {dateTime(row.created_at)}
) } // ── The approval queue ───────────────────────────────────────────────────── function RequestQueue({ rows, role, onDecide, busy }) { if (!rows.length) return null const canDecide = role === 'admin' return (

Awaiting approval

{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.'}

{canDecide && {rows.map((row) => ( {canDecide && ( )} ))}
Request Requested Reason}
{describeRequest(row)} {dateTime(row.requested_at)} {row.reason ? `“${row.reason}”` : '—'}
) } // ── One Team ─────────────────────────────────────────────────────────────── function TeamRow({ team, role, onAct, busy, onLedger }) { const status = statusOf(team) return ( {team.displayName} {team.displayNameOverride && (
shown instead of “{team.name}”
)} {status.label} {team.memberCount} {team.linkedCount} {team.onlineCount} {dateTime(team.rosterSyncedAt) || 'never'} {team.status === 'active' && (team.hidden ? ( ) : ( ))} ) } /** * 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 (

Forum log — {team.displayName}

{error && } {!rows && !error && } {rows && rows.length === 0 && (

Nothing has been moderated in this forum.

)} {rows && rows.length > 0 && (
{rows.map((r) => ( ))}
When Action Target By As Reason
{dateTime(r.created_at)} {r.action} {r.target_type} #{r.target_id} {r.actor_username || '—'} {/* The distinction the whole ledger exists to preserve. */} {r.actor_role} {r.reason || '—'}
)}
) } // ── 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 if (!data) return return (
{/* No page

: 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 && } {notice && (
{notice}
)} {ledgerTeam && 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' && } {role === 'admin' && }

All Teams

{!data.teams.length && (

{data.configured ? 'No Teams in the projection yet.' : 'No installed module supplies Teams, so there is nothing to show.'}

)} {data.teams.length > 0 && (
{data.teams.map((team) => ( ))}
Name Status Members Linked Online Roster confirmed
)}

) } export { leadershipOf }