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:
Knacky
2026-05-26 09:37:53 +02:00
parent be266d4879
commit 5104f7c429
95 changed files with 13801 additions and 5 deletions

47
frontend/src/App.tsx Normal file
View 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
View 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;
}

View 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;
}

View 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
View 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
View 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}`);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 />;
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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 }),
});
}

View 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;
}

View 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
View 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>,
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

View 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;
}
}