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

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