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:
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;
|
||||
}
|
||||
0
frontend/src/hooks/.gitkeep
Normal file
0
frontend/src/hooks/.gitkeep
Normal file
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" />
|
||||
Reference in New Issue
Block a user