feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161
@@ -57,6 +57,8 @@ import ResetPassword from './routes/player/ResetPassword.jsx'
|
||||
import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
||||
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
|
||||
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||
import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
|
||||
import Unsubscribe from './routes/player/Unsubscribe.jsx'
|
||||
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
||||
|
||||
export default function App() {
|
||||
@@ -204,6 +206,10 @@ export default function App() {
|
||||
<Route path="/account/forgot" element={<ForgotPassword />} />
|
||||
<Route path="/account/reset/:token" element={<ResetPassword />} />
|
||||
<Route path="/invite/:token" element={<AcceptInvite />} />
|
||||
{/* PUBLIC, and grouped with the other tokened landings above rather
|
||||
than with the portal below: the person following an unsubscribe
|
||||
link is reading their mail, not signed in (TEAMS.md §6.4). */}
|
||||
<Route path="/unsubscribe/:token" element={<Unsubscribe />} />
|
||||
<Route
|
||||
element={
|
||||
<RequirePlayer>
|
||||
@@ -218,6 +224,7 @@ export default function App() {
|
||||
<Route path="/player" element={<PlayerIndex />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
<Route path="/account/notifications" element={<PlayerNotifications />} />
|
||||
{/* Installed modules' player-portal pages, at /player/<id>/…. This
|
||||
group's own routes are absolute (its layout route has no path),
|
||||
so the prefix is written here rather than inherited — the one
|
||||
|
||||
@@ -187,6 +187,26 @@ export const api = {
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
|
||||
teamGrantRevoke: (slug, userId) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- notifications (TEAMS.md Part 6) -----
|
||||
//
|
||||
// Under /auth/me rather than /player: these are role-agnostic self-service, the
|
||||
// same rule that put the forum under /player rather than behind a staff gate.
|
||||
// The streams catalog and the per-stream subscriptions were built for the app
|
||||
// and had no web consumer at all until phase 6 gave them one.
|
||||
notificationStreams: () => req('/auth/me/notifications/streams'),
|
||||
notificationSubscriptions: () => req('/auth/me/notifications/subscriptions'),
|
||||
// `streams` is always sent, empty array included — the endpoint requires the
|
||||
// field, so clearing the last subscription must not become an absent key.
|
||||
setNotificationSubscriptions: (streams) =>
|
||||
req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
|
||||
teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
|
||||
setTeamNotificationPrefs: (teams) =>
|
||||
req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
|
||||
// Unauthenticated, and the one write in the public tier: the caller is reading
|
||||
// their mail, not signed in. Always resolves 200 whatever the token was.
|
||||
unsubscribeTeam: (token) =>
|
||||
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
|
||||
wikiTags: () => req('/public/wiki/tags'),
|
||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||
|
||||
@@ -6,6 +6,7 @@ import { publishSharedDependencies } from './modules/shared.js'
|
||||
import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js'
|
||||
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
|
||||
import TeamForumPanel from './modules/TeamForumPanel.jsx'
|
||||
import TeamNotifyToggle from './modules/TeamNotifyToggle.jsx'
|
||||
import './styles/theme.css'
|
||||
|
||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||
@@ -84,6 +85,12 @@ fillModuleSlot('uo.guild.detail', TeamActivityFeed)
|
||||
// exactly as before.
|
||||
fillModuleSlot('uo.guild.forum', TeamForumPanel)
|
||||
|
||||
// And the notification control, in a third place the module declares ABOVE its
|
||||
// roster. A third slot rather than a corner of the feed for the same reason there
|
||||
// were two: this is an action on the page and the other two are content in it,
|
||||
// and only the module can say where each belongs on a page it owns.
|
||||
fillModuleSlot('uo.guild.header', TeamNotifyToggle)
|
||||
|
||||
// Render on DOMContentLoaded rather than immediately, and that is the one line
|
||||
// of core's boot the module system changes.
|
||||
//
|
||||
|
||||
102
client/src/modules/TeamNotifyToggle.jsx
Normal file
102
client/src/modules/TeamNotifyToggle.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
|
||||
// Core's per-Team notification control, rendered into a THIRD slot a module
|
||||
// declares (TEAMS.md §6.3, phase 6).
|
||||
//
|
||||
// **Why this is a slot at all, and why it is the third one.** Teams have no core
|
||||
// page — the module that owns the vocabulary owns the page — so a control that
|
||||
// acts on one Team has nowhere of core's to live. The feed and the forum go below
|
||||
// the module's roster; this goes above it, because muting a guild is an action ON
|
||||
// the page rather than more content in it, and that is exactly the placement
|
||||
// decision a module cannot make if core stacks everything into one fill.
|
||||
//
|
||||
// **It renders nothing for a viewer who is not in the Team**, including anonymous
|
||||
// ones, and that is a privacy property rather than a tidiness one: whether a
|
||||
// notification preference EXISTS for a Team answers "is this person in it", and
|
||||
// the guild page is public. The server decides — the preference list only contains
|
||||
// Teams the caller may be notified about — and this file never infers membership
|
||||
// from anything it can see on the page.
|
||||
//
|
||||
// **Muting is per-Team and covers all four streams.** The per-stream on/off lives
|
||||
// on the account screen, where the catalog does; the thing that could not be
|
||||
// expressed before phase 6 is "I am in five Teams and want notifications from
|
||||
// one", and that is the only question this control asks.
|
||||
|
||||
export default function TeamNotifyToggle({ externalId, moduleId }) {
|
||||
const { user } = useAuth()
|
||||
const [state, setState] = useState({ loading: true, team: null, pref: null })
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// Anonymous viewers never fetch. The endpoint would 401 harmlessly, but a
|
||||
// guild page rendering a public roster should not put an authenticated
|
||||
// request on the wire for every visitor.
|
||||
if (!user) return setState({ loading: false, team: null, pref: null })
|
||||
try {
|
||||
const team = await api.teamByExternalId(moduleId, externalId)
|
||||
const { teams } = await api.teamNotificationPrefs()
|
||||
const pref = (teams || []).find((t) => t.teamId === team.id) || null
|
||||
setState({ loading: false, team, pref })
|
||||
} catch {
|
||||
// Same rule as the feed and the forum: this is core's content on a page
|
||||
// core does not own, so a failure renders nothing rather than putting an
|
||||
// error box on somebody else's surface.
|
||||
setState({ loading: false, team: null, pref: null })
|
||||
}
|
||||
}, [externalId, moduleId, user])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const { loading, pref } = state
|
||||
if (loading || !pref) return null
|
||||
|
||||
async function toggle() {
|
||||
setBusy(true)
|
||||
// Optimistic, and reconciled from the server's echo rather than assumed: a
|
||||
// PUT that silently dropped the entry (a Team left in another tab) must not
|
||||
// leave the control claiming a state the server does not hold.
|
||||
const next = { ...pref, muted: !pref.muted }
|
||||
setState((s) => ({ ...s, pref: next }))
|
||||
try {
|
||||
const { teams } = await api.setTeamNotificationPrefs([
|
||||
{ teamId: pref.teamId, muted: next.muted, emailMode: pref.emailMode },
|
||||
])
|
||||
const echoed = (teams || []).find((t) => t.teamId === pref.teamId)
|
||||
if (echoed) setState((s) => ({ ...s, pref: echoed }))
|
||||
} catch {
|
||||
setState((s) => ({ ...s, pref }))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
flexWrap: 'wrap',
|
||||
margin: '10px 0 0',
|
||||
fontSize: '0.84rem',
|
||||
}}
|
||||
>
|
||||
<button type="button" onClick={toggle} disabled={busy} className="btn btn-sq">
|
||||
{pref.muted ? 'Unmute notifications' : 'Mute notifications'}
|
||||
</button>
|
||||
<span className="dim">
|
||||
{pref.muted
|
||||
? 'You get no notifications about this team.'
|
||||
: 'You get notifications about this team.'}
|
||||
</span>
|
||||
{/* The one link off this control, because "mute" is a blunt answer to a
|
||||
question the account screen asks properly — which streams, and whether
|
||||
email is on at all. */}
|
||||
<Link to="/account/notifications" className="dim">All notification settings</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
268
client/src/routes/player/PlayerNotifications.jsx
Normal file
268
client/src/routes/player/PlayerNotifications.jsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6).
|
||||
//
|
||||
// **This screen did not exist before phase 6, and that was the phase's first
|
||||
// finding.** §6.3 says the per-Team mute list is "surfaced under the existing
|
||||
// notification settings screen" — there was no such screen on the web. The stream
|
||||
// catalog and the per-stream subscriptions have been built and shipped since M7,
|
||||
// with the Android app as their only consumer; a browser could not see them at
|
||||
// all. That is tolerable for push, which needs the app anyway. It is not tolerable
|
||||
// for email, whose whole reason for existing (§6.4) is the web-only user who runs
|
||||
// neither the app nor Discord — so the sink and the screen to configure it had to
|
||||
// arrive together.
|
||||
//
|
||||
// Three blocks, in the order a user actually reasons about them: what kinds of
|
||||
// thing to be told about, then which Teams, then whether any of it should reach a
|
||||
// mailbox.
|
||||
|
||||
const EMAIL_MODES = [
|
||||
{ value: 'off', label: 'No email' },
|
||||
{ value: 'digest', label: 'Daily digest' },
|
||||
{ value: 'immediate', label: 'Every post' },
|
||||
]
|
||||
|
||||
// Streams whose scoping lives in this page's second block rather than in the
|
||||
// first. Shown as a group so a user does not toggle `team.forum.post` off site-
|
||||
// wide when what they meant was "not this one guild".
|
||||
const isTeamStream = (id) => String(id).startsWith('team.')
|
||||
|
||||
function Section({ title, hint, children }) {
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 26, marginTop: 26 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: 'var(--head)' }}>{title}</h2>
|
||||
{hint && <p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.86rem' }}>{hint}</p>}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Note({ msg, error }) {
|
||||
if (!msg && !error) return null
|
||||
return (
|
||||
<p className="sans" style={{ margin: '10px 0 0', color: error ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}>
|
||||
{error || msg}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ── What to be told about ──────────────────────────────────────────────────
|
||||
|
||||
function Streams({ streams, subscribed, onSave, busy, msg, error }) {
|
||||
const [set, setSet] = useState(() => new Set(subscribed))
|
||||
useEffect(() => { setSet(new Set(subscribed)) }, [subscribed])
|
||||
|
||||
const toggle = (id) => {
|
||||
const next = new Set(set)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
setSet(next)
|
||||
}
|
||||
|
||||
const team = streams.filter((s) => isTeamStream(s.id))
|
||||
const rest = streams.filter((s) => !isTeamStream(s.id))
|
||||
|
||||
const row = (s) => (
|
||||
<label key={s.id} className="sans" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: '0.92rem' }}>
|
||||
<input type="checkbox" checked={set.has(s.id)} onChange={() => toggle(s.id)} style={{ marginTop: 3 }} />
|
||||
<span>
|
||||
<span style={{ color: 'var(--ink)' }}>{s.label}</span>
|
||||
{s.description && <span className="dim" style={{ display: 'block', fontSize: '0.82rem' }}>{s.description}</span>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="What to notify me about"
|
||||
hint="Applies to every device you have signed in on. Notifications are delivered to the app; the website itself does not pop anything up."
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 12 }}>{rest.map(row)}</div>
|
||||
{team.length > 0 && (
|
||||
<>
|
||||
<h3 className="sans dim" style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '20px 0 10px' }}>
|
||||
Teams
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gap: 12 }}>{team.map(row)}</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave([...set])}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<Note msg={msg} error={error} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Which Teams, and whether by email ──────────────────────────────────────
|
||||
|
||||
function Teams({ teams, onSave, busy, msg, error }) {
|
||||
const [rows, setRows] = useState(teams)
|
||||
useEffect(() => { setRows(teams) }, [teams])
|
||||
|
||||
const patch = (teamId, change) =>
|
||||
setRows((rs) => rs.map((r) => (r.teamId === teamId ? { ...r, ...change } : r)))
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Section title="Teams">
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem', margin: 0 }}>
|
||||
You are not in a team, and nobody has given you access to a team forum. There is nothing to
|
||||
configure here yet.
|
||||
</p>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Teams"
|
||||
hint="Muting a team silences all four team notifications for it, without changing anything for your other teams. Email is off until you turn it on."
|
||||
>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<th style={{ padding: '8px 10px' }}>Team</th>
|
||||
<th style={{ padding: '8px 10px' }}>Notifications</th>
|
||||
<th style={{ padding: '8px 10px' }}>Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((t) => (
|
||||
<tr key={t.teamId} style={{ borderTop: '1px solid var(--line-soft)' }}>
|
||||
<td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
|
||||
{t.name}
|
||||
{/* An archived Team is still listed when a preference exists for
|
||||
it, so a mute does not silently vanish when a guild disbands
|
||||
and reappear if it re-forms under the same name. */}
|
||||
{t.archived && <span className="dim" style={{ fontSize: '0.78rem' }}> · archived</span>}
|
||||
</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<label className="sans" style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: '0.88rem' }}>
|
||||
<input type="checkbox" checked={!t.muted} onChange={() => patch(t.teamId, { muted: !t.muted })} />
|
||||
<span className="dim">{t.muted ? 'Muted' : 'On'}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<select
|
||||
className="input"
|
||||
value={t.emailMode}
|
||||
onChange={(e) => patch(t.teamId, { emailMode: e.target.value })}
|
||||
style={{ fontSize: '0.88rem' }}
|
||||
>
|
||||
{EMAIL_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave(rows)}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<Note msg={msg} error={error} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PlayerNotifications() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [streams, setStreams] = useState([])
|
||||
const [subscribed, setSubscribed] = useState([])
|
||||
const [teams, setTeams] = useState([])
|
||||
const [saving, setSaving] = useState({ streams: false, teams: false })
|
||||
const [notes, setNotes] = useState({ streams: '', teams: '', streamsError: '', teamsError: '' })
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Three reads in parallel: the catalog is boot-fixed, the subscriptions and
|
||||
// the Team list are this user's. None depends on another.
|
||||
const [cat, subs, prefs] = await Promise.all([
|
||||
api.notificationStreams(),
|
||||
api.notificationSubscriptions(),
|
||||
api.teamNotificationPrefs(),
|
||||
])
|
||||
setStreams(cat.streams || [])
|
||||
setSubscribed(subs.streams || [])
|
||||
setTeams(prefs.teams || [])
|
||||
setError('')
|
||||
} catch {
|
||||
setError('Could not load your notification settings.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const saveStreams = useCallback(async (ids) => {
|
||||
setSaving((s) => ({ ...s, streams: true }))
|
||||
setNotes((n) => ({ ...n, streams: '', streamsError: '' }))
|
||||
try {
|
||||
const { streams: stored } = await api.setNotificationSubscriptions(ids)
|
||||
setSubscribed(stored || [])
|
||||
setNotes((n) => ({ ...n, streams: 'Saved.' }))
|
||||
} catch {
|
||||
setNotes((n) => ({ ...n, streamsError: 'Could not save that.' }))
|
||||
} finally {
|
||||
setSaving((s) => ({ ...s, streams: false }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveTeams = useCallback(async (rows) => {
|
||||
setSaving((s) => ({ ...s, teams: true }))
|
||||
setNotes((n) => ({ ...n, teams: '', teamsError: '' }))
|
||||
try {
|
||||
// The whole set, every time, and the array is sent even when empty — the
|
||||
// endpoint requires the field (docs/android/PLAN.md §11).
|
||||
const { teams: stored } = await api.setTeamNotificationPrefs(
|
||||
rows.map((t) => ({ teamId: t.teamId, muted: t.muted, emailMode: t.emailMode })),
|
||||
)
|
||||
setTeams(stored || [])
|
||||
setNotes((n) => ({ ...n, teams: 'Saved.' }))
|
||||
} catch {
|
||||
setNotes((n) => ({ ...n, teamsError: 'Could not save that.' }))
|
||||
} finally {
|
||||
setSaving((s) => ({ ...s, teams: false }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||
Choose what you are told about, and how. Nothing here is on by default except team
|
||||
notifications to the app, which you can mute per team below.
|
||||
</p>
|
||||
<Streams
|
||||
streams={streams}
|
||||
subscribed={subscribed}
|
||||
onSave={saveStreams}
|
||||
busy={saving.streams}
|
||||
msg={notes.streams}
|
||||
error={notes.streamsError}
|
||||
/>
|
||||
<Teams
|
||||
teams={teams}
|
||||
onSave={saveTeams}
|
||||
busy={saving.teams}
|
||||
msg={notes.teams}
|
||||
error={notes.teamsError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ function Icon({ children, size = 16 }) {
|
||||
}
|
||||
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
||||
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
|
||||
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
|
||||
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here;
|
||||
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
|
||||
@@ -47,6 +48,7 @@ const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-1
|
||||
// with `order: 0`.
|
||||
export const NAV = [
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account/notifications', label: 'Notifications', icon: IconBell },
|
||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||
]
|
||||
|
||||
@@ -56,6 +58,7 @@ export const NAV = [
|
||||
const TITLES = {
|
||||
'/account': 'Account',
|
||||
'/account/appeals': 'Appeals',
|
||||
'/account/notifications': 'Notifications',
|
||||
}
|
||||
|
||||
function moduleTitle(baseNav, pathname) {
|
||||
|
||||
69
client/src/routes/player/Unsubscribe.jsx
Normal file
69
client/src/routes/player/Unsubscribe.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// The landing page for the unsubscribe link in a Team notification email
|
||||
// (TEAMS.md §6.4).
|
||||
//
|
||||
// **Public, and it must be**: the person reading it is in their mail client, not
|
||||
// signed in, and an unsubscribe that first demands a login is one most people do
|
||||
// not complete. The token in the path is what stands in for the session.
|
||||
//
|
||||
// **The page POSTs; the link the user clicked was a GET.** A GET must not mutate —
|
||||
// mail clients and security scanners follow links in messages, and one that did
|
||||
// would silently mute Teams nobody asked to leave. So the link lands here, this
|
||||
// runs one POST, and the API route that shares the path answers GET with a
|
||||
// redirect to exactly this page.
|
||||
//
|
||||
// **It says the same thing whatever the token was.** A page that distinguished a
|
||||
// valid token from a forged one would be an oracle for which (user, Team) pairs
|
||||
// exist, on a surface with no session behind it. The server always answers 200 and
|
||||
// this always says the same sentence.
|
||||
|
||||
export default function Unsubscribe() {
|
||||
const { token } = useParams()
|
||||
const [state, setState] = useState('working')
|
||||
// React 18 StrictMode mounts an effect twice in development. The POST is
|
||||
// idempotent (it sets a boolean), so a second call is harmless — but it is
|
||||
// still a second request for no reason, and the guard keeps the network panel
|
||||
// honest for anyone debugging this page.
|
||||
const fired = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (fired.current) return
|
||||
fired.current = true
|
||||
api.unsubscribeTeam(token)
|
||||
.then(() => setState('done'))
|
||||
// A network failure is the ONE case worth distinguishing, because it is the
|
||||
// one where trying again helps. A rejected token is not: the server does not
|
||||
// tell us, deliberately.
|
||||
.catch(() => setState('failed'))
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website" shell="narrow">
|
||||
<PageHeader eyebrow="Notifications" title="Unsubscribe" />
|
||||
{state === 'working' && <p className="sans dim">One moment…</p>}
|
||||
{state === 'done' && (
|
||||
<>
|
||||
<p className="sans" style={{ color: 'var(--ink)' }}>
|
||||
You will not receive further notification emails about this team.
|
||||
</p>
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
|
||||
This muted the team rather than switching off your account’s email, so your other
|
||||
teams are unaffected. You can turn it back on any time under{' '}
|
||||
<Link to="/account/notifications">notification settings</Link>.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{state === 'failed' && (
|
||||
<p className="sans" style={{ color: 'var(--ink)' }}>
|
||||
We could not reach the site to record that. Please try the link again, or change the
|
||||
setting yourself under <Link to="/account/notifications">notification settings</Link>.
|
||||
</p>
|
||||
)}
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
87
client/test/teamNotify.test.js
Normal file
87
client/test/teamNotify.test.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { api } from '../src/api/client.js'
|
||||
|
||||
// The client half of Team notifications (docs/website/TEAMS.md Part 6, phase 6).
|
||||
//
|
||||
// There is no DOM in this runner, so what is asserted here is the WIRE — which is
|
||||
// where this feature's client-side mistakes actually live. Two of them have
|
||||
// already been made once in this repo and are recorded rather than re-derived:
|
||||
//
|
||||
// 1. **A PUT-the-whole-set body must always carry its array**, empty included.
|
||||
// `docs/android/PLAN.md` §11: a DTO field with a default is dropped by
|
||||
// kotlinx when it equals that default, so "clear the last entry" arrives as a
|
||||
// body with no array at all and 400s. The web client has no such
|
||||
// serialisation quirk, but it shares the endpoint's contract, and a test that
|
||||
// pins the shape here is what keeps the two clients honest about the same
|
||||
// rule.
|
||||
// 2. **The unsubscribe call is a POST**, not the GET the link in the mail was.
|
||||
// A GET that mutated would be triggered by every mail-client link scanner.
|
||||
|
||||
let calls
|
||||
const realFetch = global.fetch
|
||||
|
||||
function reply(body = {}) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: async () => JSON.stringify(body),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = []
|
||||
global.fetch = async (url, opts = {}) => {
|
||||
calls.push({ url, opts })
|
||||
return reply({ teams: [], streams: [], ok: true })
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => { global.fetch = realFetch })
|
||||
|
||||
const body = (i = 0) => JSON.parse(calls[i].opts.body)
|
||||
|
||||
test('the per-Team preference endpoints sit under /auth/me, not /player', async () => {
|
||||
await api.teamNotificationPrefs()
|
||||
// Role-agnostic self-service, the same rule that put the Team forum under
|
||||
// /player rather than behind a staff gate: staff are a superset of players and
|
||||
// manage their own notifications like anyone else.
|
||||
assert.match(calls[0].url, /\/auth\/me\/notifications\/teams$/)
|
||||
assert.equal(calls[0].opts.method ?? 'GET', 'GET')
|
||||
})
|
||||
|
||||
test('saving preferences PUTs the whole set under a `teams` key', async () => {
|
||||
await api.setTeamNotificationPrefs([{ teamId: 3, muted: true, emailMode: 'digest' }])
|
||||
assert.equal(calls[0].opts.method, 'PUT')
|
||||
assert.deepEqual(body(), { teams: [{ teamId: 3, muted: true, emailMode: 'digest' }] })
|
||||
})
|
||||
|
||||
test('clearing every preference still sends the array, never an absent key', async () => {
|
||||
await api.setTeamNotificationPrefs([])
|
||||
assert.deepEqual(body(), { teams: [] })
|
||||
assert.equal('teams' in body(), true)
|
||||
})
|
||||
|
||||
test('the same rule holds for the stream subscriptions beside them', async () => {
|
||||
await api.setNotificationSubscriptions([])
|
||||
assert.deepEqual(body(), { streams: [] })
|
||||
})
|
||||
|
||||
test('unsubscribe is a POST to the public tier, with the token encoded into the path', async () => {
|
||||
await api.unsubscribeTeam('1.7.3.abcDEF')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
assert.match(calls[0].url, /\/public\/teams\/unsubscribe\/1\.7\.3\.abcDEF$/)
|
||||
})
|
||||
|
||||
test('a token with url-unsafe characters is encoded rather than pasted in', async () => {
|
||||
await api.unsubscribeTeam('a/b c')
|
||||
assert.match(calls[0].url, /unsubscribe\/a%2Fb%20c$/)
|
||||
})
|
||||
|
||||
test('the streams catalog and subscriptions are separate reads', async () => {
|
||||
await api.notificationStreams()
|
||||
await api.notificationSubscriptions()
|
||||
assert.match(calls[0].url, /\/notifications\/streams$/)
|
||||
assert.match(calls[1].url, /\/notifications\/subscriptions$/)
|
||||
})
|
||||
Reference in New Issue
Block a user