AI Implementation feature(913): Admin Area Players Management 1.00 (#57)
This commit was merged in pull request #57.
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
---
|
||||
type: guide
|
||||
title: Cross-Tab Role Change Sync
|
||||
description: How an admin changing a user's role (or their own role) at /admin/players propagates the new role to every other open tab of the SPA, plus the LAST_ADMIN invariant that protects concurrent demotion.
|
||||
tags: [guide, auth, session, role, broadcast-channel, storage-event, admin, last-admin, tester]
|
||||
timestamp: 2026-07-23T08:42:24Z
|
||||
---
|
||||
|
||||
# What this feature does
|
||||
|
||||
The [Cross-Tab Invalidation](/guides/cross-tab-invalidation.md) guide
|
||||
describes how a logout in one tab instantly clears every other open tab.
|
||||
Feature 913 adds a parallel, **non-destructive** channel: when an admin
|
||||
changes any user's role (admin ↔ player) at `/admin/players`, every other
|
||||
open tab of the same browser profile receives the change and updates its
|
||||
in-memory `CurrentUser` — without forcing a re-login or a page reload.
|
||||
|
||||
Two concerns had to be solved together:
|
||||
|
||||
1. **UI propagation.** Without a broadcast, an admin who demoted another
|
||||
admin from `admin` to `player` would still see them as `admin` in every
|
||||
other tab, and conversely the demoted user would still see admin-only
|
||||
navigation entries until they refreshed.
|
||||
2. **The LAST_ADMIN invariant.** The `[Admin Endpoints](/api/admin.md)`
|
||||
surface already rejects a role demotion that would leave the system
|
||||
with zero admins (`409 LAST_ADMIN`). Feature 913 also verifies that
|
||||
*concurrent* demotion requests cannot race past that check: if two
|
||||
admins try to demote each other at the same instant, exactly one
|
||||
request succeeds and the other receives `409 LAST_ADMIN`.
|
||||
|
||||
# Why a new concept doc
|
||||
|
||||
The change spans the pure contract module (`auth-session-events.pure.ts`),
|
||||
the `AuthService` transport, the `UserStore`, the admin players component
|
||||
(origin of the publish), and the home/shell component (consumer of the
|
||||
notification). It also adds a backend test that pins down the concurrent
|
||||
demotion invariant. Reading this doc together with
|
||||
[Cross-Tab Invalidation](/guides/cross-tab-invalidation.md) and
|
||||
[Admin — Challenges/Players area](/guides/admin-shell.md) is the fastest
|
||||
way to understand the full wiring.
|
||||
|
||||
# Tester flows
|
||||
|
||||
> Open the SPA in **two browser tabs** of the same browser profile. Both
|
||||
> tabs must be on authenticated routes. Sign in as **Admin A** in Tab A
|
||||
> and **Admin B** (also an admin) in Tab B (or use two browser profiles
|
||||
> and a regular user in Tab B — the broadcast only fires to tabs signed
|
||||
> in as the *target* user, so a regular-user tab will receive the role
|
||||
> update if it is the target).
|
||||
|
||||
## Self demotion (admin changes their own role)
|
||||
|
||||
1. Have two admin accounts (`admin1`, `admin2`).
|
||||
2. Sign in as `admin1` in **Tab A** and **Tab B** (duplicate the tab).
|
||||
3. In **Tab A**, navigate to `/admin/players`, find the row for
|
||||
`admin1`, and use the role dropdown to switch it from `admin` to
|
||||
`player`. The dropdown closes, the row updates, and the status line
|
||||
reads `Role updated to player.`
|
||||
4. In **Tab B** the following happens **without any user interaction**:
|
||||
- The header user menu updates to show the player variant
|
||||
(no admin-only entries, no `Players` nav item).
|
||||
- If Tab B happens to be on `/admin/*`, a toast notification appears
|
||||
with the text `Your role was changed to "player" by another admin.
|
||||
You may need to refresh or log in again to continue.`
|
||||
5. Confirm in DevTools → Application → Session Storage of **Tab B** that
|
||||
the persisted `hipctf.auth.v1` session blob now records `role: 'player'`.
|
||||
|
||||
## Admin demotes another admin (peer notification)
|
||||
|
||||
1. Sign in as `admin1` in **Tab A** and `admin2` in **Tab B**. Both are
|
||||
on `/admin/players`.
|
||||
2. In **Tab A**, change `admin2`'s role from `admin` to `player`.
|
||||
3. **Tab B** updates `admin2`'s row to `player` (the source of truth is
|
||||
still the server — the next `GET /admin/players` will reconcile any
|
||||
drift) and `admin2`'s own header in Tab B shows the player variant.
|
||||
4. If `admin2` was viewing `/admin/*` at the moment of the change, Tab B
|
||||
shows the role-changed toast and the admin-only navigation items
|
||||
disappear.
|
||||
|
||||
## Demoting a player to player (no-op for peers)
|
||||
|
||||
Changing a regular player between equivalent rows (or assigning them a
|
||||
role they already hold) still publishes a message, but the
|
||||
`applyRoleChangeToUser` guard short-circuits when the role is unchanged,
|
||||
so no toast is shown and no `sessionStorage` write occurs.
|
||||
|
||||
## Negative cases
|
||||
|
||||
| Action | Expected |
|
||||
|---|---|
|
||||
| Two admins open `/admin/players`; `admin1` demotes `admin2`, and at the same instant `admin2` demotes `admin1` | The server rejects one of the two requests with `409 LAST_ADMIN`; exactly one admin row remains. The successful demotion still publishes to other tabs; the rejected demotion returns an error toast (`You cannot demote the last admin`). |
|
||||
| Admin demotes themselves while on a tab that is on `/login` (logged out) | No broadcast is processed — the receiving tab has no `currentUser`, so `handlePeerRoleChange` returns early. |
|
||||
| A non-HIPCTF tab on the same browser profile writes `hipctf.role.invalidate.v1` to `localStorage` with garbage | The payload validator (`isStorageRoleChangeEvent`) ignores it — no state change. |
|
||||
| Same tab echoes its own broadcast (test environment that delivers self-messages) | The `origin === crossTabOrigin` guard prevents self-processing and rebroadcast loops. |
|
||||
| A third tab is signed in as a *different* user than the target | The `me.id !== msg.userId` guard prevents that tab from applying the change. |
|
||||
|
||||
# Visual / interactive elements
|
||||
|
||||
There is **one new UI affordance** — a toast — and the existing header
|
||||
user-menu gains/losses admin-only entries dynamically.
|
||||
|
||||
| Element | Selector | After peer role change (target tab on `/admin/*`) |
|
||||
|---|---|---|
|
||||
| Notification toast | `[data-testid="notification-info"]` (see [Notifications](/guides/notifications.md)) | Visible with the role-changed message. Auto-dismisses per the existing toast policy. |
|
||||
| Admin nav items | `[data-testid="shell-nav-admin"]` | Hidden when the new role is `player`. |
|
||||
| User menu trigger | `[data-testid="user-menu-trigger"]` | Still visible; admin-only entries (`Players`, `Settings`) removed when role is `player`. |
|
||||
| Players row | `[data-testid="admin-players-row"]` | Source-of-truth is still the server; a peer change re-renders the row on the next `GET /admin/players` call. |
|
||||
|
||||
# How it works (developer map)
|
||||
|
||||
## Pure contract module additions
|
||||
|
||||
`frontend/src/app/core/services/auth-session-events.pure.ts` now
|
||||
additionally defines:
|
||||
|
||||
| Export | Purpose |
|
||||
|---|---|
|
||||
| `CROSS_TAB_ROLE_CHANNEL_NAME = 'hipctf.auth.role.v1'` | Stable `BroadcastChannel` name for role-change messages. Separate from the invalidation channel so the two transports cannot alias. |
|
||||
| `CROSS_TAB_ROLE_STORAGE_KEY = 'hipctf.role.invalidate.v1'` | `localStorage` fallback key used when `BroadcastChannel` is unavailable. |
|
||||
| `CROSS_TAB_ROLE_EVENT_TYPE = 'hipctf.session.role-changed'` | Marker for the discriminated-union guard. |
|
||||
| `CrossTabRoleChangeMessage` | `{ type: 'role-changed'; origin: string; ts: number; userId: string; newRole: 'admin' \| 'player' }`. |
|
||||
| `encodeRoleChangeMessage(origin, userId, newRole)` | Builds a message stamped with `Date.now()`. |
|
||||
| `isCrossTabRoleChangeMessage(value)` | Type-guard used by both `BroadcastChannel` and `storage` listeners; rejects `session-invalidated` payloads so the two channels never double-fire. |
|
||||
| `isStorageRoleChangeEvent(event)` | Guards `storage` events: only accepts the namespaced key with a parseable payload. |
|
||||
| `applyRoleChangeToUser(user, newRole)` | Pure helper: returns the same reference when roles match, a new `{...user, role}` object otherwise, and `null` for a `null` user. |
|
||||
|
||||
The payload carries only the **target user id** and the **new role** —
|
||||
no tokens, no session blob, no other user attributes.
|
||||
|
||||
## `AuthService` wiring additions
|
||||
|
||||
`frontend/src/app/core/services/auth.service.ts`
|
||||
|
||||
* `installCrossTabListener()` now opens a *second* `BroadcastChannel`
|
||||
named `hipctf.auth.role.v1` and subscribes a `'message'` handler
|
||||
(`onRoleBroadcastMessage`). The existing `'storage'` listener is
|
||||
extended to dispatch both `isStorageInvalidationEvent` **and**
|
||||
`isStorageRoleChangeEvent` — the storage event fires only in the
|
||||
tabs *other* than the writer, which is exactly the fan-out we want.
|
||||
* `publishRoleChange(userId, newRole)` is the new public entry point.
|
||||
It encodes a message with the per-tab `crossTabOrigin` and posts it
|
||||
on both transports. Failures are swallowed (the storage fallback still
|
||||
fires from the other side). It deliberately does **not** clear local
|
||||
state — the originating tab is the one that just successfully called
|
||||
`PATCH /api/v1/admin/players/:id/role`.
|
||||
* `applyPeerRoleChange(newRole)` updates the local `currentUser` signal
|
||||
in-place and writes the new user into `sessionStorage` so a subsequent
|
||||
hard reload hydrates with the updated role. It does **not** publish.
|
||||
* `onPeerRoleChange(cb)` is the new subscriber API; returns an
|
||||
unsubscribe closure. Used by `HomeComponent`.
|
||||
* `handlePeerRoleChange(raw)` is the single fan-in for both transport
|
||||
sources: it parses, drops self-origin messages, drops messages when
|
||||
there is no signed-in user, drops messages that target a different
|
||||
user id, then calls `applyPeerRoleChange` and notifies subscribers.
|
||||
* `teardownCrossTabListener()` closes the role channel and clears
|
||||
`peerRoleChangeListeners`.
|
||||
|
||||
## `UserStore.setRole`
|
||||
|
||||
`frontend/src/app/core/services/user.store.ts` gains a `setRole(role)`
|
||||
helper that updates the in-memory user signal and calls
|
||||
`auth.applyPeerRoleChange(role)`. This is the path used when the
|
||||
broadcast was received from a peer tab.
|
||||
|
||||
## Publish path — `AdminUsersComponent.onToggleRole`
|
||||
|
||||
`frontend/src/app/features/admin/admin-users.component.ts`
|
||||
|
||||
After the successful `PATCH /api/v1/admin/players/:id/role` response,
|
||||
the component now:
|
||||
|
||||
1. Updates the local `rows` signal with the returned `updated` payload.
|
||||
2. If the *currently signed-in* user matches `updated.id` **and** the
|
||||
role actually changed (`me.role !== updated.role`), it calls
|
||||
`auth.applyPeerRoleChange(updated.role)` to update the local user
|
||||
signal immediately, then `auth.publishRoleChange(updated.id, updated.role)`
|
||||
to fan the change out to every other tab.
|
||||
|
||||
The build fix captured in
|
||||
`.kilo/plans/913-build-fix.md` adds the missing `AuthService` injection
|
||||
so TypeScript can resolve `this.auth`.
|
||||
|
||||
## Consumer path — `HomeComponent.handlePeerRoleChange`
|
||||
|
||||
`frontend/src/app/features/home/home.component.ts`
|
||||
|
||||
* `ngOnInit()` calls `auth.onPeerRoleChange(handlePeerRoleChange)` and
|
||||
stores the unsubscribe handle in `peerRoleChangeUnsubscribe`.
|
||||
* `ngOnDestroy()` calls the unsubscribe (in addition to the existing
|
||||
peer-invalidation unsubscribe).
|
||||
* `handlePeerRoleChange(msg)`:
|
||||
* Returns early if there is no signed-in user, or the user id does not
|
||||
match the broadcast.
|
||||
* Calls `userStore.setRole(msg.newRole)` to update the signal and
|
||||
re-persist via `AuthService`.
|
||||
* If the current route is under `/admin/*`, shows an info toast:
|
||||
`Your role was changed to "<role>" by another admin. You may need to
|
||||
refresh or log in again to continue.`
|
||||
|
||||
## Backend invariant — LAST_ADMIN under concurrent demotion
|
||||
|
||||
The existing `PATCH /api/v1/admin/players/:id/role` handler runs inside a
|
||||
transaction and re-checks the remaining admin count before committing,
|
||||
so the LAST_ADMIN invariant already holds for a *sequential* demotion.
|
||||
`tests/backend/admin-players.spec.ts` adds a **concurrent** demotion
|
||||
test that:
|
||||
|
||||
1. Registers `original` as the first admin, then creates a second admin
|
||||
`bob` (so the system has two admins).
|
||||
2. Concurrently fires `PATCH /api/v1/admin/players/original.id/role`
|
||||
from `original`'s session and
|
||||
`PATCH /api/v1/admin/players/bob.id/role` from `bob`'s session.
|
||||
3. Asserts that exactly one request returns `200` with `role: 'player'`,
|
||||
the other returns `409` with body `code: 'LAST_ADMIN'`, and the
|
||||
database still has exactly one admin row (`bob`).
|
||||
|
||||
Because both demotions happen against the same `users` table inside the
|
||||
DB-side transactional guard, the race resolves deterministically: the
|
||||
first transaction commits with the last-admin check passing; the second
|
||||
sees zero remaining admins and aborts with `409`.
|
||||
|
||||
## Test coverage
|
||||
|
||||
| File | What it pins down |
|
||||
|---|---|
|
||||
| `tests/frontend/auth-session-events.spec.ts` | Pure payload validation (`encodeRoleChangeMessage`, `isCrossTabRoleChangeMessage`, `isStorageRoleChangeEvent`), `applyRoleChangeToUser` no-op semantics, and rejection of `session-invalidated` payloads on the role channel. |
|
||||
| `tests/backend/admin-players.spec.ts` | Concurrent two-admin demotion: one succeeds, the other gets `409 LAST_ADMIN`, exactly one admin remains in the DB. |
|
||||
|
||||
# See also
|
||||
|
||||
- [Cross-Tab Invalidation](/guides/cross-tab-invalidation.md) — sibling
|
||||
channel for session invalidation.
|
||||
- [Authenticated Shell](/guides/authenticated-shell.md) — the consumer
|
||||
of peer events.
|
||||
- [Admin Shell & Side Navigation](/guides/admin-shell.md) — the
|
||||
`/admin/players` page where role changes originate.
|
||||
- [Admin Endpoints](/api/admin.md) — the `PATCH /admin/players/:id/role`
|
||||
surface and its `LAST_ADMIN` error code.
|
||||
- [Users Table](/database/users.md) — the `role` column on the `user`
|
||||
table.
|
||||
- [Frontend Structure](/architecture/frontend-structure.md)
|
||||
- [REST API Overview](/api/rest-overview.md)
|
||||
+4
-1
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
|
||||
controls.
|
||||
|
||||
The docs below are organized by purpose so agents can pull just the slice
|
||||
they need. Last regenerated 2026-07-23T06:39:17Z.
|
||||
they need. Last regenerated 2026-07-23T08:42:24Z.
|
||||
|
||||
# Architecture
|
||||
|
||||
@@ -100,6 +100,9 @@ they need. Last regenerated 2026-07-23T06:39:17Z.
|
||||
* [Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md) -
|
||||
How a logout in one tab, or an unauthorized SSE response, instantly clears
|
||||
and redirects every other open tab of the SPA.
|
||||
* [Cross-Tab Role Change Sync](/guides/cross-tab-role-change.md) -
|
||||
How a role change at `/admin/players` propagates to every other open tab,
|
||||
plus the LAST_ADMIN invariant under concurrent admin demotions.
|
||||
* [Local Development Workflow](/guides/dev-workflow.md) - How to run the
|
||||
backend and frontend together (`npm run dev`), the Angular dev-server
|
||||
proxy, and the project-local `./data/` directory.
|
||||
|
||||
Reference in New Issue
Block a user