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

View File

@@ -0,0 +1,101 @@
import { Component, type ReactNode, type ErrorInfo } from "react";
import { AlertTriangle, RefreshCw, Home } from "lucide-react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
this.setState({ errorInfo });
}
handleReload = () => {
window.location.reload();
};
handleGoHome = () => {
window.location.href = "/dashboard";
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-950 p-6">
<div className="w-full max-w-md rounded-xl border border-red-500/30 bg-gray-900 p-8">
<div className="flex flex-col items-center text-center">
<div className="rounded-full bg-red-900/30 p-4">
<AlertTriangle className="h-12 w-12 text-red-400" />
</div>
<h1 className="mt-6 text-xl font-bold text-white">
Something went wrong
</h1>
<p className="mt-2 text-sm text-gray-400">
An unexpected error occurred. Please try refreshing the page or
return to the dashboard.
</p>
{process.env.NODE_ENV === "development" && this.state.error && (
<details className="mt-4 w-full text-left">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-300">
Error details
</summary>
<pre className="mt-2 max-h-40 overflow-auto rounded bg-gray-800 p-3 text-xs text-red-400">
{this.state.error.toString()}
{this.state.errorInfo?.componentStack}
</pre>
</details>
)}
<div className="mt-6 flex gap-3">
<button
onClick={this.handleReload}
className="flex items-center gap-2 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
>
<RefreshCw className="h-4 w-4" />
Refresh Page
</button>
<button
onClick={this.handleGoHome}
className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm text-gray-300 hover:border-gray-600 hover:text-white transition-colors"
>
<Home className="h-4 w-4" />
Go to Dashboard
</button>
</div>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,40 @@
import { AlertCircle, RefreshCw } from "lucide-react";
interface ErrorMessageProps {
title?: string;
message?: string;
onRetry?: () => void;
fullHeight?: boolean;
}
export default function ErrorMessage({
title = "Something went wrong",
message = "An error occurred while loading this content.",
onRetry,
fullHeight = true,
}: ErrorMessageProps) {
return (
<div
className={`flex flex-col items-center justify-center gap-4 ${
fullHeight ? "h-64" : "py-8"
}`}
>
<div className="rounded-full bg-red-900/30 p-4">
<AlertCircle className="h-10 w-10 text-red-400" />
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<p className="mt-1 text-sm text-gray-400">{message}</p>
</div>
{onRetry && (
<button
onClick={onRetry}
className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm text-gray-300 hover:border-cyan-500/50 hover:text-cyan-400 transition-colors"
>
<RefreshCw className="h-4 w-4" />
Try Again
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Loader2 } from "lucide-react";
interface LoadingSpinnerProps {
size?: "sm" | "md" | "lg";
text?: string;
fullScreen?: boolean;
}
const sizeClasses = {
sm: "h-4 w-4",
md: "h-8 w-8",
lg: "h-12 w-12",
};
export default function LoadingSpinner({
size = "md",
text,
fullScreen = false,
}: LoadingSpinnerProps) {
const content = (
<div className="flex flex-col items-center justify-center gap-3">
<Loader2 className={`animate-spin text-cyan-400 ${sizeClasses[size]}`} />
{text && <p className="text-sm text-gray-400">{text}</p>}
</div>
);
if (fullScreen) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-gray-950/80 backdrop-blur-sm z-50">
{content}
</div>
);
}
return <div className="flex h-64 items-center justify-center">{content}</div>;
}

View File

@@ -4,6 +4,8 @@ import {
Shield,
FlaskConical,
Settings,
Users,
FileText,
} from "lucide-react";
import { useAuth } from "../context/AuthContext";
@@ -14,6 +16,8 @@ const baseLinks = [
];
const adminLinks = [
{ to: "/users", label: "Users", icon: Users },
{ to: "/audit", label: "Audit Log", icon: FileText },
{ to: "/system", label: "System", icon: Settings },
];

View File

@@ -0,0 +1,88 @@
import { useState, useEffect, createContext, useContext, useCallback, type ReactNode } from "react";
import { CheckCircle, AlertCircle, Info, X } from "lucide-react";
type ToastType = "success" | "error" | "info";
interface Toast {
id: string;
type: ToastType;
message: string;
}
interface ToastContextValue {
showToast: (type: ToastType, message: string) => void;
}
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within a ToastProvider");
}
return context;
}
const icons = {
success: CheckCircle,
error: AlertCircle,
info: Info,
};
const colors = {
success: "border-green-500/30 bg-green-900/30 text-green-400",
error: "border-red-500/30 bg-red-900/30 text-red-400",
info: "border-cyan-500/30 bg-cyan-900/30 text-cyan-400",
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((type: ToastType, message: string) => {
const id = Math.random().toString(36).substr(2, 9);
setToasts((prev) => [...prev, { id, type, message }]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<ToastItem
key={toast.id}
toast={toast}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
</ToastContext.Provider>
);
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
const Icon = icons[toast.type];
useEffect(() => {
const timer = setTimeout(onClose, 5000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div
className={`flex items-center gap-3 rounded-lg border px-4 py-3 shadow-lg animate-in slide-in-from-right ${colors[toast.type]}`}
>
<Icon className="h-5 w-5 flex-shrink-0" />
<p className="text-sm">{toast.message}</p>
<button
onClick={onClose}
className="ml-2 rounded p-1 hover:bg-white/10"
>
<X className="h-4 w-4" />
</button>
</div>
);
}