feat(web): add frontend authentication with route protection - #217
Merged
mijinummi merged 1 commit intoAug 20, 2026
Merged
Conversation
Adds `apps/web/lib/auth/`: a login flow, session storage, and a route guard for the dashboard. Token storage defaults to memory, not localStorage -------------------------------------------------- A bearer token in localStorage is readable by any script that manages to run on the page, so one XSS becomes full account takeover of a security console. The default `MemoryTokenStorage` keeps the token in the JS heap, where it dies with the tab and is never reachable through document-level access. The cost is honest and stated: a refresh signs the user out. Persisting safely across reloads needs the refresh token in an httpOnly cookie the frontend cannot read, which requires backend support that does not exist yet. `LocalStorageTokenStorage` is provided for teams that accept the trade, but it is opt-in rather than the default. Its every access is guarded, because localStorage throws in private browsing and when the quota is exceeded, and a failure to persist must not take down the login flow. Three states, not two --------------------- `AuthStatus` is `unknown | authenticated | unauthenticated`. On first render the app has not yet inspected storage, and collapsing that into "unauthenticated" flashes the login screen at users who are in fact signed in. `ProtectedRoute` renders a loading state for `unknown`. It also separates being signed out from lacking permission. The first should send you to sign in; the second should tell you that signing in again will not help. `requiredRoles` mirrors the backend `Role` enum in `src/modules/rbac/roles.enum.ts`, so the UI gates on the values the API issues. Other decisions --------------- - `expiresIn` is converted to an absolute `expiresAt` at the API boundary, so the rest of the app compares timestamps rather than recomputing a deadline from a duration whose origin it no longer knows. - Logout clears local state first and never throws. A user on a flaky connection asked to sign out, and must not be left holding a session because the network call failed. - Expired sessions found in storage are cleared on read rather than left to fail on first use. - `useAuth` throws outside a provider, since that is a wiring bug rather than a state to handle. - The API client follows the existing `lib/api/profile.ts` conventions: same base URL resolution, same typed-error shape, same response parsing. Tests: 33 across three suites covering memory and localStorage backends, corrupt JSON, quota and security exceptions, expiry handling, session restore, login success and rejection, logout including the failing-network path, the provider-less error, and every ProtectedRoute branch including the unresolved state. Full dashboard suite: 74 passing across 8 suites, no regressions. Lint and prettier clean. Note: the client targets `/auth/login`, `/auth/logout` and `/auth/me`. The backend has an RBAC guard but no auth controller yet, so those endpoints still need implementing server-side; this establishes the contract the frontend expects.
3 tasks
Collaborator
|
LGTM |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #107
Summary
Adds
apps/web/lib/auth/— login flow, session storage, and route protection for the dashboard.LoginForm+AuthProvider.login()+auth-api.login()MemoryTokenStorage(default) andLocalStorageTokenStoragebehind aTokenStorageinterfaceProtectedRoute, with optional role gatingAuthProvider.logout(), clears local state then calls the APIToken storage defaults to memory, not localStorage
This is the decision most worth your attention, and it is deliberate.
A bearer token in
localStorageis readable by any script that manages to run on the page — so a single XSS becomes full account takeover of a security console. The defaultMemoryTokenStoragekeeps the token in the JS heap, where it dies with the tab and is never reachable through document-level access.The cost is real and I would rather state it than hide it: a page refresh signs the user out. Persisting safely across reloads needs the refresh token in an httpOnly cookie the frontend cannot read, which requires backend support that does not exist yet.
LocalStorageTokenStorageis provided for teams that accept the trade, but it is opt-in rather than the default — and every access to it is guarded, becauselocalStoragethrows in private browsing and on quota exhaustion, and a failure to persist must never take down the login.If you would rather ship persistence by default, it is a one-line change at the
AuthProvidercall site.Three auth states, not two
AuthStatusisunknown | authenticated | unauthenticated.On first render the app has not yet inspected storage. Collapsing that into "unauthenticated" is the standard bug that flashes the login screen at users who are in fact signed in, so
ProtectedRouterenders a loading state forunknown.It also keeps signed out and lacking permission distinct: the first should send you to sign in, the second should tell you that signing in again will not help.
requiredRolesmirrors the backendRoleenum insrc/modules/rbac/roles.enum.ts(ADMIN | MODERATOR | USER), so the UI gates on the values the API actually issues rather than a parallel list that can drift.Other decisions
expiresInbecomes an absoluteexpiresAtat the API boundary, so the rest of the app compares timestamps instead of recomputing a deadline from a duration whose origin it no longer knows.useAuththrows outside a provider — that is a wiring bug, not a state to handle gracefully.lib/api/profile.tsconventions: same base-URL resolution, same typed-error shape, same response parsing.Tests
33 across three suites; 74 passing across the full dashboard suite, no regressions. Lint and prettier clean.
Covered: both storage backends · corrupt JSON · quota and security exceptions · expiry handling · session restore on mount · login success · rejected credentials surfaced in plain language · logout including the failing-network path · the provider-less error · and every
ProtectedRoutebranch.Two testing notes, since both look unusual in the diff:
ProtectedRoute.unknown.spec.tsxstubsuseAuthrather than going throughAuthProvider. Theunknownwindow closes the moment the restore effect runs, so it cannot be observed through the provider — but it is precisely the branch that prevents the login-screen flash, so it is pinned directly.localStoragefailure tests swap the whole object rather than usingjest.spyOn, because jsdom'slocalStorageis not spy-able.Backend dependency
The client targets
/auth/login,/auth/logoutand/auth/me. The backend has an RBAC guard but no auth controller yet, so those endpoints still need implementing server-side. This PR establishes the contract the frontend expects — request and response shapes are documented inauth-api.ts. Happy to open a follow-up issue for the backend side if that is useful.