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
|
||||
- **Object Storage**: MinIO (S3-compatible)
|
||||
- **ORM**: SQLAlchemy with Alembic migrations
|
||||
- **Frontend**: React + TypeScript + Vite (coming soon)
|
||||
- **Frontend**: React 19 + TypeScript + Vite + Tailwind CSS v4
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
5. Verify the installation:
|
||||
5. Start the frontend (requires Node.js 20+ or Docker):
|
||||
```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
|
||||
# Expected: {"status":"ok"}
|
||||
|
||||
# Frontend
|
||||
# Open http://localhost:5173 — should show the Aegis login page
|
||||
```
|
||||
|
||||
### Authentication
|
||||
@@ -77,6 +89,7 @@ curl http://localhost:8000/api/v1/auth/me \
|
||||
|
||||
| Service | Port | Description |
|
||||
|----------|------|-------------|
|
||||
| Frontend | 5173 | React dev server (Vite) |
|
||||
| Backend | 8000 | FastAPI REST API |
|
||||
| PostgreSQL | 5433 | Database (mapped to 5433 to avoid conflicts) |
|
||||
| MinIO API | 9000 | S3-compatible object storage |
|
||||
@@ -184,7 +197,34 @@ Aegis/
|
||||
│ ├── status_service.py # Recalculate technique status from tests
|
||||
│ ├── mitre_sync_service.py # MITRE ATT&CK sync via TAXII / GitHub
|
||||
│ └── 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
|
||||
|
||||
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