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:
48
README.md
48
README.md
@@ -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
3
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/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
2800
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal 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
1
frontend/public/vite.svg
Normal 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
41
frontend/src/App.tsx
Normal 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
29
frontend/src/api/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
28
frontend/src/api/client.ts
Normal file
28
frontend/src/api/client.ts
Normal 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;
|
||||||
33
frontend/src/components/Layout.tsx
Normal file
33
frontend/src/components/Layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/components/ProtectedRoute.tsx
Normal file
33
frontend/src/components/ProtectedRoute.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
64
frontend/src/components/Sidebar.tsx
Normal file
64
frontend/src/components/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
frontend/src/context/AuthContext.tsx
Normal file
77
frontend/src/context/AuthContext.tsx
Normal 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
1
frontend/src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
0
frontend/src/lib/.gitkeep
Normal file
0
frontend/src/lib/.gitkeep
Normal file
25
frontend/src/main.tsx
Normal file
25
frontend/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
15
frontend/src/pages/DashboardPage.tsx
Normal file
15
frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend/src/pages/LoginPage.tsx
Normal file
106
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
frontend/src/pages/SystemPage.tsx
Normal file
8
frontend/src/pages/SystemPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
frontend/src/pages/TechniquesPage.tsx
Normal file
8
frontend/src/pages/TechniquesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
frontend/src/pages/TestsPage.tsx
Normal file
8
frontend/src/pages/TestsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
frontend/src/types/models.ts
Normal file
91
frontend/src/types/models.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal 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
11
frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user