feat: sprint 1 — auth + CRUD engagements
Ship the first feature end-to-end on the UI: users log in with JWT,
admins manage user accounts, and any authenticated user (per RBAC)
can create, list, view, edit, and delete engagements.
Backend (Flask + SQLAlchemy + SQLite, 63 pytest)
- User / Engagement models, Alembic 0001 initial schema
- argon2 password hashing, JWT bearer (60-min TTL), @login_required
and @role_required decorators
- 13 API endpoints under /api/*, including last-admin protection on
DELETE/PATCH user and JSON 404 on unknown /api/* paths
- `flask create-admin` CLI with duplicate / short-password handling
Frontend (React + Vite + Tailwind + TanStack Query, 20 vitest)
- Inter font bundled locally (no CDN), DESIGN.md tokens in Tailwind
- LoginPage / EngagementsList / EngagementForm / EngagementDetail /
UsersAdmin pages with role-aware UI
- Layout, ProtectedRoute, StatusBadge, FormField, LoadingState,
ErrorState, EmptyState, Toast + provider
- Axios client: Bearer interceptor, 401 → purge + /login + "Session
expirée" toast, 403 → "Accès refusé" toast (declarative <Navigate>
for already-authed users, Fragment-keyed admin user rows)
Deployment
- Single multistage Dockerfile (node:20-alpine → python:3.12-slim)
- docker/entrypoint.sh runs `flask db upgrade` before `flask run`
- Makefile: build/start/stop/restart/update/logs/create-admin/
update-mitre/test-{backend,frontend,e2e}/clean
- .env.example documenting MIMIC_JWT_SECRET / MIMIC_DB_PATH / MIMIC_PORT
- SQLite at /data/mimic.sqlite on named volume mimic-data
Acceptance suite (Playwright, 36 tests, all 27 ACs)
- e2e/ scaffold with playwright.config + auth/api fixtures
- One spec per user story (us1-bootstrap through us6-deployment)
- Portable via MIMIC_CONTAINER_CMD / MIMIC_BASE_URL (docker or podman)
Docs
- README.md with quick-start and architecture overview
- CHANGELOG.md updated with Sprint 1 deliverables
- pyrightconfig.json so the Python LSP sees backend/.venv and
resolves the `backend.app.*` absolute imports
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
20
frontend/.eslintrc.cjs
Normal file
20
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2022: true, node: true },
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: { jsx: true } },
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
},
|
||||
ignorePatterns: ['dist', 'node_modules', 'coverage', '*.config.js', '*.config.cjs'],
|
||||
};
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mimic — BAS</title>
|
||||
</head>
|
||||
<body class="bg-canvas text-ink">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
7532
frontend/package-lock.json
generated
Normal file
7532
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/package.json
Normal file
45
frontend/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "mimic-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"axios": "^1.7.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/inter": "^5.1.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.8.1",
|
||||
"@typescript-eslint/parser": "^8.8.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios-mock-adapter": "^2.1.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-react": "^7.37.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^2.1.2"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="8" fill="#024ad8"/><text x="50%" y="58%" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="38" font-weight="700" fill="#fff">M</text></svg>
|
||||
|
After Width: | Height: | Size: 254 B |
47
frontend/src/App.tsx
Normal file
47
frontend/src/App.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { ToastViewport } from '@/components/Toast';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { EngagementsListPage } from '@/pages/EngagementsListPage';
|
||||
import { EngagementFormPage } from '@/pages/EngagementFormPage';
|
||||
import { EngagementDetailPage } from '@/pages/EngagementDetailPage';
|
||||
import { UsersAdminPage } from '@/pages/UsersAdminPage';
|
||||
|
||||
/**
|
||||
* Router. Auth + role gates handled by <ProtectedRoute />.
|
||||
* Default `/` redirects to /engagements (guarded — kicks to /login if no token).
|
||||
*/
|
||||
export function App(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* All authenticated routes share the Layout chrome. */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Navigate to="/engagements" replace />} />
|
||||
<Route path="/engagements" element={<EngagementsListPage />} />
|
||||
<Route path="/engagements/:id" element={<EngagementDetailPage />} />
|
||||
|
||||
{/* redteam + admin write actions */}
|
||||
<Route element={<ProtectedRoute roles={['admin', 'redteam']} />}>
|
||||
<Route path="/engagements/new" element={<EngagementFormPage />} />
|
||||
<Route path="/engagements/:id/edit" element={<EngagementFormPage />} />
|
||||
</Route>
|
||||
|
||||
{/* admin-only routes */}
|
||||
<Route element={<ProtectedRoute roles={['admin']} />}>
|
||||
<Route path="/admin/users" element={<UsersAdminPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
<ToastViewport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
frontend/src/api/auth.ts
Normal file
16
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { apiClient } from './client';
|
||||
import type { LoginResponse, User } from './types';
|
||||
|
||||
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||
const { data } = await apiClient.post<LoginResponse>('/auth/login', { username, password });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await apiClient.post('/auth/logout');
|
||||
}
|
||||
|
||||
export async function fetchMe(): Promise<User> {
|
||||
const { data } = await apiClient.get<User>('/auth/me');
|
||||
return data;
|
||||
}
|
||||
69
frontend/src/api/client.ts
Normal file
69
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'mimic.token';
|
||||
|
||||
let memoryToken: string | null = null;
|
||||
|
||||
export function setToken(token: string | null): void {
|
||||
memoryToken = token;
|
||||
if (token) {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (memoryToken) return memoryToken;
|
||||
memoryToken = localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
return memoryToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callbacks the auth/toast layer registers so the interceptor can react
|
||||
* to 401 (purge + redirect to /login + toast).
|
||||
*/
|
||||
type UnauthorizedHandler = () => void;
|
||||
let onUnauthorized: UnauthorizedHandler | null = null;
|
||||
|
||||
export function registerUnauthorizedHandler(handler: UnauthorizedHandler): void {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
setToken(null);
|
||||
onUnauthorized?.();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Extract a user-facing message from a thrown axios error.
|
||||
* Backend uses {error: "<message>"} shape.
|
||||
*/
|
||||
export function extractApiError(err: unknown, fallback = 'Une erreur est survenue'): string {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const data = err.response?.data as { error?: string } | undefined;
|
||||
if (data?.error) return data.error;
|
||||
if (err.message) return err.message;
|
||||
}
|
||||
if (err instanceof Error) return err.message;
|
||||
return fallback;
|
||||
}
|
||||
29
frontend/src/api/engagements.ts
Normal file
29
frontend/src/api/engagements.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { apiClient } from './client';
|
||||
import type { Engagement, EngagementInput } from './types';
|
||||
|
||||
export async function listEngagements(): Promise<Engagement[]> {
|
||||
const { data } = await apiClient.get<Engagement[]>('/engagements');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchEngagement(id: number): Promise<Engagement> {
|
||||
const { data } = await apiClient.get<Engagement>(`/engagements/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createEngagement(input: EngagementInput): Promise<Engagement> {
|
||||
const { data } = await apiClient.post<Engagement>('/engagements', input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function patchEngagement(
|
||||
id: number,
|
||||
input: Partial<EngagementInput>,
|
||||
): Promise<Engagement> {
|
||||
const { data } = await apiClient.patch<Engagement>(`/engagements/${id}`, input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteEngagement(id: number): Promise<void> {
|
||||
await apiClient.delete(`/engagements/${id}`);
|
||||
}
|
||||
54
frontend/src/api/types.ts
Normal file
54
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type Role = 'admin' | 'redteam' | 'soc';
|
||||
|
||||
export type EngagementStatus = 'planned' | 'active' | 'closed';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: Role;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
user: Pick<User, 'id' | 'username' | 'role'>;
|
||||
}
|
||||
|
||||
export interface EngagementCreatedBy {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface Engagement {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
status: EngagementStatus;
|
||||
created_at: string;
|
||||
created_by: EngagementCreatedBy;
|
||||
}
|
||||
|
||||
export interface EngagementInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
start_date: string;
|
||||
end_date?: string | null;
|
||||
status?: EngagementStatus;
|
||||
}
|
||||
|
||||
export interface UserCreateInput {
|
||||
username: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export interface UserPatchInput {
|
||||
role?: Role;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
}
|
||||
21
frontend/src/api/users.ts
Normal file
21
frontend/src/api/users.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { apiClient } from './client';
|
||||
import type { User, UserCreateInput, UserPatchInput } from './types';
|
||||
|
||||
export async function listUsers(): Promise<User[]> {
|
||||
const { data } = await apiClient.get<User[]>('/users');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createUser(input: UserCreateInput): Promise<User> {
|
||||
const { data } = await apiClient.post<User>('/users', input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function patchUser(id: number, input: UserPatchInput): Promise<User> {
|
||||
const { data } = await apiClient.patch<User>(`/users/${id}`, input);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteUser(id: number): Promise<void> {
|
||||
await apiClient.delete(`/users/${id}`);
|
||||
}
|
||||
20
frontend/src/components/EmptyState.tsx
Normal file
20
frontend/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, action }: EmptyStateProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-testid="empty-state"
|
||||
className="card-product flex flex-col items-start gap-md border border-hairline"
|
||||
>
|
||||
<h2 className="text-[24px] font-medium text-ink">{title}</h2>
|
||||
{description ? <p className="text-[16px] text-charcoal">{description}</p> : null}
|
||||
{action ? <div className="pt-xs">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ErrorState.tsx
Normal file
23
frontend/src/components/ErrorState.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
interface ErrorStateProps {
|
||||
title?: string;
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function ErrorState({ title = 'Something went wrong', message, onRetry }: ErrorStateProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="error-state"
|
||||
className="card-product border border-bloom-deep/20 flex flex-col items-start gap-md"
|
||||
>
|
||||
<h2 className="text-[24px] font-medium text-bloom-deep">{title}</h2>
|
||||
<p className="text-[16px] text-charcoal">{message}</p>
|
||||
{onRetry ? (
|
||||
<button type="button" className="btn-outline" onClick={onRetry}>
|
||||
Retry
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/FormField.tsx
Normal file
63
frontend/src/components/FormField.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from 'react';
|
||||
|
||||
interface BaseProps {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
error?: string | null;
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FormField({ label, htmlFor, error, hint, required, children }: BaseProps): JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col gap-xs">
|
||||
<label htmlFor={htmlFor} className="text-[14px] font-medium text-ink">
|
||||
{label}
|
||||
{required ? <span className="text-bloom-deep ml-1">*</span> : null}
|
||||
</label>
|
||||
{children}
|
||||
{error ? (
|
||||
<span role="alert" className="text-[12px] text-bloom-deep">
|
||||
{error}
|
||||
</span>
|
||||
) : hint ? (
|
||||
<span className="text-[12px] text-graphite">{hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>): JSX.Element {
|
||||
return <input {...props} className={`text-input ${props.className ?? ''}`} />;
|
||||
}
|
||||
|
||||
export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>): JSX.Element {
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
className={`text-input min-h-[112px] py-sm ${props.className ?? ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps extends Omit<InputHTMLAttributes<HTMLSelectElement>, 'children'> {
|
||||
options: SelectOption[];
|
||||
}
|
||||
|
||||
export function Select({ options, className, ...rest }: SelectProps): JSX.Element {
|
||||
return (
|
||||
<select {...rest} className={`text-input ${className ?? ''}`}>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
91
frontend/src/components/Layout.tsx
Normal file
91
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
/**
|
||||
* Top utility strip (ink) + main nav (canvas).
|
||||
* Mirrors DESIGN.md utility-strip + nav-bar-top pattern, scaled to internal app.
|
||||
*/
|
||||
export function Layout(): JSX.Element {
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex flex-col bg-canvas">
|
||||
{/* utility-strip — ink slab, fine print */}
|
||||
<div className="bg-ink text-ink-on text-[14px] h-9 flex items-center">
|
||||
<div className="mx-auto w-full max-w-page px-xl flex items-center justify-between">
|
||||
<span className="font-medium tracking-[0.5px] uppercase">Mimic · Purple Team BAS</span>
|
||||
{user ? (
|
||||
<div className="flex items-center gap-md">
|
||||
<span className="text-[12px] uppercase tracking-[0.5px] text-steel">
|
||||
{user.role}
|
||||
</span>
|
||||
<span className="text-[14px]">{user.username}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="text-[14px] underline-offset-2 hover:underline"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* nav-bar-top — canvas with hairline */}
|
||||
<header className="bg-canvas border-b border-hairline">
|
||||
<div className="mx-auto w-full max-w-page px-xl h-16 flex items-center justify-between">
|
||||
<Link to="/engagements" className="flex items-center gap-sm" aria-label="Mimic home">
|
||||
<span className="inline-block h-6 w-6 rotate-12 bg-primary" aria-hidden />
|
||||
<span className="text-[20px] font-medium tracking-tight">Mimic</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-md">
|
||||
<NavLink
|
||||
to="/engagements"
|
||||
className={({ isActive }) =>
|
||||
`text-[16px] py-2 px-md ${
|
||||
isActive ? 'text-ink border-b-2 border-primary -mb-[1px]' : 'text-charcoal'
|
||||
}`
|
||||
}
|
||||
>
|
||||
Engagements
|
||||
</NavLink>
|
||||
{isAdmin ? (
|
||||
<NavLink
|
||||
to="/admin/users"
|
||||
className={({ isActive }) =>
|
||||
`text-[16px] py-2 px-md ${
|
||||
isActive ? 'text-ink border-b-2 border-primary -mb-[1px]' : 'text-charcoal'
|
||||
}`
|
||||
}
|
||||
>
|
||||
Users
|
||||
</NavLink>
|
||||
) : null}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* page body */}
|
||||
<main className="flex-1 bg-canvas">
|
||||
<div className="mx-auto w-full max-w-page px-xl py-xxl">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* footer — ink slab close */}
|
||||
<footer className="bg-ink text-ink-on">
|
||||
<div className="mx-auto w-full max-w-page px-xl py-xl text-[12px] text-steel">
|
||||
Mimic — Internal Purple Team tooling. Authorized engagements only.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
frontend/src/components/LoadingState.tsx
Normal file
13
frontend/src/components/LoadingState.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export function LoadingState({ label = 'Loading…' }: { label?: string }): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="loading-state"
|
||||
className="flex items-center justify-center py-section text-graphite text-[16px]"
|
||||
>
|
||||
<span className="inline-block h-2 w-2 rounded-pill bg-primary animate-pulse mr-sm" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/ProtectedRoute.tsx
Normal file
51
frontend/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { Role } from '@/api/types';
|
||||
import { LoadingState } from './LoadingState';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
/** Allowed roles. If omitted, any authenticated user passes. */
|
||||
roles?: Role[];
|
||||
/** Where to send users who lack the required role. */
|
||||
redirectOnRoleDenied?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate component: handles auth + role checks before rendering nested routes.
|
||||
* - No token / no user → /login
|
||||
* - Wrong role → redirect + "Accès refusé" toast (AC-3.7)
|
||||
*/
|
||||
export function ProtectedRoute({
|
||||
roles,
|
||||
redirectOnRoleDenied = '/engagements',
|
||||
}: ProtectedRouteProps): JSX.Element {
|
||||
const { user, status } = useAuth();
|
||||
const { push } = useToast();
|
||||
const location = useLocation();
|
||||
|
||||
const roleDenied = Boolean(
|
||||
status === 'authenticated' && user && roles && !roles.includes(user.role),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (roleDenied) {
|
||||
push('Accès refusé', 'error');
|
||||
}
|
||||
}, [roleDenied, push]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return <LoadingState label="Loading session…" />;
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated' || !user) {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
if (roleDenied) {
|
||||
return <Navigate to={redirectOnRoleDenied} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
27
frontend/src/components/StatusBadge.tsx
Normal file
27
frontend/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { EngagementStatus } from '@/api/types';
|
||||
|
||||
const LABELS: Record<EngagementStatus, string> = {
|
||||
planned: 'Planned',
|
||||
active: 'Active',
|
||||
closed: 'Closed',
|
||||
};
|
||||
|
||||
const STYLES: Record<EngagementStatus, string> = {
|
||||
// Outlined ink for planned (neutral), filled primary for active (engagement live),
|
||||
// outlined steel for closed (muted). Stays within DESIGN.md palette.
|
||||
planned: 'bg-canvas text-ink border border-ink',
|
||||
active: 'bg-primary text-ink-on',
|
||||
closed: 'bg-cloud text-graphite border border-hairline',
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: EngagementStatus }): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`}
|
||||
data-testid="status-badge"
|
||||
data-status={status}
|
||||
>
|
||||
{LABELS[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/Toast.tsx
Normal file
47
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
/**
|
||||
* Stack of toast notifications anchored bottom-right.
|
||||
* Pure DESIGN.md surfaces: rounded-xl, soft-lift, ink slab for errors.
|
||||
*/
|
||||
export function ToastViewport(): JSX.Element {
|
||||
const { toasts, dismiss } = useToast();
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="fixed bottom-xl right-xl z-50 flex flex-col gap-sm w-[320px] pointer-events-none"
|
||||
>
|
||||
{toasts.map((t) => {
|
||||
const isError = t.kind === 'error';
|
||||
const isSuccess = t.kind === 'success';
|
||||
const surface = isError
|
||||
? 'bg-ink text-ink-on'
|
||||
: isSuccess
|
||||
? 'bg-primary text-ink-on'
|
||||
: 'bg-canvas text-ink border border-hairline';
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
role="status"
|
||||
data-testid="toast"
|
||||
data-kind={t.kind}
|
||||
className={`pointer-events-auto rounded-xl px-md py-sm shadow-soft-lift text-[14px] leading-[1.4] ${surface}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-sm">
|
||||
<span className="flex-1">{t.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(t.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="text-current opacity-70 hover:opacity-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
frontend/src/hooks/useAuth.tsx
Normal file
112
frontend/src/hooks/useAuth.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { login as apiLogin, logout as apiLogout, fetchMe } from '@/api/auth';
|
||||
import { getToken, registerUnauthorizedHandler, setToken } from '@/api/client';
|
||||
import type { Role, User } from '@/api/types';
|
||||
import { useToast } from './useToast';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
status: 'loading' | 'authenticated' | 'unauthenticated';
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
isAdmin: boolean;
|
||||
isRedteam: boolean;
|
||||
isSoc: boolean;
|
||||
canEditEngagements: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }): JSX.Element {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'authenticated' | 'unauthenticated'>('loading');
|
||||
const { push } = useToast();
|
||||
|
||||
// Bootstrap: if a token exists in storage, try to recover the session.
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetchMe()
|
||||
.then((u) => {
|
||||
if (cancelled) return;
|
||||
setUser(u);
|
||||
setStatus('authenticated');
|
||||
})
|
||||
.catch(() => {
|
||||
// 401 interceptor already purges token + triggers redirect/toast.
|
||||
if (cancelled) return;
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Register the global 401 handler once.
|
||||
useEffect(() => {
|
||||
registerUnauthorizedHandler(() => {
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
push('Session expirée', 'error');
|
||||
// Hard redirect so any in-flight query state is cleared.
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.assign('/login');
|
||||
}
|
||||
});
|
||||
}, [push]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const resp = await apiLogin(username, password);
|
||||
setToken(resp.access_token);
|
||||
// Hydrate full user (with created_at) via /me.
|
||||
const me = await fetchMe();
|
||||
setUser(me);
|
||||
setStatus('authenticated');
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await apiLogout();
|
||||
} catch {
|
||||
// Logout is best-effort server-side; the client purge below is what matters.
|
||||
}
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(() => {
|
||||
const role: Role | undefined = user?.role;
|
||||
return {
|
||||
user,
|
||||
status,
|
||||
login,
|
||||
logout,
|
||||
isAdmin: role === 'admin',
|
||||
isRedteam: role === 'redteam',
|
||||
isSoc: role === 'soc',
|
||||
canEditEngagements: role === 'admin' || role === 'redteam',
|
||||
};
|
||||
}, [user, status, login, logout]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
50
frontend/src/hooks/useEngagements.ts
Normal file
50
frontend/src/hooks/useEngagements.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
createEngagement,
|
||||
deleteEngagement,
|
||||
fetchEngagement,
|
||||
listEngagements,
|
||||
patchEngagement,
|
||||
} from '@/api/engagements';
|
||||
import type { EngagementInput } from '@/api/types';
|
||||
|
||||
const KEY = ['engagements'] as const;
|
||||
|
||||
export function useEngagementsList() {
|
||||
return useQuery({ queryKey: KEY, queryFn: listEngagements });
|
||||
}
|
||||
|
||||
export function useEngagement(id: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...KEY, id],
|
||||
queryFn: () => fetchEngagement(id as number),
|
||||
enabled: typeof id === 'number' && !Number.isNaN(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateEngagement() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: EngagementInput) => createEngagement(input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePatchEngagement(id: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: Partial<EngagementInput>) => patchEngagement(id, input),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: KEY });
|
||||
qc.invalidateQueries({ queryKey: [...KEY, id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteEngagement() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteEngagement(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
58
frontend/src/hooks/useToast.tsx
Normal file
58
frontend/src/hooks/useToast.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
export type ToastKind = 'info' | 'success' | 'error';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
kind: ToastKind;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: Toast[];
|
||||
push: (message: string, kind?: ToastKind) => void;
|
||||
dismiss: (id: number) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
const DEFAULT_DURATION_MS = 4000;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }): JSX.Element {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const counterRef = useRef(0);
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const push = useCallback(
|
||||
(message: string, kind: ToastKind = 'info') => {
|
||||
counterRef.current += 1;
|
||||
const id = counterRef.current;
|
||||
setToasts((prev) => [...prev, { id, message, kind }]);
|
||||
setTimeout(() => dismiss(id), DEFAULT_DURATION_MS);
|
||||
},
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]);
|
||||
|
||||
return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useToast must be used inside ToastProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
33
frontend/src/hooks/useUsers.ts
Normal file
33
frontend/src/hooks/useUsers.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createUser, deleteUser, listUsers, patchUser } from '@/api/users';
|
||||
import type { UserCreateInput, UserPatchInput } from '@/api/types';
|
||||
|
||||
const KEY = ['users'] as const;
|
||||
|
||||
export function useUsersList() {
|
||||
return useQuery({ queryKey: KEY, queryFn: listUsers });
|
||||
}
|
||||
|
||||
export function useCreateUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: UserCreateInput) => createUser(input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePatchUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: UserPatchInput }) => patchUser(id, input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteUser(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
35
frontend/src/main.tsx
Normal file
35
frontend/src/main.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App';
|
||||
import { AuthProvider } from './hooks/useAuth';
|
||||
import { ToastProvider } from './hooks/useToast';
|
||||
import './styles/index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root) throw new Error('Missing #root element');
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
84
frontend/src/pages/EngagementDetailPage.tsx
Normal file
84
frontend/src/pages/EngagementDetailPage.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useEngagement } from '@/hooks/useEngagements';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
|
||||
export function EngagementDetailPage(): JSX.Element {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const numericId = id ? Number(id) : undefined;
|
||||
const { canEditEngagements } = useAuth();
|
||||
|
||||
const detail = useEngagement(numericId);
|
||||
|
||||
if (detail.isLoading) return <LoadingState label="Loading engagement…" />;
|
||||
if (detail.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(detail.error, 'Could not load engagement')}
|
||||
onRetry={() => detail.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!detail.data) return <ErrorState message="Engagement not found" />;
|
||||
|
||||
const eng = detail.data;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-start justify-between gap-md">
|
||||
<div className="flex flex-col gap-sm">
|
||||
<Link to="/engagements" className="btn-text-link text-[14px]">
|
||||
← Back to engagements
|
||||
</Link>
|
||||
<h1 className="text-[44px] font-medium leading-none">{eng.name}</h1>
|
||||
<div className="flex items-center gap-md">
|
||||
<StatusBadge status={eng.status} />
|
||||
<span className="text-[14px] text-graphite">
|
||||
Created by <span className="text-ink">{eng.created_by.username}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{canEditEngagements ? (
|
||||
<Link to={`/engagements/${eng.id}/edit`} className="btn-outline">
|
||||
Edit
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-md">
|
||||
<div className="card-product">
|
||||
<h2 className="text-[20px] font-medium mb-md">Schedule</h2>
|
||||
<dl className="grid grid-cols-2 gap-md text-[14px]">
|
||||
<dt className="text-graphite">Start date</dt>
|
||||
<dd className="text-ink">{eng.start_date}</dd>
|
||||
<dt className="text-graphite">End date</dt>
|
||||
<dd className="text-ink">{eng.end_date ?? '—'}</dd>
|
||||
<dt className="text-graphite">Status</dt>
|
||||
<dd className="text-ink capitalize">{eng.status}</dd>
|
||||
<dt className="text-graphite">Created at</dt>
|
||||
<dd className="text-ink">{eng.created_at}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="card-product">
|
||||
<h2 className="text-[20px] font-medium mb-md">Description</h2>
|
||||
<p className="text-[16px] text-charcoal whitespace-pre-line">
|
||||
{eng.description?.trim() ? eng.description : 'No description provided.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sprint 2 placeholder per AC-4.9 */}
|
||||
<section className="bg-ink text-ink-on rounded-xl p-xxl">
|
||||
<h2 className="text-[32px] font-medium leading-none">Simulations</h2>
|
||||
<p className="text-[16px] mt-sm text-steel">
|
||||
Simulations à venir au Sprint 2 — tracking of red team tests and SOC detection coverage
|
||||
will live here.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
frontend/src/pages/EngagementFormPage.tsx
Normal file
219
frontend/src/pages/EngagementFormPage.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { EngagementInput, EngagementStatus } from '@/api/types';
|
||||
import {
|
||||
useCreateEngagement,
|
||||
useEngagement,
|
||||
usePatchEngagement,
|
||||
} from '@/hooks/useEngagements';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { FormField, Select, TextArea, TextInput } from '@/components/FormField';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
|
||||
const STATUS_OPTIONS: { value: EngagementStatus; label: string }[] = [
|
||||
{ value: 'planned', label: 'Planned' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'closed', label: 'Closed' },
|
||||
];
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
description: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status: EngagementStatus;
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
name: '',
|
||||
description: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
status: 'planned',
|
||||
};
|
||||
|
||||
function validate(state: FormState): Partial<Record<keyof FormState, string>> {
|
||||
const errors: Partial<Record<keyof FormState, string>> = {};
|
||||
if (!state.name.trim()) errors.name = 'Name is required';
|
||||
if (!state.start_date) errors.start_date = 'Start date is required';
|
||||
if (state.end_date && state.start_date && state.end_date < state.start_date) {
|
||||
errors.end_date = 'End date must be on or after start date';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function EngagementFormPage(): JSX.Element {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const editing = Boolean(id);
|
||||
const numericId = id ? Number(id) : undefined;
|
||||
const navigate = useNavigate();
|
||||
const { push } = useToast();
|
||||
|
||||
const detail = useEngagement(editing ? numericId : undefined);
|
||||
const createMutation = useCreateEngagement();
|
||||
const patchMutation = usePatchEngagement(numericId ?? 0);
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
// Hydrate edit form when data arrives.
|
||||
useEffect(() => {
|
||||
if (editing && detail.data) {
|
||||
setForm({
|
||||
name: detail.data.name,
|
||||
description: detail.data.description ?? '',
|
||||
start_date: detail.data.start_date,
|
||||
end_date: detail.data.end_date ?? '',
|
||||
status: detail.data.status,
|
||||
});
|
||||
}
|
||||
}, [editing, detail.data]);
|
||||
|
||||
if (editing && detail.isLoading) return <LoadingState label="Loading engagement…" />;
|
||||
if (editing && detail.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={extractApiError(detail.error, 'Could not load engagement')}
|
||||
onRetry={() => detail.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitError(null);
|
||||
const v = validate(form);
|
||||
setErrors(v);
|
||||
if (Object.keys(v).length > 0) return;
|
||||
|
||||
const payload: EngagementInput = {
|
||||
name: form.name.trim(),
|
||||
start_date: form.start_date,
|
||||
status: form.status,
|
||||
};
|
||||
if (form.description.trim()) payload.description = form.description.trim();
|
||||
// PATCH with null clears end_date; POST with omitted leaves it null
|
||||
if (editing) {
|
||||
// Always include end_date for edit: '' → null to clear, otherwise value
|
||||
payload.end_date = form.end_date === '' ? null : form.end_date;
|
||||
} else if (form.end_date) {
|
||||
payload.end_date = form.end_date;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editing && numericId) {
|
||||
await patchMutation.mutateAsync(payload);
|
||||
push('Engagement updated', 'success');
|
||||
navigate(`/engagements/${numericId}`);
|
||||
} else {
|
||||
const created = await createMutation.mutateAsync(payload);
|
||||
push('Engagement created', 'success');
|
||||
navigate(`/engagements/${created.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not save engagement'));
|
||||
}
|
||||
};
|
||||
|
||||
const submitting = createMutation.isPending || patchMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl max-w-2xl">
|
||||
<header>
|
||||
<h1 className="text-[44px] font-medium leading-none">
|
||||
{editing ? 'Edit engagement' : 'New engagement'}
|
||||
</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
{editing
|
||||
? 'Update the engagement metadata.'
|
||||
: 'Create a new red team mission to host simulations.'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onSubmit} noValidate className="card-product flex flex-col gap-md">
|
||||
<FormField label="Name" htmlFor="eng-name" required error={errors.name}>
|
||||
<TextInput
|
||||
id="eng-name"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Description" htmlFor="eng-description">
|
||||
<TextArea
|
||||
id="eng-description"
|
||||
name="description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
|
||||
<FormField
|
||||
label="Start date"
|
||||
htmlFor="eng-start"
|
||||
required
|
||||
error={errors.start_date}
|
||||
>
|
||||
<TextInput
|
||||
id="eng-start"
|
||||
type="date"
|
||||
name="start_date"
|
||||
value={form.start_date}
|
||||
onChange={(e) => setForm({ ...form, start_date: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="End date"
|
||||
htmlFor="eng-end"
|
||||
hint="Leave empty to clear / leave open-ended"
|
||||
error={errors.end_date}
|
||||
>
|
||||
<TextInput
|
||||
id="eng-end"
|
||||
type="date"
|
||||
name="end_date"
|
||||
value={form.end_date}
|
||||
onChange={(e) => setForm({ ...form, end_date: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Status" htmlFor="eng-status" required>
|
||||
<Select
|
||||
id="eng-status"
|
||||
name="status"
|
||||
value={form.status}
|
||||
onChange={(e) => setForm({ ...form, status: e.target.value as EngagementStatus })}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{submitError ? (
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">
|
||||
{submitError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-md pt-sm">
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Saving…' : editing ? 'Save changes' : 'Create engagement'}
|
||||
</button>
|
||||
<Link
|
||||
to={editing && numericId ? `/engagements/${numericId}` : '/engagements'}
|
||||
className="btn-outline-ink"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
frontend/src/pages/EngagementsListPage.tsx
Normal file
126
frontend/src/pages/EngagementsListPage.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { Engagement } from '@/api/types';
|
||||
import { useDeleteEngagement, useEngagementsList } from '@/hooks/useEngagements';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return '—';
|
||||
return value;
|
||||
}
|
||||
|
||||
export function EngagementsListPage(): JSX.Element {
|
||||
const { data, isLoading, isError, error, refetch } = useEngagementsList();
|
||||
const { canEditEngagements } = useAuth();
|
||||
const { push } = useToast();
|
||||
const deleteMutation = useDeleteEngagement();
|
||||
|
||||
const onDelete = async (eng: Engagement) => {
|
||||
if (!window.confirm(`Delete engagement "${eng.name}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(eng.id);
|
||||
push('Engagement supprimé', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Suppression impossible'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header className="flex items-end justify-between gap-md">
|
||||
<div>
|
||||
<h1 className="text-[44px] font-medium leading-none">Engagements</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
Red team missions and their lifecycle status.
|
||||
</p>
|
||||
</div>
|
||||
{canEditEngagements ? (
|
||||
<Link to="/engagements/new" className="btn-primary">
|
||||
New engagement
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{isLoading ? <LoadingState label="Loading engagements…" /> : null}
|
||||
|
||||
{isError ? (
|
||||
<ErrorState message={extractApiError(error, 'Could not load engagements')} onRetry={() => refetch()} />
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && data && data.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No engagements yet"
|
||||
description="Create your first engagement to start tracking red team missions."
|
||||
action={
|
||||
canEditEngagements ? (
|
||||
<Link to="/engagements/new" className="btn-primary">
|
||||
Create engagement
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && data && data.length > 0 ? (
|
||||
<div className="card-product overflow-hidden p-0">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
||||
<th className="px-xl py-md">Name</th>
|
||||
<th className="px-xl py-md">Status</th>
|
||||
<th className="px-xl py-md">Start</th>
|
||||
<th className="px-xl py-md">End</th>
|
||||
<th className="px-xl py-md">Created by</th>
|
||||
<th className="px-xl py-md text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((eng) => (
|
||||
<tr key={eng.id} className="border-b border-hairline last:border-0">
|
||||
<td className="px-xl py-md">
|
||||
<Link to={`/engagements/${eng.id}`} className="text-ink font-medium hover:underline">
|
||||
{eng.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-xl py-md">
|
||||
<StatusBadge status={eng.status} />
|
||||
</td>
|
||||
<td className="px-xl py-md text-charcoal">{formatDate(eng.start_date)}</td>
|
||||
<td className="px-xl py-md text-charcoal">{formatDate(eng.end_date)}</td>
|
||||
<td className="px-xl py-md text-charcoal">{eng.created_by.username}</td>
|
||||
<td className="px-xl py-md text-right">
|
||||
<div className="inline-flex gap-sm">
|
||||
<Link to={`/engagements/${eng.id}`} className="btn-text-link">
|
||||
View
|
||||
</Link>
|
||||
{canEditEngagements ? (
|
||||
<>
|
||||
<Link to={`/engagements/${eng.id}/edit`} className="btn-text-link">
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link text-bloom-deep"
|
||||
onClick={() => onDelete(eng)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/pages/LoginPage.tsx
Normal file
90
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { FormField, TextInput } from '@/components/FormField';
|
||||
|
||||
interface LocationState {
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export function LoginPage(): JSX.Element {
|
||||
const { login, status, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const fromPath = (location.state as LocationState | null)?.from;
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Already authenticated → bounce to engagements (e.g. user navigates back to /login).
|
||||
// Returning <Navigate> instead of calling navigate() during render avoids the
|
||||
// "Cannot update a component while rendering a different component" warning.
|
||||
if (status === 'authenticated' && user) {
|
||||
return <Navigate to={fromPath ?? '/engagements'} replace />;
|
||||
}
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
navigate(fromPath ?? '/engagements', { replace: true });
|
||||
} catch (err) {
|
||||
setError(extractApiError(err, 'Invalid credentials'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-cloud flex items-center justify-center px-md">
|
||||
<div className="w-full max-w-md card-product flex flex-col gap-lg">
|
||||
{/* Chevron echo of the brand mark */}
|
||||
<div className="flex items-center gap-sm">
|
||||
<span className="inline-block h-8 w-8 rotate-12 bg-primary" aria-hidden />
|
||||
<h1 className="text-[32px] font-medium leading-none">Mimic</h1>
|
||||
</div>
|
||||
<p className="text-[16px] text-charcoal">Sign in to access your engagements.</p>
|
||||
|
||||
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-md">
|
||||
<FormField label="Username" htmlFor="login-username" required>
|
||||
<TextInput
|
||||
id="login-username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" htmlFor="login-password" required>
|
||||
<TextInput
|
||||
id="login-password"
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{error ? (
|
||||
<div role="alert" data-testid="login-error" className="text-[14px] text-bloom-deep">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
276
frontend/src/pages/UsersAdminPage.tsx
Normal file
276
frontend/src/pages/UsersAdminPage.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { Fragment, useState, type FormEvent } from 'react';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { Role, User } from '@/api/types';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import {
|
||||
useCreateUser,
|
||||
useDeleteUser,
|
||||
usePatchUser,
|
||||
useUsersList,
|
||||
} from '@/hooks/useUsers';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { FormField, Select, TextInput } from '@/components/FormField';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
|
||||
const ROLE_OPTIONS: { value: Role; label: string }[] = [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'redteam', label: 'Red Team' },
|
||||
{ value: 'soc', label: 'SOC' },
|
||||
];
|
||||
|
||||
interface CreateFormState {
|
||||
username: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
const EMPTY_CREATE: CreateFormState = { username: '', password: '', role: 'redteam' };
|
||||
|
||||
export function UsersAdminPage(): JSX.Element {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { push } = useToast();
|
||||
const list = useUsersList();
|
||||
const createMutation = useCreateUser();
|
||||
const patchMutation = usePatchUser();
|
||||
const deleteMutation = useDeleteUser();
|
||||
|
||||
const [createForm, setCreateForm] = useState<CreateFormState>(EMPTY_CREATE);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
// Per-row password reset state. Only one row open at a time.
|
||||
const [resetOpen, setResetOpen] = useState<number | null>(null);
|
||||
const [resetPassword, setResetPassword] = useState('');
|
||||
|
||||
const onCreate = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreateError(null);
|
||||
if (createForm.password.length < 8) {
|
||||
setCreateError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync(createForm);
|
||||
setCreateForm(EMPTY_CREATE);
|
||||
push('User created', 'success');
|
||||
} catch (err) {
|
||||
setCreateError(extractApiError(err, 'Could not create user'));
|
||||
}
|
||||
};
|
||||
|
||||
const onRoleChange = async (u: User, role: Role) => {
|
||||
if (u.role === role) return;
|
||||
try {
|
||||
await patchMutation.mutateAsync({ id: u.id, input: { role } });
|
||||
push(`Role updated for ${u.username}`, 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not update role'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onResetPassword = async (u: User, e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (resetPassword.length < 8) {
|
||||
push('Password must be at least 8 characters', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await patchMutation.mutateAsync({ id: u.id, input: { password: resetPassword } });
|
||||
push(`Password reset for ${u.username}`, 'success');
|
||||
setResetOpen(null);
|
||||
setResetPassword('');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not reset password'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (u: User) => {
|
||||
if (currentUser?.id === u.id) {
|
||||
push('You cannot delete your own account', 'error');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Delete user "${u.username}"?`)) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(u.id);
|
||||
push('User deleted', 'success');
|
||||
} catch (err) {
|
||||
push(extractApiError(err, 'Could not delete user'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-xl">
|
||||
<header>
|
||||
<h1 className="text-[44px] font-medium leading-none">User accounts</h1>
|
||||
<p className="text-charcoal text-[16px] mt-sm">
|
||||
Manage local accounts. Admins can create new red team or SOC analysts.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="card-product flex flex-col gap-md">
|
||||
<h2 className="text-[20px] font-medium">Create account</h2>
|
||||
<form onSubmit={onCreate} className="grid grid-cols-1 md:grid-cols-4 gap-md items-end">
|
||||
<FormField label="Username" htmlFor="new-username" required>
|
||||
<TextInput
|
||||
id="new-username"
|
||||
value={createForm.username}
|
||||
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Password" htmlFor="new-password" required hint="≥ 8 characters">
|
||||
<TextInput
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={createForm.password}
|
||||
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Role" htmlFor="new-role" required>
|
||||
<Select
|
||||
id="new-role"
|
||||
value={createForm.role}
|
||||
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value as Role })}
|
||||
options={ROLE_OPTIONS}
|
||||
/>
|
||||
</FormField>
|
||||
<button type="submit" className="btn-primary" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
{createError ? (
|
||||
<div role="alert" className="text-[14px] text-bloom-deep">
|
||||
{createError}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-md">
|
||||
<h2 className="text-[20px] font-medium">All accounts</h2>
|
||||
|
||||
{list.isLoading ? <LoadingState label="Loading users…" /> : null}
|
||||
{list.isError ? (
|
||||
<ErrorState
|
||||
message={extractApiError(list.error, 'Could not load users')}
|
||||
onRetry={() => list.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{!list.isLoading && !list.isError && list.data && list.data.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No users yet"
|
||||
description="Create the first account using the form above."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!list.isLoading && !list.isError && list.data && list.data.length > 0 ? (
|
||||
<div className="card-product overflow-hidden p-0">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-cloud border-b border-hairline">
|
||||
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
|
||||
<th className="px-xl py-md">Username</th>
|
||||
<th className="px-xl py-md">Role</th>
|
||||
<th className="px-xl py-md">Created</th>
|
||||
<th className="px-xl py-md text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data.map((u) => {
|
||||
const isSelf = currentUser?.id === u.id;
|
||||
return (
|
||||
// Fragment must carry the key — `<>` cannot, which broke
|
||||
// per-row reconciliation (reset-password state leaked across rows).
|
||||
<Fragment key={u.id}>
|
||||
<tr className="border-b border-hairline last:border-0">
|
||||
<td className="px-xl py-md font-medium text-ink">
|
||||
{u.username}
|
||||
{isSelf ? (
|
||||
<span className="ml-sm text-[12px] text-graphite uppercase tracking-[0.5px]">
|
||||
(you)
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-xl py-md">
|
||||
<Select
|
||||
value={u.role}
|
||||
onChange={(e) => onRoleChange(u, e.target.value as Role)}
|
||||
options={ROLE_OPTIONS}
|
||||
aria-label={`Change role for ${u.username}`}
|
||||
disabled={patchMutation.isPending}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-xl py-md text-charcoal">{u.created_at}</td>
|
||||
<td className="px-xl py-md text-right">
|
||||
<div className="inline-flex gap-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link"
|
||||
onClick={() => {
|
||||
setResetOpen(resetOpen === u.id ? null : u.id);
|
||||
setResetPassword('');
|
||||
}}
|
||||
>
|
||||
Reset password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-link text-bloom-deep disabled:text-steel"
|
||||
disabled={isSelf || deleteMutation.isPending}
|
||||
onClick={() => onDelete(u)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{resetOpen === u.id ? (
|
||||
<tr className="border-b border-hairline last:border-0 bg-cloud">
|
||||
<td colSpan={4} className="px-xl py-md">
|
||||
<form
|
||||
onSubmit={(e) => onResetPassword(u, e)}
|
||||
className="flex items-end gap-md"
|
||||
>
|
||||
<FormField
|
||||
label={`New password for ${u.username}`}
|
||||
htmlFor={`reset-${u.id}`}
|
||||
hint="≥ 8 characters"
|
||||
>
|
||||
<TextInput
|
||||
id={`reset-${u.id}`}
|
||||
type="password"
|
||||
value={resetPassword}
|
||||
onChange={(e) => setResetPassword(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<button type="submit" className="btn-primary">
|
||||
Save password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink"
|
||||
onClick={() => {
|
||||
setResetOpen(null);
|
||||
setResetPassword('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
frontend/src/styles/fonts.css
Normal file
6
frontend/src/styles/fonts.css
Normal file
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
* Inter Variable — bundled locally via @fontsource-variable/inter.
|
||||
* NO remote CDN / Google Fonts loading at runtime.
|
||||
* Forma DJR Micro substitute per DESIGN.md §Note on Font Substitutes.
|
||||
*/
|
||||
@import '@fontsource-variable/inter/index.css';
|
||||
94
frontend/src/styles/index.css
Normal file
94
frontend/src/styles/index.css
Normal file
@@ -0,0 +1,94 @@
|
||||
@import './fonts.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-canvas text-ink font-sans antialiased;
|
||||
/* DESIGN.md: body line-height 1.4 when substituting Inter */
|
||||
font-feature-settings: 'cv11', 'ss01';
|
||||
}
|
||||
|
||||
/* Compensate for Inter being slightly narrower than Forma DJR Micro (~3%) */
|
||||
:root {
|
||||
font-size: 16.5px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/*
|
||||
* DESIGN.md component recipes.
|
||||
* Buttons stay sharp (rounded-md = 4px); cards stay soft (rounded-xl = 16px).
|
||||
*/
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center bg-primary text-ink-on uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
@apply bg-primary-deep;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
@apply bg-steel cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-ink {
|
||||
@apply inline-flex items-center justify-center bg-ink text-ink-on uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||
}
|
||||
.btn-ink:hover {
|
||||
@apply bg-ink-soft;
|
||||
}
|
||||
.btn-ink:disabled {
|
||||
@apply bg-steel cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply inline-flex items-center justify-center bg-canvas text-primary border border-primary uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||
}
|
||||
.btn-outline:hover {
|
||||
@apply bg-primary-soft;
|
||||
}
|
||||
|
||||
.btn-outline-ink {
|
||||
@apply inline-flex items-center justify-center bg-canvas text-ink border border-ink uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
|
||||
}
|
||||
.btn-outline-ink:hover {
|
||||
@apply bg-cloud;
|
||||
}
|
||||
|
||||
.btn-text-link {
|
||||
@apply inline-flex items-center text-primary font-medium text-[16px] leading-[1.38] underline-offset-2 hover:underline;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
@apply block w-full bg-canvas text-ink rounded-md border border-steel px-md py-sm h-11 text-[16px] leading-[1.38] focus:outline-none focus:border-ink;
|
||||
}
|
||||
|
||||
.card-product {
|
||||
@apply bg-canvas rounded-xl p-xl shadow-soft-lift;
|
||||
}
|
||||
|
||||
.badge-pill-ink {
|
||||
@apply inline-flex items-center bg-ink text-ink-on rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
|
||||
}
|
||||
|
||||
.badge-pill-outline {
|
||||
@apply inline-flex items-center bg-canvas text-ink border border-ink rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
|
||||
}
|
||||
}
|
||||
101
frontend/tailwind.config.ts
Normal file
101
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
/**
|
||||
* Tokens mirror DESIGN.md.
|
||||
* Forma DJR Micro substitut: Inter (bundled locally via @fontsource-variable/inter).
|
||||
*/
|
||||
const config: Config = {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Brand & Accent
|
||||
primary: {
|
||||
DEFAULT: '#024ad8',
|
||||
bright: '#296ef9',
|
||||
deep: '#0e3191',
|
||||
soft: '#c9e0fc',
|
||||
},
|
||||
// Surface
|
||||
canvas: '#ffffff',
|
||||
paper: '#ffffff',
|
||||
cloud: '#f7f7f7',
|
||||
fog: '#e8e8e8',
|
||||
steel: '#c2c2c2',
|
||||
hairline: '#e8e8e8',
|
||||
// Text
|
||||
ink: {
|
||||
DEFAULT: '#1a1a1a',
|
||||
deep: '#000000',
|
||||
soft: '#292929',
|
||||
on: '#ffffff',
|
||||
},
|
||||
charcoal: '#3d3d3d',
|
||||
graphite: '#636363',
|
||||
// Semantic / decorative
|
||||
bloom: {
|
||||
coral: '#ff5050',
|
||||
rose: '#f9d4d2',
|
||||
deep: '#b3262b',
|
||||
wine: '#5a1313',
|
||||
},
|
||||
storm: {
|
||||
mist: '#8ebdce',
|
||||
sea: '#7fadbe',
|
||||
deep: '#356373',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Inter Variable"', 'Inter', 'Arial', 'sans-serif'],
|
||||
},
|
||||
fontSize: {
|
||||
// DESIGN.md typography scale
|
||||
'display-xxl': ['72px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'display-xl': ['56px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'display-lg': ['44px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'display-md': ['32px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'display-sm': ['24px', { lineHeight: '1.17', fontWeight: '500' }],
|
||||
'display-xs': ['20px', { lineHeight: '1.0', fontWeight: '500' }],
|
||||
'body-lg': ['18px', { lineHeight: '1.33', fontWeight: '400' }],
|
||||
'body-md': ['16px', { lineHeight: '1.38', fontWeight: '400' }],
|
||||
'body-emphasis': ['16px', { lineHeight: '1.38', fontWeight: '500' }],
|
||||
'caption-md': ['14px', { lineHeight: '1.5', fontWeight: '400' }],
|
||||
'caption-bold': ['14px', { lineHeight: '1.3', fontWeight: '700' }],
|
||||
'caption-sm': ['12px', { lineHeight: '1.33', fontWeight: '400' }],
|
||||
'link-md': ['16px', { lineHeight: '1.38', fontWeight: '500' }],
|
||||
'button-md': ['14px', { lineHeight: '1.4', fontWeight: '600', letterSpacing: '0.7px' }],
|
||||
},
|
||||
spacing: {
|
||||
// DESIGN.md spacing tokens (named, complement Tailwind defaults)
|
||||
xxs: '4px',
|
||||
xs: '8px',
|
||||
sm: '12px',
|
||||
md: '16px',
|
||||
lg: '20px',
|
||||
xl: '24px',
|
||||
xxl: '32px',
|
||||
section: '80px',
|
||||
},
|
||||
borderRadius: {
|
||||
// DESIGN.md radius tokens
|
||||
none: '0px',
|
||||
xs: '2px',
|
||||
sm: '3px',
|
||||
md: '4px',
|
||||
lg: '8px',
|
||||
xl: '16px',
|
||||
pill: '9999px',
|
||||
},
|
||||
boxShadow: {
|
||||
'soft-lift': '0 2px 8px rgba(26, 26, 26, 0.08)',
|
||||
floating: '0 8px 24px rgba(26, 26, 26, 0.12)',
|
||||
},
|
||||
maxWidth: {
|
||||
page: '1366px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
80
frontend/tests/ProtectedRoute.test.tsx
Normal file
80
frontend/tests/ProtectedRoute.test.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { ToastProvider } from '@/hooks/useToast';
|
||||
import { AuthProvider } from '@/hooks/useAuth';
|
||||
import { setToken } from '@/api/client';
|
||||
import { ToastViewport } from '@/components/Toast';
|
||||
|
||||
// Mock the auth API so AuthProvider hydrates without network.
|
||||
vi.mock('@/api/auth', () => ({
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
fetchMe: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchMe } from '@/api/auth';
|
||||
|
||||
function setup() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/admin']}>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>LOGIN PAGE</div>} />
|
||||
<Route path="/engagements" element={<div>ENGAGEMENTS</div>} />
|
||||
<Route element={<ProtectedRoute roles={['admin']} />}>
|
||||
<Route path="/admin" element={<div>ADMIN AREA</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<ToastViewport />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ProtectedRoute', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.mocked(fetchMe).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setToken(null);
|
||||
});
|
||||
|
||||
it('redirects unauthenticated users to /login', async () => {
|
||||
// No token → unauthenticated, no /me call.
|
||||
setup();
|
||||
expect(await screen.findByText('LOGIN PAGE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('admins reach the admin page', async () => {
|
||||
setToken('fake-token');
|
||||
vi.mocked(fetchMe).mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'alice',
|
||||
role: 'admin',
|
||||
created_at: '2026-01-01',
|
||||
});
|
||||
setup();
|
||||
expect(await screen.findByText('ADMIN AREA')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('non-admins get redirected and see an access denied toast', async () => {
|
||||
setToken('fake-token');
|
||||
vi.mocked(fetchMe).mockResolvedValue({
|
||||
id: 2,
|
||||
username: 'bob',
|
||||
role: 'soc',
|
||||
created_at: '2026-01-01',
|
||||
});
|
||||
setup();
|
||||
expect(await screen.findByText('ENGAGEMENTS')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('toast')).toHaveTextContent('Accès refusé');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
frontend/tests/StatusBadge.test.tsx
Normal file
12
frontend/tests/StatusBadge.test.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
|
||||
describe('StatusBadge', () => {
|
||||
it.each(['planned', 'active', 'closed'] as const)('renders %s label and data attr', (status) => {
|
||||
render(<StatusBadge status={status} />);
|
||||
const badge = screen.getByTestId('status-badge');
|
||||
expect(badge).toHaveAttribute('data-status', status);
|
||||
expect(badge.textContent?.toLowerCase()).toBe(status);
|
||||
});
|
||||
});
|
||||
62
frontend/tests/Toast.test.tsx
Normal file
62
frontend/tests/Toast.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ToastProvider, useToast } from '@/hooks/useToast';
|
||||
import { ToastViewport } from '@/components/Toast';
|
||||
|
||||
function Pusher() {
|
||||
const { push } = useToast();
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => push('Session expirée', 'error')}>Push session</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Toast', () => {
|
||||
it('renders pushed toasts and lets the user dismiss them manually', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Pusher />
|
||||
<ToastViewport />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /push session/i }));
|
||||
const toast = await screen.findByTestId('toast');
|
||||
expect(toast).toHaveTextContent('Session expirée');
|
||||
expect(toast).toHaveAttribute('data-kind', 'error');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dismiss/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('toast')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-dismisses after the timeout', async () => {
|
||||
// Override the 4s default by polling the DOM up to 6s. Real timers keep
|
||||
// user-event happy; the toast hook clears itself via setTimeout.
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Pusher />
|
||||
<ToastViewport />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /push session/i }));
|
||||
expect(await screen.findByTestId('toast')).toBeInTheDocument();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByTestId('toast')).toBeNull();
|
||||
},
|
||||
{ timeout: 6000 },
|
||||
);
|
||||
// Quiet act() warning by flushing any pending state.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}, 10_000);
|
||||
});
|
||||
106
frontend/tests/UsersAdminPage.test.tsx
Normal file
106
frontend/tests/UsersAdminPage.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { UsersAdminPage } from '@/pages/UsersAdminPage';
|
||||
import { renderWithProviders } from './utils';
|
||||
import type { User } from '@/api/types';
|
||||
|
||||
// Mock useAuth so the page sees a logged-in admin without hydrating from network.
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' } as User,
|
||||
status: 'authenticated',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isAdmin: true,
|
||||
isRedteam: false,
|
||||
isSoc: false,
|
||||
canEditEngagements: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const USERS: User[] = [
|
||||
{ id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' },
|
||||
{ id: 2, username: 'bob', role: 'redteam', created_at: '2026-02-01' },
|
||||
{ id: 3, username: 'carol', role: 'soc', created_at: '2026-03-01' },
|
||||
];
|
||||
|
||||
describe('UsersAdminPage', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
mock.onGet('/users').reply(200, USERS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('renders the list of users from the API', async () => {
|
||||
renderWithProviders(<UsersAdminPage />);
|
||||
expect(await screen.findByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('bob')).toBeInTheDocument();
|
||||
expect(screen.getByText('carol')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a user via POST /users and refreshes the list', async () => {
|
||||
const newUser: User = { id: 4, username: 'dan', role: 'soc', created_at: '2026-04-01' };
|
||||
const postSpy = vi.fn().mockReturnValue([201, newUser]);
|
||||
mock.onPost('/users').reply((config) => postSpy(JSON.parse(config.data)));
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UsersAdminPage />);
|
||||
|
||||
await screen.findByText('alice');
|
||||
|
||||
await user.type(screen.getByLabelText(/^username/i), 'dan');
|
||||
await user.type(screen.getByLabelText(/^password/i), 'sup3rs4fe!');
|
||||
// role default is 'redteam'; switch to 'soc' to match newUser
|
||||
await user.selectOptions(screen.getByLabelText(/^role/i), 'soc');
|
||||
|
||||
// After POST, hooks invalidate and the list refetches → return the new list
|
||||
mock.onGet('/users').reply(200, [...USERS, newUser]);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^create$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(postSpy).toHaveBeenCalledWith({
|
||||
username: 'dan',
|
||||
password: 'sup3rs4fe!',
|
||||
role: 'soc',
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('dan')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the password reset form for the row that was clicked (fragment-key regression guard)', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UsersAdminPage />);
|
||||
|
||||
await screen.findByText('bob');
|
||||
|
||||
// The "Reset password" button for bob lives in bob's row.
|
||||
const bobRow = screen.getByText('bob').closest('tr');
|
||||
expect(bobRow).not.toBeNull();
|
||||
await user.click(within(bobRow as HTMLElement).getByRole('button', { name: /reset password/i }));
|
||||
|
||||
// The reset form for bob (and bob only) must appear.
|
||||
expect(await screen.findByLabelText(/new password for bob/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/new password for carol/i)).toBeNull();
|
||||
expect(screen.queryByLabelText(/new password for alice/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('disables the delete button on the current user own row', async () => {
|
||||
renderWithProviders(<UsersAdminPage />);
|
||||
await screen.findByText('alice');
|
||||
|
||||
const aliceRow = screen.getByText('alice').closest('tr') as HTMLElement;
|
||||
const bobRow = screen.getByText('bob').closest('tr') as HTMLElement;
|
||||
|
||||
expect(within(aliceRow).getByRole('button', { name: /delete/i })).toBeDisabled();
|
||||
expect(within(bobRow).getByRole('button', { name: /delete/i })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
57
frontend/tests/apiClient.test.tsx
Normal file
57
frontend/tests/apiClient.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { apiClient, getToken, registerUnauthorizedHandler, setToken } from '@/api/client';
|
||||
|
||||
describe('apiClient interceptors', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient);
|
||||
setToken(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
setToken(null);
|
||||
});
|
||||
|
||||
it('attaches Bearer token from storage', async () => {
|
||||
setToken('abc123');
|
||||
mock.onGet('/auth/me').reply((config) => {
|
||||
expect(config.headers?.Authorization).toBe('Bearer abc123');
|
||||
return [200, { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' }];
|
||||
});
|
||||
|
||||
const resp = await apiClient.get('/auth/me');
|
||||
expect(resp.status).toBe(200);
|
||||
});
|
||||
|
||||
it('purges token and calls the registered handler on 401', async () => {
|
||||
setToken('expired');
|
||||
const handler = vi.fn();
|
||||
registerUnauthorizedHandler(handler);
|
||||
|
||||
mock.onGet('/engagements').reply(401, { error: 'token expired' });
|
||||
|
||||
await expect(apiClient.get('/engagements')).rejects.toMatchObject({
|
||||
response: { status: 401 },
|
||||
});
|
||||
|
||||
expect(getToken()).toBeNull();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Reset to avoid leaking into other tests.
|
||||
registerUnauthorizedHandler(() => {});
|
||||
});
|
||||
|
||||
it('leaves the token intact on non-401 errors', async () => {
|
||||
setToken('still-valid');
|
||||
mock.onGet('/users').reply(403, { error: 'forbidden' });
|
||||
|
||||
await expect(apiClient.get('/users')).rejects.toMatchObject({
|
||||
response: { status: 403 },
|
||||
});
|
||||
|
||||
expect(getToken()).toBe('still-valid');
|
||||
});
|
||||
});
|
||||
43
frontend/tests/states.test.tsx
Normal file
43
frontend/tests/states.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { ErrorState } from '@/components/ErrorState';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
|
||||
describe('LoadingState', () => {
|
||||
it('shows the default label', () => {
|
||||
render(<LoadingState />);
|
||||
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
|
||||
});
|
||||
|
||||
it('shows a custom label', () => {
|
||||
render(<LoadingState label="Fetching things…" />);
|
||||
expect(screen.getByTestId('loading-state')).toHaveTextContent('Fetching things…');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ErrorState', () => {
|
||||
it('renders message and triggers retry', async () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<ErrorState message="Boom" onRetry={onRetry} />);
|
||||
expect(screen.getByTestId('error-state')).toHaveTextContent('Boom');
|
||||
await userEvent.click(screen.getByRole('button', { name: /retry/i }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('omits retry button when no handler given', () => {
|
||||
render(<ErrorState message="Boom" />);
|
||||
expect(screen.queryByRole('button', { name: /retry/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('renders title, description and action', () => {
|
||||
render(<EmptyState title="Nothing here" description="Add one" action={<button>Create</button>} />);
|
||||
const node = screen.getByTestId('empty-state');
|
||||
expect(node).toHaveTextContent('Nothing here');
|
||||
expect(node).toHaveTextContent('Add one');
|
||||
expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
33
frontend/tests/utils.tsx
Normal file
33
frontend/tests/utils.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, type RenderOptions } from '@testing-library/react';
|
||||
import { MemoryRouter, type MemoryRouterProps } from 'react-router-dom';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { ToastProvider } from '@/hooks/useToast';
|
||||
|
||||
interface WrapperOptions {
|
||||
routerProps?: MemoryRouterProps;
|
||||
}
|
||||
|
||||
export function makeQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0, staleTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function renderWithProviders(
|
||||
ui: ReactElement,
|
||||
{ routerProps, ...rtlOptions }: WrapperOptions & Omit<RenderOptions, 'wrapper'> = {},
|
||||
) {
|
||||
const client = makeQueryClient();
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter {...routerProps}>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return { ...render(ui, { wrapper: Wrapper, ...rtlOptions }), client };
|
||||
}
|
||||
29
frontend/tsconfig.json
Normal file
29
frontend/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["vite/client", "node", "vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src", "tests", "vite.config.ts", "vitest.setup.ts"]
|
||||
}
|
||||
27
frontend/vite.config.ts
Normal file
27
frontend/vite.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './vitest.setup.ts',
|
||||
css: false,
|
||||
},
|
||||
});
|
||||
1
frontend/vitest.setup.ts
Normal file
1
frontend/vitest.setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
Reference in New Issue
Block a user