From 356f8b47fa35b037807a5946c79a8954b94c8eb2 Mon Sep 17 00:00:00 2001
From: cybermaxi7
Date: Wed, 19 Aug 2026 18:04:20 +0100
Subject: [PATCH] feat(web): add frontend authentication with route protection
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.
---
apps/web/lib/auth/AuthContext.tsx | 130 +++++++
apps/web/lib/auth/LoginForm.tsx | 80 +++++
apps/web/lib/auth/ProtectedRoute.tsx | 52 +++
.../lib/auth/ProtectedRoute.unknown.spec.tsx | 35 ++
apps/web/lib/auth/auth-api.ts | 81 +++++
apps/web/lib/auth/auth.spec.tsx | 334 ++++++++++++++++++
apps/web/lib/auth/index.ts | 11 +
apps/web/lib/auth/token-storage.spec.ts | 151 ++++++++
apps/web/lib/auth/token-storage.ts | 89 +++++
apps/web/lib/auth/types.ts | 64 ++++
10 files changed, 1027 insertions(+)
create mode 100644 apps/web/lib/auth/AuthContext.tsx
create mode 100644 apps/web/lib/auth/LoginForm.tsx
create mode 100644 apps/web/lib/auth/ProtectedRoute.tsx
create mode 100644 apps/web/lib/auth/ProtectedRoute.unknown.spec.tsx
create mode 100644 apps/web/lib/auth/auth-api.ts
create mode 100644 apps/web/lib/auth/auth.spec.tsx
create mode 100644 apps/web/lib/auth/index.ts
create mode 100644 apps/web/lib/auth/token-storage.spec.ts
create mode 100644 apps/web/lib/auth/token-storage.ts
create mode 100644 apps/web/lib/auth/types.ts
diff --git a/apps/web/lib/auth/AuthContext.tsx b/apps/web/lib/auth/AuthContext.tsx
new file mode 100644
index 0000000..12afa67
--- /dev/null
+++ b/apps/web/lib/auth/AuthContext.tsx
@@ -0,0 +1,130 @@
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import * as authApi from './auth-api';
+import { MemoryTokenStorage, TokenStorage } from './token-storage';
+import {
+ AuthApiError,
+ AuthSession,
+ AuthStatus,
+ AuthUser,
+ LoginCredentials,
+ isSessionValid,
+} from './types';
+
+export interface AuthContextValue {
+ status: AuthStatus;
+ user: AuthUser | null;
+ /** Current access token, or null. Exposed for API clients that need it. */
+ token: string | null;
+ /** Last login failure, cleared on the next attempt. */
+ error: string | null;
+ isSubmitting: boolean;
+ login: (credentials: LoginCredentials) => Promise;
+ logout: () => Promise;
+}
+
+const AuthContext = createContext(null);
+
+export interface AuthProviderProps {
+ children: React.ReactNode;
+ /** Injectable so tests, and apps that opt into persistence, can swap it. */
+ storage?: TokenStorage;
+}
+
+export const AuthProvider: React.FC = ({ children, storage }) => {
+ // Held in a ref so swapping storage never re-runs the restore effect, and so
+ // the default instance is stable across renders.
+ const storageRef = useRef(storage ?? new MemoryTokenStorage());
+
+ const [session, setSession] = useState(null);
+ const [status, setStatus] = useState('unknown');
+ const [error, setError] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // Restore once on mount. Until this runs, status stays 'unknown' so guards
+ // render a loading state rather than briefly showing the login screen to
+ // someone who is already signed in.
+ useEffect(() => {
+ const restored = storageRef.current.read();
+ if (isSessionValid(restored)) {
+ setSession(restored);
+ setStatus('authenticated');
+ } else {
+ // An expired entry is cleared rather than left to fail on first use.
+ storageRef.current.clear();
+ setStatus('unauthenticated');
+ }
+ }, []);
+
+ const login = useCallback(async (credentials: LoginCredentials): Promise => {
+ setIsSubmitting(true);
+ setError(null);
+
+ try {
+ const newSession = await authApi.login(credentials);
+ storageRef.current.write(newSession);
+ setSession(newSession);
+ setStatus('authenticated');
+ return true;
+ } catch (caught) {
+ const message =
+ caught instanceof AuthApiError && caught.isCredentialFailure
+ ? 'Incorrect email or password.'
+ : caught instanceof Error
+ ? caught.message
+ : 'Unable to sign in. Please try again.';
+
+ setError(message);
+ setStatus('unauthenticated');
+ return false;
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, []);
+
+ const logout = useCallback(async (): Promise => {
+ const token = session?.accessToken;
+
+ // Local state is cleared first. If the network call is slow or fails, the
+ // user is still signed out of this tab, which is what they asked for.
+ storageRef.current.clear();
+ setSession(null);
+ setStatus('unauthenticated');
+ setError(null);
+
+ if (token) {
+ await authApi.logout(token);
+ }
+ }, [session]);
+
+ const value = useMemo(
+ () => ({
+ status,
+ user: session?.user ?? null,
+ token: session?.accessToken ?? null,
+ error,
+ isSubmitting,
+ login,
+ logout,
+ }),
+ [status, session, error, isSubmitting, login, logout],
+ );
+
+ return {children};
+};
+
+/** Access the auth state. Throws outside a provider, which is a wiring bug. */
+export const useAuth = (): AuthContextValue => {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error('useAuth must be used within an AuthProvider');
+ }
+ return context;
+};
diff --git a/apps/web/lib/auth/LoginForm.tsx b/apps/web/lib/auth/LoginForm.tsx
new file mode 100644
index 0000000..b7b54bc
--- /dev/null
+++ b/apps/web/lib/auth/LoginForm.tsx
@@ -0,0 +1,80 @@
+import React, { useState } from 'react';
+import { useAuth } from './AuthContext';
+
+export interface LoginFormProps {
+ /** Called after a successful sign-in. */
+ onSuccess?: () => void;
+ heading?: string;
+}
+
+/**
+ * Credential sign-in form.
+ *
+ * The submit button stays enabled while fields are empty so that pressing it
+ * surfaces validation messages, rather than leaving the user with a dead
+ * control and no explanation. It disables only while a request is in flight.
+ */
+export const LoginForm: React.FC = ({
+ onSuccess,
+ heading = 'Sign in to Sentinel',
+}) => {
+ const { login, error, isSubmitting } = useAuth();
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [validationError, setValidationError] = useState(null);
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+
+ if (!email.trim() || !password) {
+ setValidationError('Enter both your email and password.');
+ return;
+ }
+ setValidationError(null);
+
+ const ok = await login({ email: email.trim(), password });
+ if (ok) onSuccess?.();
+ };
+
+ const message = validationError ?? error;
+
+ return (
+
+ );
+};
+
+export default LoginForm;
diff --git a/apps/web/lib/auth/ProtectedRoute.tsx b/apps/web/lib/auth/ProtectedRoute.tsx
new file mode 100644
index 0000000..83e1026
--- /dev/null
+++ b/apps/web/lib/auth/ProtectedRoute.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { useAuth } from './AuthContext';
+import { AuthRole, hasAnyRole } from './types';
+
+export interface ProtectedRouteProps {
+ children: React.ReactNode;
+ /** When non-empty, the user must hold at least one of these roles. */
+ requiredRoles?: AuthRole[];
+ /** Shown to unauthenticated users. Typically the login screen. */
+ fallback?: React.ReactNode;
+ /** Shown to authenticated users who lack the required role. */
+ forbiddenFallback?: React.ReactNode;
+ /** Shown while the session is still being resolved. */
+ loading?: React.ReactNode;
+}
+
+/**
+ * Gates its children on authentication, and optionally on role.
+ *
+ * Three states, not two. While `status` is `unknown` the app has not yet
+ * inspected storage, and rendering the fallback then would flash the login
+ * screen at users who are in fact signed in — so that case renders `loading`.
+ *
+ * Being signed out and lacking permission are also kept distinct: the first
+ * should send you to sign in, the second should tell you that signing in again
+ * will not help.
+ */
+export const ProtectedRoute: React.FC = ({
+ children,
+ requiredRoles = [],
+ fallback = null,
+ forbiddenFallback = null,
+ loading = null,
+}) => {
+ const { status, user } = useAuth();
+
+ if (status === 'unknown') {
+ return <>{loading}>;
+ }
+
+ if (status !== 'authenticated' || !user) {
+ return <>{fallback}>;
+ }
+
+ if (!hasAnyRole(user, requiredRoles)) {
+ return <>{forbiddenFallback}>;
+ }
+
+ return <>{children}>;
+};
+
+export default ProtectedRoute;
diff --git a/apps/web/lib/auth/ProtectedRoute.unknown.spec.tsx b/apps/web/lib/auth/ProtectedRoute.unknown.spec.tsx
new file mode 100644
index 0000000..900cbc5
--- /dev/null
+++ b/apps/web/lib/auth/ProtectedRoute.unknown.spec.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+// The 'unknown' window closes as soon as the provider's restore effect runs, so
+// it cannot be observed through AuthProvider in a test. Stubbing useAuth pins
+// the contract directly: an unresolved session must render `loading`, never the
+// signed-out fallback, or users who are signed in see the login screen flash.
+jest.mock('./AuthContext', () => ({
+ useAuth: () => ({
+ status: 'unknown',
+ user: null,
+ token: null,
+ error: null,
+ isSubmitting: false,
+ login: jest.fn(),
+ logout: jest.fn(),
+ }),
+}));
+
+import { ProtectedRoute } from './ProtectedRoute';
+
+describe('ProtectedRoute while the session is unresolved', () => {
+ it('renders the loading state, not the signed-out fallback', () => {
+ render(
+ Checking session
} fallback={
Sign in
}>
+
Incident data
+ ,
+ );
+
+ expect(screen.getByText('Checking session')).toBeInTheDocument();
+ expect(screen.queryByText('Sign in')).not.toBeInTheDocument();
+ expect(screen.queryByText('Incident data')).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/lib/auth/auth-api.ts b/apps/web/lib/auth/auth-api.ts
new file mode 100644
index 0000000..e600c18
--- /dev/null
+++ b/apps/web/lib/auth/auth-api.ts
@@ -0,0 +1,81 @@
+import { AuthApiError, AuthSession, AuthUser, LoginCredentials } from './types';
+
+const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000/api';
+
+/** Shape the API returns on a successful login. */
+interface LoginResponse {
+ accessToken: string;
+ /** Lifetime in seconds, as issued by the API. */
+ expiresIn: number;
+ user: AuthUser;
+}
+
+async function parseResponse(response: Response): Promise {
+ const body = (await response.json().catch(() => ({}))) as {
+ message?: string | string[];
+ };
+
+ if (!response.ok) {
+ const message = Array.isArray(body.message)
+ ? body.message.join(', ')
+ : (body.message ?? `Request failed with status ${response.status}`);
+ throw new AuthApiError(message, response.status);
+ }
+
+ return body as T;
+}
+
+const authHeaders = (token: string): HeadersInit => ({
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+});
+
+/**
+ * Exchange credentials for a session.
+ *
+ * `expiresIn` is converted to an absolute `expiresAt` at the boundary, so the
+ * rest of the app compares timestamps rather than recomputing a deadline from a
+ * duration whose origin it no longer knows.
+ */
+export async function login(credentials: LoginCredentials): Promise {
+ const response = await fetch(`${API_BASE}/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(credentials),
+ });
+
+ const body = await parseResponse(response);
+ return {
+ accessToken: body.accessToken,
+ expiresAt: Date.now() + body.expiresIn * 1000,
+ user: body.user,
+ };
+}
+
+/**
+ * Invalidate the session server-side.
+ *
+ * Deliberately never throws. Logout must clear local state even when the
+ * network call fails, otherwise a user on a flaky connection is stuck holding a
+ * session they asked to end.
+ */
+export async function logout(token: string): Promise {
+ try {
+ await fetch(`${API_BASE}/auth/logout`, {
+ method: 'POST',
+ headers: authHeaders(token),
+ });
+ } catch {
+ // Swallowed on purpose — see above.
+ }
+}
+
+/** Re-read the current user, used to validate a restored session. */
+export async function fetchCurrentUser(token: string): Promise {
+ const response = await fetch(`${API_BASE}/auth/me`, {
+ method: 'GET',
+ headers: authHeaders(token),
+ });
+
+ return parseResponse(response);
+}
diff --git a/apps/web/lib/auth/auth.spec.tsx b/apps/web/lib/auth/auth.spec.tsx
new file mode 100644
index 0000000..69e7f8f
--- /dev/null
+++ b/apps/web/lib/auth/auth.spec.tsx
@@ -0,0 +1,334 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { AuthProvider, useAuth } from './AuthContext';
+import { ProtectedRoute } from './ProtectedRoute';
+import { LoginForm } from './LoginForm';
+import { MemoryTokenStorage } from './token-storage';
+import { AuthSession, AuthRole } from './types';
+
+const validSession = (roles: AuthRole[] = ['USER']): AuthSession => ({
+ accessToken: 'token-123',
+ expiresAt: Date.now() + 60_000,
+ user: { id: 'u1', email: 'analyst@sentinel.test', name: 'Analyst', roles },
+});
+
+/** Minimal fetch stub; each test declares the response it cares about. */
+const mockFetch = (impl: (url: string) => Partial & { json: () => Promise }) => {
+ (globalThis as unknown as { fetch: jest.Mock }).fetch = jest.fn((url: string) =>
+ Promise.resolve(impl(String(url)) as Response),
+ );
+};
+
+const okLogin = (roles: AuthRole[] = ['USER']) =>
+ mockFetch(() => ({
+ ok: true,
+ status: 200,
+ json: () =>
+ Promise.resolve({
+ accessToken: 'token-123',
+ expiresIn: 3600,
+ user: { id: 'u1', email: 'analyst@sentinel.test', roles },
+ }),
+ }));
+
+/** Surfaces context state for assertions. */
+const AuthProbe: React.FC = () => {
+ const { status, user, token } = useAuth();
+ return (
+