feat: Phase 7 - Frontend scaffolding and auth (T-023, T-024, T-025)

T-023: Initialize React project
- Vite + React 19 + TypeScript scaffold
- Tailwind CSS v4 with @tailwindcss/vite plugin
- Dependencies: react-router-dom, axios, @tanstack/react-query, lucide-react
- Project structure: api/, components/, pages/, context/, types/, hooks/, lib/

T-024: API client and auth context
- Axios client with JWT interceptor (auto-attach token, clear on 401)
- login() and getMe() API functions
- AuthContext: user state, login, logout, isAuthenticated, isLoading
- Token persistence via localStorage with hydration on mount
- TypeScript types for all backend models

T-025: Login page and layout
- LoginPage with form, error handling, redirect on success
- Layout with sidebar + header + Outlet
- Sidebar with role-aware navigation (System only for admin)
- ProtectedRoute wrapper with role-based access control
- Routes: /login, /dashboard, /techniques, /tests, /system
This commit is contained in:
2026-02-06 16:09:50 +01:00
parent 52d230628d
commit 591b5df250
26 changed files with 3489 additions and 4 deletions

View File

@@ -18,7 +18,7 @@ Aegis is a comprehensive platform for tracking and managing security coverage ag
- **Database**: PostgreSQL 15 - **Database**: PostgreSQL 15
- **Object Storage**: MinIO (S3-compatible) - **Object Storage**: MinIO (S3-compatible)
- **ORM**: SQLAlchemy with Alembic migrations - **ORM**: SQLAlchemy with Alembic migrations
- **Frontend**: React + TypeScript + Vite (coming soon) - **Frontend**: React 19 + TypeScript + Vite + Tailwind CSS v4
## Quick Start ## Quick Start
@@ -50,11 +50,23 @@ docker exec -w /app aegis-backend-1 alembic upgrade head
docker exec -w /app aegis-backend-1 python -m app.seed docker exec -w /app aegis-backend-1 python -m app.seed
``` ```
5. Verify the installation: 5. Start the frontend (requires Node.js 20+ or Docker):
```bash ```bash
# Check backend health # Option A — with Node.js installed locally
cd frontend && npm install && npm run dev
# Option B — via Docker
docker run --rm -v ./frontend:/app -w /app -p 5173:5173 node:20-alpine sh -c "npm run dev"
```
6. Verify the installation:
```bash
# Backend health
curl http://localhost:8000/health curl http://localhost:8000/health
# Expected: {"status":"ok"} # Expected: {"status":"ok"}
# Frontend
# Open http://localhost:5173 — should show the Aegis login page
``` ```
### Authentication ### Authentication
@@ -77,6 +89,7 @@ curl http://localhost:8000/api/v1/auth/me \
| Service | Port | Description | | Service | Port | Description |
|----------|------|-------------| |----------|------|-------------|
| Frontend | 5173 | React dev server (Vite) |
| Backend | 8000 | FastAPI REST API | | Backend | 8000 | FastAPI REST API |
| PostgreSQL | 5433 | Database (mapped to 5433 to avoid conflicts) | | PostgreSQL | 5433 | Database (mapped to 5433 to avoid conflicts) |
| MinIO API | 9000 | S3-compatible object storage | | MinIO API | 9000 | S3-compatible object storage |
@@ -184,7 +197,34 @@ Aegis/
│ ├── status_service.py # Recalculate technique status from tests │ ├── status_service.py # Recalculate technique status from tests
│ ├── mitre_sync_service.py # MITRE ATT&CK sync via TAXII / GitHub │ ├── mitre_sync_service.py # MITRE ATT&CK sync via TAXII / GitHub
│ └── intel_service.py # Automated intel scan via RSS feeds │ └── intel_service.py # Automated intel scan via RSS feeds
└── frontend/ # React frontend (coming soon) └── frontend/ # React + TypeScript frontend
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
└── src/
├── main.tsx # App entry point
├── App.tsx # Route definitions
├── index.css # Tailwind CSS entry
├── api/ # Axios clients
│ ├── client.ts # Base axios instance with JWT interceptor
│ └── auth.ts # login(), getMe()
├── context/
│ └── AuthContext.tsx # Auth state: user, login, logout, isLoading
├── components/
│ ├── Layout.tsx # Sidebar + header + <Outlet/>
│ ├── Sidebar.tsx # Nav links (role-aware)
│ └── ProtectedRoute.tsx
├── pages/
│ ├── LoginPage.tsx
│ ├── DashboardPage.tsx
│ ├── TechniquesPage.tsx
│ ├── TestsPage.tsx
│ └── SystemPage.tsx
├── types/
│ └── models.ts # TS interfaces matching backend schemas
├── hooks/
└── lib/
``` ```
## Database Schema ## Database Schema

3
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
*.local

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aegis — MITRE ATT&CK Coverage</title>
</head>
<body class="bg-gray-950 text-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2800
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "aegis-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.90.20",
"axios": "^1.13.4",
"lucide-react": "^0.563.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.3",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF9640"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

41
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { Routes, Route, Navigate } from "react-router-dom";
import LoginPage from "./pages/LoginPage";
import DashboardPage from "./pages/DashboardPage";
import TechniquesPage from "./pages/TechniquesPage";
import TestsPage from "./pages/TestsPage";
import SystemPage from "./pages/SystemPage";
import Layout from "./components/Layout";
import ProtectedRoute from "./components/ProtectedRoute";
export default function App() {
return (
<Routes>
{/* Public */}
<Route path="/login" element={<LoginPage />} />
{/* Protected — wrapped in shared Layout */}
<Route
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/techniques" element={<TechniquesPage />} />
<Route path="/tests" element={<TestsPage />} />
<Route
path="/system"
element={
<ProtectedRoute roles={["admin"]}>
<SystemPage />
</ProtectedRoute>
}
/>
</Route>
{/* Catch-all → dashboard */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
);
}

29
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,29 @@
import client from "./client";
import type { User } from "../types/models";
interface TokenResponse {
access_token: string;
token_type: string;
}
/** Authenticate and return the access token. */
export async function login(
username: string,
password: string,
): Promise<string> {
const params = new URLSearchParams();
params.append("username", username);
params.append("password", password);
const { data } = await client.post<TokenResponse>("/auth/login", params, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
return data.access_token;
}
/** Fetch the currently authenticated user profile. */
export async function getMe(): Promise<User> {
const { data } = await client.get<User>("/auth/me");
return data;
}

View File

@@ -0,0 +1,28 @@
import axios from "axios";
const client = axios.create({
baseURL: "http://localhost:8000/api/v1",
headers: { "Content-Type": "application/json" },
});
// Attach the JWT token on every request (if present)
client.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// On 401, clear token so the UI can redirect to login
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("token");
}
return Promise.reject(error);
},
);
export default client;

View File

@@ -0,0 +1,33 @@
import { Outlet } from "react-router-dom";
import { LogOut } from "lucide-react";
import { useAuth } from "../context/AuthContext";
import Sidebar from "./Sidebar";
export default function Layout() {
const { user, logout } = useAuth();
return (
<div className="flex h-screen bg-gray-950 text-gray-100">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<header className="flex h-16 items-center justify-end gap-4 border-b border-gray-800 bg-gray-900 px-6">
<span className="text-sm text-gray-300">{user?.username}</span>
<button
onClick={logout}
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</header>
{/* Main content */}
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
interface Props {
children: React.ReactNode;
roles?: string[];
}
/**
* Wrapper that redirects to `/login` when the user is not authenticated.
* Optionally restricts access to specific roles (admins always pass).
*/
export default function ProtectedRoute({ children, roles }: Props) {
const { isAuthenticated, isLoading, user } = useAuth();
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-gray-950">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-cyan-500 border-t-transparent" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (roles && user && !roles.includes(user.role) && user.role !== "admin") {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,64 @@
import { NavLink } from "react-router-dom";
import {
LayoutDashboard,
Shield,
FlaskConical,
Settings,
} from "lucide-react";
import { useAuth } from "../context/AuthContext";
const baseLinks = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/techniques", label: "Techniques", icon: Shield },
{ to: "/tests", label: "Tests", icon: FlaskConical },
];
const adminLinks = [
{ to: "/system", label: "System", icon: Settings },
];
export default function Sidebar() {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const links = isAdmin ? [...baseLinks, ...adminLinks] : baseLinks;
return (
<aside className="flex h-screen w-60 flex-col border-r border-gray-800 bg-gray-900">
{/* Logo */}
<div className="flex h-16 items-center gap-2 px-5">
<Shield className="h-7 w-7 text-cyan-400" />
<span className="text-lg font-bold tracking-wide text-white">
Aegis
</span>
</div>
{/* Nav links */}
<nav className="flex-1 space-y-1 px-3 py-4">
{links.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
isActive
? "bg-cyan-500/10 text-cyan-400"
: "text-gray-400 hover:bg-gray-800 hover:text-gray-200"
}`
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))}
</nav>
{/* Footer */}
<div className="border-t border-gray-800 px-5 py-4">
<p className="truncate text-xs text-gray-500">
{user?.role ?? "—"}
</p>
</div>
</aside>
);
}

View File

@@ -0,0 +1,77 @@
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from "react";
import { login as apiLogin, getMe } from "../api/auth";
import type { User } from "../types/models";
/* ── Context shape ────────────────────────────────────────────────── */
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthState | undefined>(undefined);
/* ── Provider ─────────────────────────────────────────────────────── */
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// On mount — check for a persisted token and hydrate the user
useEffect(() => {
const token = localStorage.getItem("token");
if (!token) {
setIsLoading(false);
return;
}
getMe()
.then(setUser)
.catch(() => localStorage.removeItem("token"))
.finally(() => setIsLoading(false));
}, []);
const login = useCallback(async (username: string, password: string) => {
const token = await apiLogin(username, password);
localStorage.setItem("token", token);
const me = await getMe();
setUser(me);
}, []);
const logout = useCallback(() => {
localStorage.removeItem("token");
setUser(null);
}, []);
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: user !== null,
isLoading,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
/* ── Hook ─────────────────────────────────────────────────────────── */
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
return ctx;
}

1
frontend/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

25
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "./context/AuthContext";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,15 @@
import { Shield } from "lucide-react";
export default function DashboardPage() {
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<div className="flex items-center gap-3 rounded-xl border border-gray-800 bg-gray-900 p-6">
<Shield className="h-8 w-8 text-cyan-400" />
<p className="text-gray-300">
Coverage metrics will appear here.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { Shield } from "lucide-react";
import { useAuth } from "../context/AuthContext";
export default function LoginPage() {
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// If already logged in, redirect immediately
if (isAuthenticated) {
navigate("/dashboard", { replace: true });
return null;
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
await login(username, password);
navigate("/dashboard", { replace: true });
} catch {
setError("Invalid username or password");
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-950 px-4">
<div className="w-full max-w-sm space-y-8">
{/* Logo */}
<div className="flex flex-col items-center gap-2">
<Shield className="h-12 w-12 text-cyan-400" />
<h1 className="text-2xl font-bold tracking-wide text-white">
Aegis
</h1>
<p className="text-sm text-gray-400">
MITRE ATT&CK Coverage Platform
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label
htmlFor="username"
className="mb-1.5 block text-sm font-medium text-gray-300"
>
Username
</label>
<input
id="username"
type="text"
required
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-white placeholder-gray-500 outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500"
placeholder="admin"
/>
</div>
<div>
<label
htmlFor="password"
className="mb-1.5 block text-sm font-medium text-gray-300"
>
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-white placeholder-gray-500 outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500"
placeholder="••••••••"
/>
</div>
{error && (
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-400">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-cyan-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:opacity-50"
>
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
export default function SystemPage() {
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold text-white">System</h1>
<p className="text-gray-400">System administration panel coming soon.</p>
</div>
);
}

View File

@@ -0,0 +1,8 @@
export default function TechniquesPage() {
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold text-white">Techniques</h1>
<p className="text-gray-400">MITRE ATT&CK technique listing coming soon.</p>
</div>
);
}

View File

@@ -0,0 +1,8 @@
export default function TestsPage() {
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold text-white">Tests</h1>
<p className="text-gray-400">Security test management coming soon.</p>
</div>
);
}

View File

@@ -0,0 +1,91 @@
/* ── Shared TypeScript types matching the backend Pydantic schemas ── */
export interface User {
id: string;
username: string;
role: string;
}
export interface Technique {
id: string;
mitre_id: string;
name: string;
description: string | null;
tactic: string | null;
platforms: string[];
mitre_version: string | null;
mitre_last_modified: string | null;
is_subtechnique: boolean;
parent_mitre_id: string | null;
status_global: TechniqueStatus;
review_required: boolean;
last_review_date: string | null;
}
export type TechniqueStatus =
| "not_evaluated"
| "in_progress"
| "validated"
| "partial"
| "not_covered"
| "review_required";
export interface Test {
id: string;
technique_id: string;
name: string;
description: string | null;
platform: string | null;
procedure_text: string | null;
tool_used: string | null;
execution_date: string | null;
created_by: string | null;
result: TestResult | null;
state: TestState;
validated_by: string | null;
validated_at: string | null;
created_at: string;
}
export type TestState = "draft" | "in_review" | "validated" | "rejected";
export type TestResult = "detected" | "not_detected" | "partially_detected";
export interface Evidence {
id: string;
test_id: string;
file_name: string;
file_path: string;
sha256_hash: string;
uploaded_by: string | null;
uploaded_at: string;
}
export interface IntelItem {
id: string;
technique_id: string | null;
url: string;
title: string | null;
source: string | null;
detected_at: string;
reviewed: boolean;
}
export interface CoverageSummary {
total_techniques: number;
validated: number;
partial: number;
not_covered: number;
in_progress: number;
not_evaluated: number;
coverage_percentage: number;
}
export interface TacticCoverage {
tactic: string;
total: number;
validated: number;
partial: number;
not_covered: number;
not_evaluated: number;
in_progress: number;
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}

11
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
host: "0.0.0.0",
port: 5173,
},
});