feat(phase-9): implement MVP polishing and closure

T-032: User management admin panel - backend users router with CRUD, frontend UsersPage with modals

T-033: Audit log viewer - backend audit router with filters/pagination, frontend AuditLogPage

T-034: Global error handling - ErrorBoundary, LoadingSpinner, ErrorMessage, Toast components

T-035: Backend tests - pytest setup with SQLite, tests for health/auth/techniques/tests

T-036: Documentation - Updated README with testing section, created docs/API.md
This commit is contained in:
2026-02-06 16:30:35 +01:00
parent cb447f3803
commit 174919da4e
27 changed files with 2539 additions and 17 deletions

58
frontend/src/api/audit.ts Normal file
View File

@@ -0,0 +1,58 @@
import client from "./client";
export interface AuditLogOut {
id: string;
user_id: string | null;
username: string | null;
action: string;
entity_type: string | null;
entity_id: string | null;
timestamp: string;
details: Record<string, unknown> | null;
}
export interface AuditLogPage {
items: AuditLogOut[];
total: number;
offset: number;
limit: number;
}
export interface AuditLogFilters {
user_id?: string;
action?: string;
entity_type?: string;
start_date?: string;
end_date?: string;
offset?: number;
limit?: number;
}
/** Fetch paginated audit logs with filters. */
export async function getAuditLogs(filters?: AuditLogFilters): Promise<AuditLogPage> {
const params = new URLSearchParams();
if (filters?.user_id) params.append("user_id", filters.user_id);
if (filters?.action) params.append("action", filters.action);
if (filters?.entity_type) params.append("entity_type", filters.entity_type);
if (filters?.start_date) params.append("start_date", filters.start_date);
if (filters?.end_date) params.append("end_date", filters.end_date);
if (filters?.offset !== undefined) params.append("offset", String(filters.offset));
if (filters?.limit !== undefined) params.append("limit", String(filters.limit));
const { data } = await client.get<AuditLogPage>(
`/audit-logs${params.toString() ? `?${params}` : ""}`
);
return data;
}
/** Fetch list of distinct actions. */
export async function getAuditActions(): Promise<string[]> {
const { data } = await client.get<string[]>("/audit-logs/actions");
return data;
}
/** Fetch list of distinct entity types. */
export async function getAuditEntityTypes(): Promise<string[]> {
const { data } = await client.get<string[]>("/audit-logs/entity-types");
return data;
}

View File

@@ -1,4 +1,4 @@
import axios from "axios";
import axios, { type AxiosError } from "axios";
const client = axios.create({
baseURL: "http://localhost:8000/api/v1",
@@ -14,14 +14,33 @@ client.interceptors.request.use((config) => {
return config;
});
// On 401, clear token so the UI can redirect to login
// Response interceptor for error handling
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
(error: AxiosError<{ detail?: string; code?: string }>) => {
const status = error.response?.status;
// On 401, clear token and redirect to login
if (status === 401) {
localStorage.removeItem("token");
// Only redirect if not already on login page
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
return Promise.reject(error);
// Extract error message from response
const message =
error.response?.data?.detail ||
error.message ||
"An unexpected error occurred";
// Create a more descriptive error
const enhancedError = new Error(message);
(enhancedError as Error & { status?: number; code?: string }).status = status;
(enhancedError as Error & { code?: string }).code = error.response?.data?.code;
return Promise.reject(enhancedError);
},
);

43
frontend/src/api/users.ts Normal file
View File

@@ -0,0 +1,43 @@
import client from "./client";
export interface UserOut {
id: string;
username: string;
email: string | null;
role: string;
is_active: boolean;
created_at: string | null;
last_login: string | null;
}
export interface UserCreatePayload {
username: string;
email?: string;
password: string;
role: string;
}
export interface UserUpdatePayload {
email?: string;
role?: string;
is_active?: boolean;
password?: string;
}
/** Fetch all users (admin only). */
export async function getUsers(): Promise<UserOut[]> {
const { data } = await client.get<UserOut[]>("/users");
return data;
}
/** Create a new user (admin only). */
export async function createUser(payload: UserCreatePayload): Promise<UserOut> {
const { data } = await client.post<UserOut>("/users", payload);
return data;
}
/** Update a user (admin only). */
export async function updateUser(userId: string, payload: UserUpdatePayload): Promise<UserOut> {
const { data } = await client.patch<UserOut>(`/users/${userId}`, payload);
return data;
}