Skip to content

feat(roles): role password, permission flow, route + speaker gating (#469) - #746

Merged
eyevinn-developer[bot] merged 2 commits into
mainfrom
frontend/roles-469
Nov 3, 2026
Merged

eyevinn-developer[bot] merged 2 commits into
mainfrom
frontend/roles-469

Conversation

@eyevinn-developer

Copy link
Copy Markdown
Contributor

Summary

Implements the Roles feature on the frontend (closes five sub-issues of #469). The whole feature is dormant by default: with no role held and REQUIRE_AUTH off, the app behaves exactly as before — no Authorization header is ever sent, no route or UI element is gated.

  • Frontend: Roles — Role password input in user settings #631 — Role password input. A masked (type="password") field in user settings (role-password.tsx), shown on the landing-page settings and the calls-page Profile tab. Its value feeds the permission request; when a role is held it shows the granted role and a "Clear role" action.
  • Frontend: Roles — Permission request flow to manager #632 — Permission request flow. useRequestPermission calls POST /api/v1/permissions with the password (API.requestPermissions), stores the granted role + features in global state (UPDATE_ROLE_AUTH) and the password in a framework-agnostic store for the header. Failures are classified: 401 → invalid password, 429 → rate-limited, else generic server error. The permission call deliberately bypasses handleFetchRequest so a bad-password 401 does not trip the global auth circuit breaker.
  • Frontend: Roles — Route-level permission enforcement #633 — Route-level enforcement. RequireFeature gates /create (canCreateProduction) and /manage (canEditProduction) via useHasFeature. "No role held" resolves to allow unless the global REQUIRE_AUTH flag (new getRequireAuth() in runtime-config, read the same way as the manager base URL / AUTH URL) says otherwise; a role that lacks the feature is redirected to the landing page.
  • Frontend: Roles — Attach auth headers to all API requests #634 — Auth header on all manager requests. New authorizedFetch wrapper merges Authorization: Bearer <role-password> into every api.ts call — but only when a password is stored, so with no role it calls fetch with byte-identical arguments.
  • Frontend: Roles — Prevent joining program lines as speaker without permission #635 — Speaker-join gate. Joining a program-output line as audio feed (speaker) is gated on features.canJoinLine at the single initiateProductionCall choke point (enforced regardless of which UI triggers it); listening is always allowed. The program-line join card also disables the "Audio feed" option when the role lacks the feature.

Dormant-by-default design

  • Header attached only when a role password is actually stored (never a bogus header).
  • Guards/gates treat "no role held" as allow unless REQUIRE_AUTH is on.
  • Role rehydrated from the persisted store into global state on app mount; a no-role deployment is a no-op.

Test plan

npm ci
npm run typecheck
npm run lint
npm test      # 614 passed (64 files)
npm run build

All five commands pass. New tests cover:

  • role-auth-store.test.ts — store set/get/clear, Bearer header, hasFeature dormant vs. granted vs. REQUIRE_AUTH.
  • api.test.ts — requestPermissions 200 / 401 / 429.
  • use-request-permission.test.ts — success stores role+features and dispatches; 401 / 429 messages; clear.
  • require-feature.test.tsx — allow when feature held, block when role lacks it, allow when no role held (dormant), block when no role + REQUIRE_AUTH on.
  • use-initiate-production-call.test.ts — audio-feed join blocked without canJoinLine, listener allowed, audio-feed allowed when no role held.

Network and RTC are stubbed per the project's happy-dom / vi.stubGlobal patterns.

What a human does next

To activate, on the intercom-manager deployment set ROLE_ADMIN_PASSWORD / ROLE_EDITOR_PASSWORD / ROLE_PARTICIPANT_PASSWORD and REQUIRE_AUTH=true (and optionally set REQUIRE_AUTH=true / VITE_REQUIRE_AUTH=true on the frontend if you want "no role held" to block restricted routes). Without that configuration the feature stays dormant and the app behaves exactly as before. To test: open user settings, enter a configured role password under Role, Unlock — restricted routes (/create, /manage) and the program-line audio-feed option unlock according to the granted role; clearing the role restores the gated state.

Closes #631
Closes #632
Closes #633
Closes #634
Closes #635

🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com

@eyevinn-pr-reviewer eyevinn-pr-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — Needs Changes

Automated review (daily-backlog-pr Phase 3). The Roles feature is well structured — authorizedFetch is applied centrally in src/api/api.ts, state changes go through a typed reducer action, there are no dependency changes, and the new store/hook/guard logic has tests. No Blocking items, but a cluster of auth-flow/secret-storage Warnings (the 3+ rule) makes this Needs Changes:

  • src/App.tsx:243-248 — Persisted role is rehydrated in a useEffect, so roleAuth is null on first render. With REQUIRE_AUTH=true, reloading or deep-linking to /create or /manage makes RequireFeature redirect to / before the role is restored, so valid role holders lose their page. Seed the reducer's initial state from getRoleAuth(), or gate the routes until rehydration completes.
  • src/api/role-auth-store.ts:68-76 — The raw role password is persisted in plaintext in localStorage and sent as Bearer <password> (readable by any XSS). At minimum record the trade-off; sessionStorage would narrow exposure. Also, a later-invalid stored password is never cleared — a 401 on any call leaves the user "signed in" with every request failing until they clear it by hand. Clear stored auth on 401.
  • src/api/api.ts:436-460 — requestPermissions hand-rolls error handling instead of going through handleFetchRequest; if that's to avoid tripping the auth circuit-breaker on a wrong-password 401, say so in a comment (or give handleFetchRequest a breaker opt-out) rather than duplicating parsing.
  • src/hooks/use-request-permission.ts:37 — e as { status?: number } cast; use a typed HttpError/type guard. Add explicit return types to the exported useRequestPermission, RolePassword, RequireFeature, and useInitiateProductionCall.

Suggestions (non-blocking): validate parsed TStoredRoleAuth shape in readFromStorage; add a direct test for use-has-feature.ts and a component test for program-line-join-card.tsx gating.

Please address the Warnings (especially the rehydration redirect and the 401-not-clearing-stored-auth) and push to the same branch. Moving the linked board items (#631–#635) back to Ready.

…469)

Implement the Roles feature end to end on the frontend, dormant by default:

- #631 Masked role-password field in user settings whose value feeds the
  permission request.
- #632 Permission request flow: POST /api/v1/permissions with the password,
  store the granted role + feature flags in app state (password kept only for
  the auth header), classify 401 / 429 / server-error failures.
- #633 Route-level enforcement: RequireFeature guard gates /create and /manage
  on the stored flags via useHasFeature; "no role held" allows unless the
  REQUIRE_AUTH global flag is set.
- #634 Attach Authorization: Bearer <role-password> to all manager API
  requests via an authorizedFetch wrapper — only when a password is stored.
- #635 Prevent joining program lines as audio feed (speaker) without
  canJoinLine, gated centrally in the join choke point; listening stays allowed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n errors

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@eyevinn-pr-reviewer eyevinn-pr-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

PR #746, frontend/roles-469, head e843c32 (rebased on origin/main).

Automated review (daily-backlog-pr Phase 3). All four warnings from the previous Needs-Changes review are addressed and nothing is blocking. Evidence: npm run typecheck clean, npm run lint passes with --max-warnings 0, and npm test passes (65 files, 616 tests).

Prior review warnings

  1. Role restored after first render: fixed. useInitializeGlobalStateReducer seeds roleAuth from getRoleAuth() via the lazy useReducer initializer, so RequireFeature sees the persisted role on first render (require-feature.test.tsx covers it).
  2. Plaintext password / no clear on 401: fixed. src/api/role-auth-store.ts:37-44 documents the localStorage trade-off; src/api/authorized-fetch.ts:27-33 calls clearRoleAuth() on 401 and App.tsx dispatches UPDATE_ROLE_AUTH: null. The password is not copied into global state. (See Warning 1 for a side effect.)
  3. requestPermissions bypasses handleFetchRequest: fixed. src/api/api.ts:430-437 now explains a 401 here means a wrong role password, not an expired session, so it must not trip the auth circuit breaker.
  4. Cast / missing return types: fixed. src/api/http-error.ts adds an HttpError type, isHttpError guard and createHttpError; use-request-permission.ts uses the guard, and the new hooks + useInitiateProductionCall have explicit return types.

Blocking

None.

Warnings (follow-ups, non-blocking)

  1. src/api/authorized-fetch.ts:29: any 401 wipes the role. Once a role is stored the 401 handler fires for every endpoint; a session-expiry 401 (not a bad role password) would silently drop a valid role. Consider clearing only on role-validation 401s (e.g. permissions), or document that session expiry also drops the role. A "401 from a non-role endpoint" test would pin the behaviour. Most worth addressing before release.
  2. src/api/role-auth-store.ts: persisted features are trusted and never revalidated. Rotated passwords / changed flags aren't reflected until a request 401s; no storage event handling for multi-tab sync; readFromStorage doesn't validate role ∈ TRole or boolean features. A startup re-POST to /permissions (or a verify call) plus a storage listener would fix this.
  3. Client-side gating is not enforcement. RequireFeature/canJoinLine/disabled checkbox are all bypassable (localStorage is editable). Acceptable only if the manager enforces every flag server-side from the Bearer token — state that the manager is the source of truth and the frontend gates are UX only.
  4. Unused flags. canDeleteProduction, canManageLines, canRemoveParticipants, canManageIngests are defined but no UI gates on them; only canCreateProduction/canEditProduction/canJoinLine are enforced. If #633/#635 intentionally cover only those three, say so; otherwise those controls stay visible to roles that cannot use them.

Suggestions

  • authorized-fetch.ts:26: ...init?.headers assumes a plain object; new Headers(init?.headers) then .set(...) is more robust.
  • requestPermissions attaches a stale Authorization header; sending without it is cleaner.
  • use-websocket.ts:38 carries no role credential — confirm the WS channel gating is out of scope.
  • use-request-permission.ts: clearPermission double-dispatches UPDATE_ROLE_AUTH: null (the App.tsx listener already does); and setLoading(false) may run after unmount (low impact in React 19).
  • role-password.tsx: minLength: 1 redundant next to required; failed-attempt error not cleared on typing.
  • No package.json changes — no dependency-policy concern. New logic (role-auth-store, authorized-fetch, use-request-permission, RequireFeature, join gate) has happy-dom tests with no timing hacks.

Verdict: LGTM. All four prior warnings are addressed and nothing is blocking. Warnings 1–4 are worth follow-ups, with Warning 1 (a session-expiry 401 wiping the role) the one most worth fixing before release.

@eyevinn-developer
eyevinn-developer Bot merged commit 85715be into main Nov 3, 2026
6 checks passed
@eyevinn-developer
eyevinn-developer Bot deleted the frontend/roles-469 branch November 3, 2026 08:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant