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:
101
frontend/src/components/ErrorBoundary.tsx
Normal file
101
frontend/src/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
40
frontend/src/components/ErrorMessage.tsx
Normal file
40
frontend/src/components/ErrorMessage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/LoadingSpinner.tsx
Normal file
36
frontend/src/components/LoadingSpinner.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
88
frontend/src/components/Toast.tsx
Normal file
88
frontend/src/components/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user