feat(teams): the web surface — a notifications screen that did not exist

This is phase 6's first finding, and it changed the phase's shape.

TEAMS.md §6.3 says the per-Team mute list is surfaced "under the existing
notification settings screen". There was no such screen. `/auth/me/notifications/*`
was built for the Android app in M7 and had ZERO web consumers — a browser could
not see the stream catalog or its own subscriptions at all. That is tolerable
while push is the only sink, because push needs the app anyway. It is not
tolerable for email, whose entire argument is the web-only user who runs neither
the app nor Discord, so the sink and the screen to configure it had to ship
together.

`/account/notifications` carries all three: what to be told about, which Teams,
and whether any of it reaches a mailbox — in the order a user actually reasons
about them.

The mute toggle goes in a THIRD module-declared slot, above the roster, because
muting is an action ON the guild page while the feed and forum are content IN it.
It renders nothing for a viewer with no preference available, which is a privacy
property rather than a tidiness one: whether a preference EXISTS for a Team
answers "is this person in it", and the guild page is public.

`/unsubscribe/:token` is public and POSTs on mount — the link the user clicked was
a GET, and a GET that mutated would be triggered by every mail-client link scanner.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:35:08 -05:00
parent 2a56cbf22a
commit b458c1f46f
8 changed files with 563 additions and 0 deletions

View File

@@ -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