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

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