diff --git a/client/src/App.jsx b/client/src/App.jsx
index a627509..1750361 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -42,6 +42,7 @@ import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
+import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
@@ -174,6 +175,10 @@ export default function App() {
the volume in the first place. Declared here with the rest of
core's routes, above the module-supplied ones below. */}
} />
+ {/* Staff-wide, like the moderation queues: the gate on the three
+ actions that publish a game-written name is applied per request
+ on the server, from the caller's live role (TEAMS.md 2.9). */}
+ } />
} />
{/* Installed modules' admin pages, at /admin//…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
diff --git a/client/src/api/client.js b/client/src/api/client.js
index b21db5a..64359c4 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -247,6 +247,29 @@ export const api = {
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
+ // Teams (docs/website/TEAMS.md §2.11). Three of these mean something
+ // different depending on who calls them: for a moderator, unhide and
+ // setTeamDisplayName file a request and the response says `pending: true`.
+ // The caller does not choose — the server decides from the live role — so
+ // there is deliberately no "asRequest" argument to get wrong.
+ listTeams: () => req('/admin/teams'),
+ getTeam: (id) => req(`/admin/teams/${id}`),
+ resyncTeams: () => req('/admin/teams/resync', { method: 'POST' }),
+ archiveTeam: (id, reason) => req(`/admin/teams/${id}/archive`, { method: 'POST', body: { reason } }),
+ teamGrants: (id) => req(`/admin/teams/${id}/grants`),
+ hideTeam: (id, reason) => req(`/admin/teams/${id}/hide`, { method: 'POST', body: { reason } }),
+ unhideTeam: (id, reason) => req(`/admin/teams/${id}/unhide`, { method: 'POST', body: { reason } }),
+ setTeamDisplayName: (id, displayName, reason) =>
+ req(`/admin/teams/${id}/display-name`, { method: 'POST', body: { displayName, reason } }),
+ setTeamLeaderOverride: (id, body) =>
+ req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }),
+ clearTeamLeaderOverride: (id, memberKey) =>
+ req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }),
+ teamReviewQueue: () => req('/admin/teams/review'),
+ teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`),
+ decideTeamRequest: (id, status, note) =>
+ req(`/admin/teams/requests/${id}/decide`, { method: 'POST', body: { status, note } }),
+
// ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'),
modRecent: (params = {}) => {
diff --git a/client/src/lib/teamAdmin.js b/client/src/lib/teamAdmin.js
new file mode 100644
index 0000000..7c3c34f
--- /dev/null
+++ b/client/src/lib/teamAdmin.js
@@ -0,0 +1,140 @@
+// What Admin → Teams SAYS, separated from how it renders (docs/website/TEAMS.md
+// §2.4, §2.8, §2.9).
+//
+// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth
+// splitting here specifically: this screen's job is to tell an operator the
+// difference between "the shard has no Teams" and "core has not been able to ask
+// for two hours", and those two produce almost the same page. Getting that
+// wording right is logic, not markup.
+
+/** Tones the screen uses. Names, not colours — the view maps them. */
+export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' }
+
+/**
+ * How to describe the projection's freshness.
+ *
+ * The four states are genuinely different and an operator needs to tell them
+ * apart:
+ *
+ * - no provider registered — nothing to sync, and not a fault;
+ * - never synced — core has an empty projection it has never confirmed, which
+ * must NOT read as "there are no Teams";
+ * - stale — the projection is real but old, and the reason is usually in
+ * `lastError`;
+ * - current.
+ */
+export function freshnessOf(sync = {}) {
+ if (!sync.configured) {
+ return { tone: TONE.idle, label: 'No Team provider', detail: 'No installed module supplies Teams.' }
+ }
+ if (!sync.lastSyncAt) {
+ return {
+ tone: TONE.bad,
+ label: 'Never synced',
+ detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.',
+ }
+ }
+ if (sync.stale) {
+ return {
+ tone: TONE.warn,
+ label: 'Stale',
+ detail: `Last confirmed ${ago(sync.lastSyncAt)}. Rosters below may be out of date.`,
+ }
+ }
+ return { tone: TONE.ok, label: 'Current', detail: `Last confirmed ${ago(sync.lastSyncAt)}.` }
+}
+
+/**
+ * A short, human age. Deliberately coarse: this exists so a sentence reads
+ * "confirmed 14 minutes ago", and second-level precision would be false comfort
+ * about a projection whose interval is fifteen minutes.
+ */
+export function ago(value) {
+ if (!value) return 'never'
+ const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000))
+ if (seconds < 90) return 'just now'
+ const minutes = Math.round(seconds / 60)
+ if (minutes < 60) return `${minutes} minutes ago`
+ const hours = Math.round(minutes / 60)
+ if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
+ return `${Math.round(hours / 24)} days ago`
+}
+
+/** The status pill for one Team row. */
+export function statusOf(team = {}) {
+ if (team.status === 'archived') {
+ return { tone: TONE.idle, label: team.archivedReason === 'renamed' ? 'Renamed' : 'Archived' }
+ }
+ if (team.hidden && team.hiddenReason === 'reserved_name') {
+ return { tone: TONE.bad, label: 'Hidden — reserved name' }
+ }
+ if (team.hidden) return { tone: TONE.warn, label: 'Hidden by staff' }
+ return { tone: TONE.ok, label: 'Public' }
+}
+
+/**
+ * What a staff member is told will happen when they press the button.
+ *
+ * The gate is decided server-side from the caller's live role, so this only
+ * describes it. Saying "Request" to a moderator and "Apply" to an admin is what
+ * stops the pending result being a surprise.
+ */
+export function gateLabelFor(role, verb) {
+ return role === 'admin' ? verb : `Request ${verb.toLowerCase()}`
+}
+
+/** The three gated actions, for the note under the buttons. */
+export const GATED_NOTE =
+ 'Publishing a game-written name needs an admin: a moderator’s un-hide or display-name change '
+ + 'is filed for approval. Hiding is not gated — suppression is always safe.'
+
+/** A one-line description of a queued request, for the approval queue. */
+export function describeRequest(request = {}) {
+ const payload = parsePayload(request.payload)
+ const who = request.requested_username || 'a deleted user'
+ switch (request.action) {
+ case 'unhide':
+ return `${who} asks to publish “${request.team_name}”`
+ case 'display_name_override':
+ return `${who} asks to display “${request.team_name}” as “${payload.displayName || ''}”`
+ case 'clear_display_name_override':
+ return `${who} asks to clear the display name on “${request.team_name}”`
+ default:
+ return `${who} asks for “${request.action}” on “${request.team_name}”`
+ }
+}
+
+/**
+ * The payload may arrive parsed or as a JSON string depending on the driver, so
+ * this normalises rather than assuming either. The server has the same note.
+ */
+export function parsePayload(payload) {
+ if (payload == null) return {}
+ if (typeof payload === 'object') return payload
+ try {
+ return JSON.parse(payload)
+ } catch {
+ return {}
+ }
+}
+
+/**
+ * How a member's leadership should read.
+ *
+ * An override is shown AS an override rather than folded into the answer: staff
+ * looking at a roster need to see that a decision was made, not a fact that looks
+ * like the game's.
+ */
+export function leadershipOf(member = {}) {
+ if (!member.leaderOverride) {
+ return { isLeader: Boolean(member.isLeader), overridden: false, note: null }
+ }
+ const granted = member.leaderOverride.effect === 'grant'
+ return {
+ isLeader: granted,
+ overridden: true,
+ note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}`
+ + `${member.leaderOverride.reason ? ` — ${member.leaderOverride.reason}` : ''}`
+ + ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`,
+ }
+}
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index c7b9136..7c3313d 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -76,6 +76,12 @@ export const NAV = [
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
+ // Moderation rather than System: the screen's daily job is the
+ // reserved-name review queue, which is moderator work. The three actions
+ // that publish a game-written name are gated to admins server-side, so a
+ // moderator reaching this screen is correct — what they do here is file a
+ // request (TEAMS.md §2.9).
+ { to: '/admin/teams', label: 'Teams', icon: IconUsers, roles: ['admin', 'moderator'] },
],
},
{
diff --git a/client/src/routes/admin/views/TeamsAdmin.jsx b/client/src/routes/admin/views/TeamsAdmin.jsx
new file mode 100644
index 0000000..f55dbaf
--- /dev/null
+++ b/client/src/routes/admin/views/TeamsAdmin.jsx
@@ -0,0 +1,301 @@
+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'
+
+// 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.
+
+const TONE_COLOR = { ok: '#7fd0a4', warn: 'var(--accent)', bad: '#d98b84', idle: 'var(--muted)' }
+
+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. */}
+
+ These Teams are hidden from every public surface because their name matched a reserved term.
+ They work normally for their own members. {GATED_NOTE}
+
+ {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.'}
+