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/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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user