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 (

Notification bridge

{error &&

{error}

}
) } 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 (

Notification bridge

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.

{error &&

{error}

} {notice &&

{notice}

} {config.rows.length === 0 && !draft && (

Nothing configured — no Team events leave the site.

)} {config.rows.length > 0 && (
{config.rows.map((row) => ( ))}
Applies to Events Channel State
{appliesToLabel(row)} {isDefaultRow(row) && (default)} {row.events.length === 0 ? none : row.events.map(eventLabel).join(', ')} {row.channel_ref || unset} {row.enabled ? 'Enabled' : 'Disabled'} {row.members_ack && ( members-only destination confirmed {row.members_ack_username ? ` by ${row.members_ack_username}` : ''} )}
)} {!draft && (
{!hasDefault && ( )} {available.length > 0 && ( )}
)} {draft && (
Events to send {config.events.map((event) => ( ))} {draft.membersAck && (

You have confirmed this channel is restricted to the Team’s members.{' '}

)}
)} {dialog && (

Confirm the destination’s audience

{ACK_TEXT.map((line) => (

{line}

))}
)}
) }