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
293 lines
12 KiB
JavaScript
293 lines
12 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
||
import { api } from '../../../api/client.js'
|
||
import {
|
||
eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent,
|
||
setChannel, needsAcknowledgement, membersOnlyIdsOf, availableTargets,
|
||
} from '../../../lib/teamIntegrations.js'
|
||
|
||
// The Team notification bridge (TEAMS.md §7.2, phase 8).
|
||
//
|
||
// Named for the TEAM concern rather than for Discord, and placed under Teams
|
||
// rather than in the Discord Bot panel, because phase 10 replaces "Discord" here
|
||
// with whatever the capability registry declares. What changes then should be
|
||
// what fills this panel, not where an operator goes to find it. Nothing below
|
||
// hardcodes the word except the heading the server sends as `platform`.
|
||
//
|
||
// **The checkbox in the dialog is not the gate.** The server refuses to enable a
|
||
// row carrying `team.forum.post` or `team.announcement` without the
|
||
// acknowledgement, 422, whether or not this dialog was ever rendered — the same
|
||
// division TeamForumSettings draws for image uploads. What is here is how the
|
||
// gate is PRESENTED: the sentence an operator agrees to, and the fact that
|
||
// agreeing is a deliberate act rather than a checkbox they tab past.
|
||
|
||
const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 }
|
||
const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' }
|
||
|
||
const ACK_TEXT = [
|
||
'Forum posts and announcements are visible only to a Team’s members. This site cannot see who can'
|
||
+ ' read a channel on another platform, so it cannot check that for you.',
|
||
'By enabling these events you confirm that the destination channel is restricted to the members of'
|
||
+ ' the Team whose posts it will carry.',
|
||
]
|
||
|
||
export default function TeamIntegrations() {
|
||
const [config, setConfig] = useState(null)
|
||
const [teams, setTeams] = useState([])
|
||
const [draft, setDraft] = useState(null)
|
||
const [dialog, setDialog] = useState(null)
|
||
const [error, setError] = useState('')
|
||
const [notice, setNotice] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
|
||
const load = useCallback(async () => {
|
||
setError('')
|
||
try {
|
||
const [cfg, teamList] = await Promise.all([api.admin.teamIntegrations(), api.admin.listTeams()])
|
||
setConfig(cfg)
|
||
setTeams((teamList.teams || []).filter((t) => t.status === 'active'))
|
||
} catch (err) {
|
||
// A moderator never reaches this panel — the admin nav does not render it —
|
||
// so a 403 here means the role changed underneath an open tab rather than a
|
||
// routing mistake, and saying so beats "could not load".
|
||
setError(err.status === 403 ? 'Only an admin can configure the notification bridge.' : (err.message || 'Could not load the bridge configuration.'))
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => { load() }, [load])
|
||
|
||
if (!config) {
|
||
return (
|
||
<section className="panel" style={PANEL}>
|
||
<h2 className="display" style={HEADING}>Notification bridge</h2>
|
||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
const membersOnlyIds = membersOnlyIdsOf(config.events)
|
||
const { hasDefault, teams: available } = availableTargets(config.rows, teams)
|
||
|
||
async function persist(next) {
|
||
setBusy(true)
|
||
setError('')
|
||
setNotice('')
|
||
try {
|
||
await api.admin.saveTeamIntegration({
|
||
teamId: next.teamId,
|
||
events: next.events,
|
||
channelRef: next.channelRef.trim() || null,
|
||
enabled: next.enabled,
|
||
membersAck: next.membersAck,
|
||
})
|
||
setDraft(null)
|
||
setDialog(null)
|
||
setNotice('Saved.')
|
||
await load()
|
||
} catch (err) {
|
||
setError(err.message || 'Could not save.')
|
||
setDialog(null)
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
// Enabling members-only events without a standing acknowledgement asks first.
|
||
// Everything else — disabling, editing a channel, adding a roster event — saves
|
||
// straight through.
|
||
function save() {
|
||
if (!draft) return
|
||
if (needsAcknowledgement(draft, membersOnlyIds)) {
|
||
setDialog(draft)
|
||
return
|
||
}
|
||
persist(draft)
|
||
}
|
||
|
||
async function remove(row) {
|
||
setBusy(true)
|
||
setError('')
|
||
try {
|
||
await api.admin.deleteTeamIntegration(row.team_id ?? null)
|
||
setNotice('Removed.')
|
||
await load()
|
||
} catch (err) {
|
||
setError(err.message || 'Could not remove.')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="panel" style={PANEL}>
|
||
<h2 className="display" style={HEADING}>Notification bridge</h2>
|
||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
|
||
Send Team notifications to a {config.platform} channel. Set a default that every Team uses, and
|
||
override it for individual Teams. A message is sent once and not retried — the bridge is a
|
||
courtesy, and nothing on the site depends on it arriving.
|
||
</p>
|
||
|
||
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
|
||
{notice && <p className="sans" style={{ color: '#7fd0a4', fontSize: '0.82rem' }}>{notice}</p>}
|
||
|
||
{config.rows.length === 0 && !draft && (
|
||
<p className="sans dim" style={{ fontSize: '0.8rem' }}>Nothing configured — no Team events leave the site.</p>
|
||
)}
|
||
|
||
{config.rows.length > 0 && (
|
||
<div className="panel-flat" style={{ overflowX: 'auto' }}>
|
||
<table className="adm-table">
|
||
<thead>
|
||
<tr>
|
||
<th className="adm-th">Applies to</th>
|
||
<th className="adm-th">Events</th>
|
||
<th className="adm-th">Channel</th>
|
||
<th className="adm-th">State</th>
|
||
<th className="adm-th" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{config.rows.map((row) => (
|
||
<tr key={rowKey(row)}>
|
||
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
||
{appliesToLabel(row)}
|
||
{isDefaultRow(row) && <span className="dim"> (default)</span>}
|
||
</td>
|
||
<td className="adm-td">
|
||
{row.events.length === 0
|
||
? <span className="dim">none</span>
|
||
: row.events.map(eventLabel).join(', ')}
|
||
</td>
|
||
<td className="adm-td dim">{row.channel_ref || <span className="dim">unset</span>}</td>
|
||
<td className="adm-td">
|
||
{row.enabled ? 'Enabled' : 'Disabled'}
|
||
{row.members_ack && (
|
||
<span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
|
||
members-only destination confirmed
|
||
{row.members_ack_username ? ` by ${row.members_ack_username}` : ''}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button>
|
||
<button type="button" className="btn btn-ghost btn-sq" style={{ marginLeft: 8 }} disabled={busy} onClick={() => remove(row)}>Remove</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{!draft && (
|
||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14 }}>
|
||
{!hasDefault && (
|
||
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft(blankDraft(null))}>
|
||
Set a default for all Teams
|
||
</button>
|
||
)}
|
||
{available.length > 0 && (
|
||
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft(blankDraft(available[0].id))}>
|
||
Add a per-Team override
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{draft && (
|
||
<div style={{ marginTop: 18, borderTop: '1px solid var(--line-soft)', paddingTop: 16 }}>
|
||
<label style={{ display: 'block', marginBottom: 12 }}>
|
||
<span className="field-label">Applies to</span>
|
||
<select
|
||
className="select"
|
||
value={draft.teamId === null ? 'default' : String(draft.teamId)}
|
||
onChange={(e) => setDraft({ ...draft, teamId: e.target.value === 'default' ? null : Number(e.target.value) })}
|
||
>
|
||
<option value="default">All Teams (default)</option>
|
||
{teams.map((t) => (
|
||
<option key={t.id} value={t.id}>{t.display_name_override || t.name}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
|
||
<span className="field-label">Events to send</span>
|
||
{config.events.map((event) => (
|
||
<label key={event.id} style={{ display: 'block', marginTop: 6 }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={draft.events.includes(event.id)}
|
||
onChange={() => setDraft((d) => toggleEvent(d, event.id))}
|
||
style={{ marginRight: 8 }}
|
||
/>
|
||
<span className="sans" style={{ fontSize: '0.82rem' }}>{eventLabel(event.id)}</span>
|
||
{event.membersOnly && (
|
||
<span className="dim sans" style={{ fontSize: '0.72rem', marginLeft: 8 }}>members-only content</span>
|
||
)}
|
||
</label>
|
||
))}
|
||
|
||
<label style={{ display: 'block', marginTop: 14 }}>
|
||
<span className="field-label">Channel id</span>
|
||
<input
|
||
className="input"
|
||
value={draft.channelRef}
|
||
// Changing the channel drops a standing acknowledgement in the SAME
|
||
// place the server does. Leaving the tick showing while the server
|
||
// has already decided to clear it would let an operator repoint a row
|
||
// at a public channel and believe the confirmation still covered it.
|
||
onChange={(e) => setDraft((d) => setChannel(d, e.target.value))}
|
||
placeholder="1024839201048392010"
|
||
style={{ maxWidth: 280 }}
|
||
/>
|
||
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||
Right-click a channel in {config.platform} and copy its id. Changing it asks you to confirm
|
||
the new channel’s audience again.
|
||
</span>
|
||
</label>
|
||
|
||
<label style={{ display: 'block', marginTop: 14 }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={draft.enabled}
|
||
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
|
||
style={{ marginRight: 8 }}
|
||
/>
|
||
<span className="field-label" style={{ display: 'inline' }}>Enabled</span>
|
||
</label>
|
||
|
||
{draft.membersAck && (
|
||
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 10 }}>
|
||
You have confirmed this channel is restricted to the Team’s members.{' '}
|
||
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft({ ...draft, membersAck: false })}>
|
||
Withdraw
|
||
</button>
|
||
</p>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
|
||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={save}>Save</button>
|
||
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => { setDraft(null); setError('') }}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{dialog && (
|
||
<div style={{ marginTop: 18, border: '1px solid #e0b070', padding: 16, borderRadius: 'var(--radius-input)' }}>
|
||
<h3 className="display" style={{ fontSize: '0.95rem', marginTop: 0 }}>Confirm the destination’s audience</h3>
|
||
{ACK_TEXT.map((line) => (
|
||
<p key={line} className="sans" style={{ fontSize: '0.8rem' }}>{line}</p>
|
||
))}
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary btn-sq"
|
||
disabled={busy}
|
||
onClick={() => persist({ ...dialog, membersAck: true })}
|
||
>
|
||
I confirm the channel is members-only
|
||
</button>
|
||
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDialog(null)}>Cancel</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|