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 (
+
+ {status}
+ {user?.email ?? 'none'}
+ {token ?? 'none'}
+
+ );
+};
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe('AuthProvider', () => {
+ it('resolves to unauthenticated when storage is empty', async () => {
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('unauthenticated'));
+ });
+
+ it('restores a valid session from storage', async () => {
+ const storage = new MemoryTokenStorage();
+ storage.write(validSession());
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'));
+ expect(screen.getByTestId('user')).toHaveTextContent('analyst@sentinel.test');
+ });
+
+ it('ignores and clears an expired stored session', async () => {
+ const storage = new MemoryTokenStorage();
+ storage.write({ ...validSession(), expiresAt: Date.now() - 1_000 });
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('unauthenticated'));
+ expect(storage.read()).toBeNull();
+ });
+
+ it('signs in and persists the session', async () => {
+ okLogin();
+ const storage = new MemoryTokenStorage();
+
+ render(
+
+
+
+ ,
+ );
+
+ fireEvent.change(screen.getByLabelText(/email/i), {
+ target: { value: 'analyst@sentinel.test' },
+ });
+ fireEvent.change(screen.getByLabelText(/password/i), {
+ target: { value: 'correct-horse' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
+
+ await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'));
+ expect(storage.read()?.accessToken).toBe('token-123');
+ });
+
+ it('converts expiresIn into an absolute expiry', async () => {
+ okLogin();
+ const storage = new MemoryTokenStorage();
+ const before = Date.now();
+
+ render(
+
+
+ ,
+ );
+
+ fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'a@b.c' } });
+ fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'pw' } });
+ fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
+
+ await waitFor(() => expect(storage.read()).not.toBeNull());
+ const expiresAt = storage.read()!.expiresAt;
+ expect(expiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000);
+ });
+
+ it('reports rejected credentials in plain language', async () => {
+ mockFetch(() => ({
+ ok: false,
+ status: 401,
+ json: () => Promise.resolve({ message: 'Unauthorized' }),
+ }));
+
+ render(
+
+
+
+ ,
+ );
+
+ fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'a@b.c' } });
+ fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'wrong' } });
+ fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
+
+ await waitFor(() =>
+ expect(screen.getByRole('alert')).toHaveTextContent(/incorrect email or password/i),
+ );
+ expect(screen.getByTestId('status')).toHaveTextContent('unauthenticated');
+ });
+
+ it('signs out and clears storage', async () => {
+ okLogin();
+ const storage = new MemoryTokenStorage();
+ storage.write(validSession());
+
+ const Controls: React.FC = () => {
+ const { logout } = useAuth();
+ return (
+
+ );
+ };
+
+ render(
+
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'));
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /sign out/i }));
+ });
+
+ expect(screen.getByTestId('status')).toHaveTextContent('unauthenticated');
+ expect(storage.read()).toBeNull();
+ });
+
+ it('signs out locally even when the logout request fails', async () => {
+ mockFetch(() => {
+ throw new Error('network down');
+ });
+
+ const storage = new MemoryTokenStorage();
+ storage.write(validSession());
+
+ const Controls: React.FC = () => {
+ const { logout } = useAuth();
+ return (
+
+ );
+ };
+
+ render(
+
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated'));
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /sign out/i }));
+ });
+
+ expect(screen.getByTestId('status')).toHaveTextContent('unauthenticated');
+ expect(storage.read()).toBeNull();
+ });
+
+ it('throws when useAuth is used outside a provider', () => {
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ expect(() => render()).toThrow(/must be used within an AuthProvider/i);
+ spy.mockRestore();
+ });
+});
+
+describe('ProtectedRoute', () => {
+ const Secret = () => Incident data
;
+
+ it('shows the fallback when signed out', async () => {
+ render(
+
+ Sign in}>
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByText('Sign in')).toBeInTheDocument());
+ expect(screen.queryByText('Incident data')).not.toBeInTheDocument();
+ });
+
+ it('renders children when signed in', async () => {
+ const storage = new MemoryTokenStorage();
+ storage.write(validSession());
+
+ render(
+
+ Sign in}>
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByText('Incident data')).toBeInTheDocument());
+ });
+
+ it('distinguishes lacking permission from being signed out', async () => {
+ const storage = new MemoryTokenStorage();
+ storage.write(validSession(['USER']));
+
+ render(
+
+ Sign in}
+ forbiddenFallback={Administrator access required
}
+ >
+
+
+ ,
+ );
+
+ await waitFor(() =>
+ expect(screen.getByText('Administrator access required')).toBeInTheDocument(),
+ );
+ // Being told to sign in again would be misleading — they already are.
+ expect(screen.queryByText('Sign in')).not.toBeInTheDocument();
+ });
+
+ it('admits a user holding one of the required roles', async () => {
+ const storage = new MemoryTokenStorage();
+ storage.write(validSession(['MODERATOR']));
+
+ render(
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByText('Incident data')).toBeInTheDocument());
+ });
+});
+
+describe('LoginForm', () => {
+ it('asks for both fields before calling the API', async () => {
+ const fetchSpy = jest.fn();
+ (globalThis as unknown as { fetch: jest.Mock }).fetch = fetchSpy;
+
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
+
+ await waitFor(() =>
+ expect(screen.getByRole('alert')).toHaveTextContent(/enter both your email and password/i),
+ );
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('calls onSuccess after signing in', async () => {
+ okLogin();
+ const onSuccess = jest.fn();
+
+ render(
+
+
+ ,
+ );
+
+ fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'a@b.c' } });
+ fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'pw' } });
+ fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
+
+ await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
+ });
+});
diff --git a/apps/web/lib/auth/index.ts b/apps/web/lib/auth/index.ts
new file mode 100644
index 0000000..6941a5f
--- /dev/null
+++ b/apps/web/lib/auth/index.ts
@@ -0,0 +1,11 @@
+export { AuthProvider, useAuth } from './AuthContext';
+export type { AuthContextValue, AuthProviderProps } from './AuthContext';
+export { ProtectedRoute } from './ProtectedRoute';
+export type { ProtectedRouteProps } from './ProtectedRoute';
+export { LoginForm } from './LoginForm';
+export type { LoginFormProps } from './LoginForm';
+export { MemoryTokenStorage, LocalStorageTokenStorage, SESSION_STORAGE_KEY } from './token-storage';
+export type { TokenStorage } from './token-storage';
+export { AuthApiError, hasAnyRole, isSessionValid } from './types';
+export type { AuthRole, AuthSession, AuthStatus, AuthUser, LoginCredentials } from './types';
+export * as authApi from './auth-api';
diff --git a/apps/web/lib/auth/token-storage.spec.ts b/apps/web/lib/auth/token-storage.spec.ts
new file mode 100644
index 0000000..53c3f55
--- /dev/null
+++ b/apps/web/lib/auth/token-storage.spec.ts
@@ -0,0 +1,151 @@
+import { LocalStorageTokenStorage, MemoryTokenStorage, SESSION_STORAGE_KEY } from './token-storage';
+import { AuthSession, hasAnyRole, isSessionValid } from './types';
+
+const session = (overrides: Partial = {}): AuthSession => ({
+ accessToken: 'token-123',
+ expiresAt: Date.now() + 60_000,
+ user: { id: 'u1', email: 'analyst@sentinel.test', roles: ['USER'] },
+ ...overrides,
+});
+
+describe('MemoryTokenStorage', () => {
+ it('round-trips a session', () => {
+ const storage = new MemoryTokenStorage();
+ const value = session();
+
+ storage.write(value);
+ expect(storage.read()).toEqual(value);
+ });
+
+ it('returns null before anything is written', () => {
+ expect(new MemoryTokenStorage().read()).toBeNull();
+ });
+
+ it('clears the session', () => {
+ const storage = new MemoryTokenStorage();
+ storage.write(session());
+ storage.clear();
+ expect(storage.read()).toBeNull();
+ });
+
+ it('does not leak the session to localStorage', () => {
+ const storage = new MemoryTokenStorage();
+ storage.write(session());
+ expect(globalThis.localStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
+ });
+});
+
+describe('LocalStorageTokenStorage', () => {
+ beforeEach(() => {
+ globalThis.localStorage.clear();
+ jest.restoreAllMocks();
+ });
+
+ it('persists a session across instances', () => {
+ const value = session();
+ new LocalStorageTokenStorage().write(value);
+
+ expect(new LocalStorageTokenStorage().read()).toEqual(value);
+ });
+
+ it('treats an expired entry as absent and removes it', () => {
+ const expired = session({ expiresAt: Date.now() - 1_000 });
+ globalThis.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(expired));
+
+ const storage = new LocalStorageTokenStorage();
+ expect(storage.read()).toBeNull();
+ expect(globalThis.localStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
+ });
+
+ it('survives corrupt JSON', () => {
+ globalThis.localStorage.setItem(SESSION_STORAGE_KEY, '{ not json');
+
+ expect(() => new LocalStorageTokenStorage().read()).not.toThrow();
+ expect(new LocalStorageTokenStorage().read()).toBeNull();
+ });
+
+ it('treats an entry without a token as absent', () => {
+ globalThis.localStorage.setItem(
+ SESSION_STORAGE_KEY,
+ JSON.stringify({ expiresAt: Date.now() + 60_000 }),
+ );
+ expect(new LocalStorageTokenStorage().read()).toBeNull();
+ });
+
+ // jsdom's localStorage is not spy-able, so these swap the whole object.
+ const withLocalStorage = (stub: Partial, run: () => void) => {
+ const original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
+ Object.defineProperty(globalThis, 'localStorage', {
+ value: stub,
+ configurable: true,
+ writable: true,
+ });
+ try {
+ run();
+ } finally {
+ if (original) Object.defineProperty(globalThis, 'localStorage', original);
+ }
+ };
+
+ it('does not throw when the quota is exceeded', () => {
+ withLocalStorage(
+ {
+ getItem: () => null,
+ setItem: () => {
+ throw new Error('QuotaExceededError');
+ },
+ removeItem: () => undefined,
+ },
+ () => {
+ expect(() => new LocalStorageTokenStorage().write(session())).not.toThrow();
+ },
+ );
+ });
+
+ it('does not throw when reading is blocked', () => {
+ withLocalStorage(
+ {
+ getItem: () => {
+ throw new Error('SecurityError');
+ },
+ setItem: () => undefined,
+ removeItem: () => undefined,
+ },
+ () => {
+ expect(new LocalStorageTokenStorage().read()).toBeNull();
+ },
+ );
+ });
+});
+
+describe('session helpers', () => {
+ it('rejects a null session', () => {
+ expect(isSessionValid(null)).toBe(false);
+ });
+
+ it('rejects an expired session', () => {
+ expect(isSessionValid(session({ expiresAt: Date.now() - 1 }))).toBe(false);
+ });
+
+ it('accepts a live session', () => {
+ expect(isSessionValid(session())).toBe(true);
+ });
+
+ it('treats an empty role requirement as satisfied', () => {
+ expect(hasAnyRole(null, [])).toBe(true);
+ });
+
+ it('requires a user when roles are demanded', () => {
+ expect(hasAnyRole(null, ['ADMIN'])).toBe(false);
+ });
+
+ it('matches when the user holds one of several roles', () => {
+ const user = { id: 'u1', email: 'a@b.c', roles: ['MODERATOR' as const] };
+ expect(hasAnyRole(user, ['ADMIN', 'MODERATOR'])).toBe(true);
+ });
+
+ it('rejects when the user holds none of them', () => {
+ const user = { id: 'u1', email: 'a@b.c', roles: ['USER' as const] };
+ expect(hasAnyRole(user, ['ADMIN'])).toBe(false);
+ });
+});
diff --git a/apps/web/lib/auth/token-storage.ts b/apps/web/lib/auth/token-storage.ts
new file mode 100644
index 0000000..1682d18
--- /dev/null
+++ b/apps/web/lib/auth/token-storage.ts
@@ -0,0 +1,89 @@
+import { AuthSession, isSessionValid } from './types';
+
+/**
+ * Where the session lives between reads.
+ *
+ * Two implementations ship. The default is in-memory, and that is a deliberate
+ * choice for this product: a bearer token in `localStorage` is readable by any
+ * script that manages to run on the page, so a single XSS becomes full account
+ * takeover of a security console. Keeping it in the JS heap means the token
+ * dies with the tab and is never exposed to `document`-level access.
+ *
+ * The cost is that a page refresh logs the user out. Persisting across reloads
+ * safely needs the refresh token in an httpOnly cookie the frontend cannot
+ * read, which requires backend support that does not exist yet. Until then
+ * `LocalStorageTokenStorage` is available for teams that accept the trade, and
+ * it is opt-in rather than the default.
+ */
+export interface TokenStorage {
+ read(): AuthSession | null;
+ write(session: AuthSession): void;
+ clear(): void;
+}
+
+/** Default. The token never leaves the JS heap. */
+export class MemoryTokenStorage implements TokenStorage {
+ private session: AuthSession | null = null;
+
+ read(): AuthSession | null {
+ return this.session;
+ }
+
+ write(session: AuthSession): void {
+ this.session = session;
+ }
+
+ clear(): void {
+ this.session = null;
+ }
+}
+
+export const SESSION_STORAGE_KEY = 'sentinel.auth.session';
+
+/**
+ * Opt-in persistence across reloads.
+ *
+ * Every access is guarded: `localStorage` throws in private browsing modes and
+ * when the quota is exceeded, and a failure to persist a session must not take
+ * down the login flow.
+ */
+export class LocalStorageTokenStorage implements TokenStorage {
+ constructor(private readonly key: string = SESSION_STORAGE_KEY) {}
+
+ read(): AuthSession | null {
+ try {
+ const raw = globalThis.localStorage?.getItem(this.key);
+ if (!raw) return null;
+
+ const parsed = JSON.parse(raw) as AuthSession;
+ // A malformed or expired entry is treated as no session at all, and is
+ // removed so it cannot be re-read on every mount.
+ if (!parsed?.accessToken || !isSessionValid(parsed)) {
+ this.clear();
+ return null;
+ }
+ return parsed;
+ } catch {
+ // Corrupt JSON, or storage unavailable.
+ this.clear();
+ return null;
+ }
+ }
+
+ write(session: AuthSession): void {
+ try {
+ globalThis.localStorage?.setItem(this.key, JSON.stringify(session));
+ } catch {
+ // Quota exceeded or storage disabled — the in-memory session in
+ // AuthProvider remains authoritative for this tab.
+ }
+ }
+
+ clear(): void {
+ try {
+ globalThis.localStorage?.removeItem(this.key);
+ } catch {
+ // Nothing to do; the caller is discarding the session regardless.
+ }
+ }
+}
diff --git a/apps/web/lib/auth/types.ts b/apps/web/lib/auth/types.ts
new file mode 100644
index 0000000..60cee42
--- /dev/null
+++ b/apps/web/lib/auth/types.ts
@@ -0,0 +1,64 @@
+/**
+ * Frontend authentication types.
+ *
+ * `AuthRole` mirrors the backend `Role` enum in `src/modules/rbac/roles.enum.ts`
+ * so the values the UI gates on are the values the API actually issues.
+ */
+export type AuthRole = 'ADMIN' | 'MODERATOR' | 'USER';
+
+export interface AuthUser {
+ id: string;
+ email: string;
+ name?: string;
+ roles: AuthRole[];
+}
+
+/** A logged-in session. `expiresAt` is epoch milliseconds. */
+export interface AuthSession {
+ accessToken: string;
+ expiresAt: number;
+ user: AuthUser;
+}
+
+export interface LoginCredentials {
+ email: string;
+ password: string;
+}
+
+/**
+ * Resolution state of the session.
+ *
+ * `unknown` matters: on first render the app has not yet inspected storage, and
+ * treating that as "unauthenticated" would flash the login screen at users who
+ * are in fact signed in.
+ */
+export type AuthStatus = 'unknown' | 'authenticated' | 'unauthenticated';
+
+/** Mirrors the shape of `ProfileApiError` used elsewhere in `lib/api`. */
+export class AuthApiError extends Error {
+ constructor(
+ message: string,
+ readonly status: number,
+ ) {
+ super(message);
+ this.name = 'AuthApiError';
+ }
+
+ /** Credentials were rejected, as opposed to the request failing. */
+ get isCredentialFailure(): boolean {
+ return this.status === 401 || this.status === 403;
+ }
+}
+
+/** Session is absent or past its expiry. */
+export const isSessionValid = (
+ session: AuthSession | null,
+ now: number = Date.now(),
+): session is AuthSession => session !== null && session.expiresAt > now;
+
+/** Whether a user holds at least one of the required roles. */
+export const hasAnyRole = (user: AuthUser | null, required: AuthRole[]): boolean => {
+ if (required.length === 0) return true;
+ if (!user) return false;
+ return required.some(role => user.roles.includes(role));
+};