Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions apps/web/lib/auth/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>;
logout: () => Promise<void>;
}

const AuthContext = createContext<AuthContextValue | null>(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<AuthProviderProps> = ({ 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<TokenStorage>(storage ?? new MemoryTokenStorage());

const [session, setSession] = useState<AuthSession | null>(null);
const [status, setStatus] = useState<AuthStatus>('unknown');
const [error, setError] = useState<string | null>(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<boolean> => {
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<void> => {
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<AuthContextValue>(
() => ({
status,
user: session?.user ?? null,
token: session?.accessToken ?? null,
error,
isSubmitting,
login,
logout,
}),
[status, session, error, isSubmitting, login, logout],
);

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

/** 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;
};
80 changes: 80 additions & 0 deletions apps/web/lib/auth/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -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<LoginFormProps> = ({
onSuccess,
heading = 'Sign in to Sentinel',
}) => {
const { login, error, isSubmitting } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [validationError, setValidationError] = useState<string | null>(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 (
<form className="auth-login" onSubmit={handleSubmit} aria-label="Sign in">
<h1 className="auth-login-heading">{heading}</h1>

<label className="auth-field" htmlFor="auth-email">
Email
<input
id="auth-email"
type="email"
value={email}
autoComplete="username"
onChange={event => setEmail(event.target.value)}
/>
</label>

<label className="auth-field" htmlFor="auth-password">
Password
<input
id="auth-password"
type="password"
value={password}
autoComplete="current-password"
onChange={event => setPassword(event.target.value)}
/>
</label>

{message && (
<p className="auth-error" role="alert">
{message}
</p>
)}

<button className="auth-submit" type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
);
};

export default LoginForm;
52 changes: 52 additions & 0 deletions apps/web/lib/auth/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -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<ProtectedRouteProps> = ({
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;
35 changes: 35 additions & 0 deletions apps/web/lib/auth/ProtectedRoute.unknown.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ProtectedRoute loading={<p>Checking session</p>} fallback={<p>Sign in</p>}>
<p>Incident data</p>
</ProtectedRoute>,
);

expect(screen.getByText('Checking session')).toBeInTheDocument();
expect(screen.queryByText('Sign in')).not.toBeInTheDocument();
expect(screen.queryByText('Incident data')).not.toBeInTheDocument();
});
});
81 changes: 81 additions & 0 deletions apps/web/lib/auth/auth-api.ts
Original file line number Diff line number Diff line change
@@ -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<T>(response: Response): Promise<T> {
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<AuthSession> {
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});

const body = await parseResponse<LoginResponse>(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<void> {
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<AuthUser> {
const response = await fetch(`${API_BASE}/auth/me`, {
method: 'GET',
headers: authHeaders(token),
});

return parseResponse<AuthUser>(response);
}
Loading
Loading