feat(teams): the Team read API, the moderation routes, and Admin -> Teams
The eighteen routes of docs/website/TEAMS.md §2.11, their OpenAPI annotations,
and the staff screen that drives them.
Two rules shape the read model. Hidden means absent from every public surface --
the index, the lookup and the roster alike, and a hidden Team 404s
indistinguishably from one that does not exist, because "absent" includes not
confirming it is there. And staleness is surfaced rather than silent: every
public payload carries { configured, stale, lastSyncAt }, so a page can say how
recently the projection was confirmed instead of presenting stale data as
current.
The public roster withholds both the member key and the user id -- one is a
game-internal identifier, the other names a site account. `linked` answers the
only question a public page has without publishing which account. The module's
per-audience field projection is phase 3's; this is a conservative core one.
The §2.9 gate is enforced per REQUEST, not per route. A moderator may call all
eighteen; three of them mean something different when they do, and the server
decides from the role it re-validates on every request rather than from a token
claim. The client has no "file as request" argument to get wrong.
Found by booting the real server against the real database, and not by any test:
**the index and the by-slug lookup disagreed about what exists.** listPublic was
keyed on a registered team provider while findBySlug is not, so with no module
installed `/teams` returned an empty list while `/teams/:slug/members` served a
full roster -- the index denying a Team that direct URLs answered for in full.
The rows are core's and they outlive the module that filled them: an uninstalled
module leaves a projection that is unmaintained, not one that stopped existing,
and `configured: false` is how a client learns that. The read side no longer
takes the provider into account at all. There is now a test named for the
property.
Also verified live: the public routes answer anonymously, an unknown and a hidden
slug both 404, the player and admin tiers 401 an anonymous caller, a seeded
roster projects correctly, and the reconciler logs that it is staying idle with
no provider registered rather than failing a boot.
Process obligations, all done: #swagger.* annotations on every route, `npm run
swagger` regenerated (18 paths in the spec, no dangling $refs, and the schemas
they reference added), `npm run routes:manifest` regenerated -- additions only,
184 public routes -- and BACKEND_DESIGN.md updated across the schema section and
all three tier tables.
Admin -> Teams follows the ModulesAdmin precedent: everything that decides what a
row SAYS lives in lib/teamAdmin.js, which is plain JS with tests, and the view
renders it. That split earns itself here specifically -- the screen's job is to
make "the shard has no Teams" and "core has not been able to ask for two hours"
impossible to confuse, and those two produce the same empty table. The four
freshness states are named and tested for exactly that reason, and the last
provider error is shown verbatim rather than paraphrased.
The button labels follow the caller's role: a moderator sees "Request publish",
so the pending result is not a surprise. Hiding is offered to everyone with no
gate, matching the server.
Server 894 passed, client 206 passed, client build clean. 17 route tests, 20
client display tests.
Refs docs/website/TEAMS.md §2.11, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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. */}
|
||||
<Route path="modules" element={<ModulesAdmin />} />
|
||||
{/* 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). */}
|
||||
<Route path="teams" element={<TeamsAdmin />} />
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||
RequireAuth + AdminLayout. A module cannot supply its own auth
|
||||
|
||||
@@ -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 = {}) => {
|
||||
|
||||
140
client/src/lib/teamAdmin.js
Normal file
140
client/src/lib/teamAdmin.js
Normal file
@@ -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'})`,
|
||||
}
|
||||
}
|
||||
@@ -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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
301
client/src/routes/admin/views/TeamsAdmin.jsx
Normal file
301
client/src/routes/admin/views/TeamsAdmin.jsx
Normal file
@@ -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 (
|
||||
<span
|
||||
className="badge"
|
||||
style={{ color: TONE_COLOR[tone] || 'var(--muted)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sync state ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SyncPanel({ sync, syncState, onResync, busy }) {
|
||||
const freshness = freshnessOf(sync)
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '.75rem', flexWrap: 'wrap' }}>
|
||||
<h2 style={{ margin: 0 }}>Sync</h2>
|
||||
<Pill tone={freshness.tone}>{freshness.label}</Pill>
|
||||
<button type="button" className="btn" onClick={onResync} disabled={busy || !sync.configured}>
|
||||
{busy ? 'Resyncing…' : 'Resync now'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: '.5rem' }}>{freshness.detail}</p>
|
||||
|
||||
{syncState && (
|
||||
<dl className="kv" style={{ marginTop: '.75rem' }}>
|
||||
<dt>Module</dt><dd>{syncState.moduleId}</dd>
|
||||
<dt>Last attempt</dt><dd>{dateTime(syncState.lastAttemptAt) || 'never'}</dd>
|
||||
<dt>Last success</dt><dd>{dateTime(syncState.lastSuccessAt) || 'never'}</dd>
|
||||
<dt>Consecutive failures</dt><dd>{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>Last error</dt>
|
||||
<dd style={{ color: TONE_COLOR.bad }}>{syncState.lastError}</dd>
|
||||
</>
|
||||
)}
|
||||
{syncState.pendingEmptySince && (
|
||||
<>
|
||||
<dt>Empty answer held</dt>
|
||||
<dd>
|
||||
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={{ marginBottom: '1.5rem' }}>
|
||||
<h2>Names to review</h2>
|
||||
<p className="muted">
|
||||
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>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Matched</th><th>Members</th><th>Created</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.name}</td>
|
||||
<td><Pill tone="bad">{row.hidden_term}</Pill></td>
|
||||
<td>{row.member_count}</td>
|
||||
<td>{dateTime(row.created_at)}</td>
|
||||
<td>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => onAct(row.id, 'unhide')}>
|
||||
{gateLabelFor(role, 'Publish')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The approval queue ─────────────────────────────────────────────────────
|
||||
|
||||
function RequestQueue({ rows, role, onDecide, busy }) {
|
||||
if (!rows.length) return null
|
||||
const canDecide = role === 'admin'
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: '1.5rem' }}>
|
||||
<h2>Awaiting approval</h2>
|
||||
<p className="muted">
|
||||
{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>
|
||||
<ul className="list">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id} style={{ display: 'flex', gap: '.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span>{describeRequest(row)}</span>
|
||||
<span className="muted">{dateTime(row.requested_at)}</span>
|
||||
{row.reason && <span className="muted">“{row.reason}”</span>}
|
||||
{canDecide && (
|
||||
<>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'approved')}>
|
||||
Approve
|
||||
</button>
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'rejected')}>
|
||||
Reject
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── One Team ───────────────────────────────────────────────────────────────
|
||||
|
||||
function TeamRow({ team, role, onAct, busy }) {
|
||||
const status = statusOf(team)
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
{team.displayName}
|
||||
{team.displayNameOverride && (
|
||||
<div className="muted" style={{ fontSize: '.85em' }}>
|
||||
shown instead of “{team.name}”
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td><Pill tone={status.tone}>{status.label}</Pill></td>
|
||||
<td>{team.memberCount}</td>
|
||||
<td>{team.linkedCount}</td>
|
||||
<td>{team.onlineCount}</td>
|
||||
<td className="muted">{dateTime(team.rosterSyncedAt) || 'never'}</td>
|
||||
<td>
|
||||
{team.status === 'active' && (team.hidden
|
||||
? (
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => onAct(team.id, 'unhide')}>
|
||||
{gateLabelFor(role, 'Publish')}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<button type="button" className="btn" disabled={busy} onClick={() => onAct(team.id, 'hide')}>
|
||||
Hide
|
||||
</button>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 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>
|
||||
<h1>Teams</h1>
|
||||
{error && <ErrorState message={error} />}
|
||||
{notice && <p className="notice">{notice}</p>}
|
||||
|
||||
<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">
|
||||
<h2>All Teams</h2>
|
||||
{!data.teams.length && (
|
||||
<p className="muted">
|
||||
{data.configured
|
||||
? 'No Teams in the projection yet.'
|
||||
: 'No installed module supplies Teams, so there is nothing to show.'}
|
||||
</p>
|
||||
)}
|
||||
{data.teams.length > 0 && (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Status</th><th>Members</th><th>Linked</th><th>Online</th>
|
||||
<th>Roster confirmed</th><th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.teams.map((team) => (
|
||||
<TeamRow key={team.id} team={team} role={role} onAct={act} busy={busy} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { leadershipOf }
|
||||
Reference in New Issue
Block a user