Files
gatehouse-ui/src/pages/admin/SystemAuditPage.tsx
T

517 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from "react";
import {
Download,
Search,
Filter,
RefreshCw,
ChevronLeft,
ChevronRight,
LogIn,
Key,
UserPlus,
Shield,
Settings,
AlertTriangle,
Terminal,
Loader2,
CheckCircle2,
XCircle,
X,
Globe,
Lock,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api, AuditLogEntry, ApiError } from "@/lib/api";
import { formatDateTime } from "@/lib/date";
// ─── category helpers ────────────────────────────────────────────────────────
type Category = "auth" | "ssh" | "org" | "user" | "security" | "token" | "admin" | "other";
const getCategory = (action: string): Category => {
const a = action.toLowerCase();
if (a.startsWith("session") || a === "user.login" || a === "user.logout" || a.startsWith("external_auth.login"))
return "auth";
if (a.startsWith("ssh"))
return "ssh";
if (a.startsWith("admin."))
return "admin";
if (a.startsWith("org") || a.includes("member") || a.includes("department") || a.includes("invite"))
return "org";
if (a.startsWith("user"))
return "user";
if (a.includes("mfa") || a.includes("totp") || a.includes("webauthn") || a.includes("passkey") || a.includes("password"))
return "security";
if (a.includes("token") || a.includes("oidc") || a.includes("client") || a.startsWith("external_auth"))
return "token";
return "other";
};
const CATEGORY_META: Record<Category, { label: string; color: string }> = {
auth: { label: "Auth", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400" },
ssh: { label: "SSH", color: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" },
org: { label: "Org", color: "bg-violet-500/10 text-violet-600 dark:text-violet-400" },
user: { label: "User", color: "bg-amber-500/10 text-amber-600 dark:text-amber-400" },
security: { label: "Security", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400" },
token: { label: "Token", color: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400" },
admin: { label: "Admin", color: "bg-red-500/10 text-red-600 dark:text-red-400" },
other: { label: "Other", color: "bg-muted text-muted-foreground" },
};
const getCategoryIcon = (category: Category) => {
const cls = "w-4 h-4";
switch (category) {
case "auth": return <LogIn className={cls} />;
case "ssh": return <Terminal className={cls} />;
case "org": return <Settings className={cls} />;
case "user": return <UserPlus className={cls} />;
case "security": return <Shield className={cls} />;
case "token": return <Key className={cls} />;
case "admin": return <Lock className={cls} />;
default: return <Globe className={cls} />;
}
};
const getActionLabel = (action: string) =>
action
.replace(/_/g, " ")
.replace(/\./g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
// ─── component ───────────────────────────────────────────────────────────────
const ACTION_FILTER_OPTIONS = [
{ value: "all", label: "All actions" },
{ value: "session.create", label: "Login" },
{ value: "session.revoke", label: "Logout" },
{ value: "external_auth.login", label: "OAuth Login" },
{ value: "external_auth.login.failed", label: "OAuth Failed" },
{ value: "external_auth.link.completed", label: "OAuth Account Linked" },
{ value: "external_auth.unlink", label: "OAuth Account Unlinked" },
{ value: "user.register", label: "Register" },
{ value: "ssh.key.added", label: "SSH Key Added" },
{ value: "ssh.key.verified", label: "SSH Key Verified" },
{ value: "ssh.key.deleted", label: "SSH Key Deleted" },
{ value: "ssh.cert.issued", label: "SSH Cert Issued" },
{ value: "ssh.cert.revoked", label: "SSH Cert Revoked" },
{ value: "ssh.cert.failed", label: "SSH Cert Failed" },
{ value: "org.create", label: "Org Created" },
{ value: "org.member.add", label: "Member Added" },
{ value: "org.member.remove", label: "Member Removed" },
{ value: "org.member.role_change", label: "Role Changed" },
{ value: "org.security_policy.update", label: "Security Policy Updated" },
{ value: "admin.mfa.remove", label: "MFA Removed (Admin)" },
{ value: "admin.oauth.unlink", label: "OAuth Unlinked (Admin)" },
{ value: "admin.password.set", label: "Password Set (Admin)" },
{ value: "totp.enroll.completed", label: "TOTP Enrolled" },
{ value: "totp.disabled", label: "TOTP Disabled" },
{ value: "webauthn.register.completed", label: "Passkey Registered" },
{ value: "webauthn.credential.deleted", label: "Passkey Removed" },
{ value: "user.password_change", label: "Password Changed" },
{ value: "user.password_reset", label: "Password Reset" },
{ value: "user.suspend", label: "User Suspended" },
];
export default function SystemAuditPage() {
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isExporting, setIsExporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState(false);
const [isAdminView, setIsAdminView] = useState(false);
// filters
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [actionFilter, setActionFilter] = useState("all");
const [successFilter, setSuccessFilter] = useState("all");
const [userFilter, setUserFilter] = useState<string | null>(null);
const [userFilterLabel, setUserFilterLabel] = useState<string | null>(null);
// pagination
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const PER_PAGE = 50;
// debounce search
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
const fetchLogs = useCallback(async () => {
setIsLoading(true);
setError(null);
setAccessDenied(false);
try {
const params: Record<string, string> = {
page: String(page),
per_page: String(PER_PAGE),
};
if (actionFilter !== "all") params.action = actionFilter;
if (successFilter !== "all") params.success = successFilter;
if (userFilter) params.user_id = userFilter;
if (debouncedSearch) params.q = debouncedSearch;
const resp = await api.admin.getAuditLogs(params);
setLogs(resp.audit_logs ?? []);
setTotalCount(resp.count ?? 0);
setTotalPages(resp.pages ?? 1);
setIsAdminView(resp.is_admin_view ?? false);
} catch (err) {
if (err instanceof ApiError && err.code === 403) {
setAccessDenied(true);
} else {
console.error("Failed to fetch system audit logs:", err);
setError("Failed to load audit logs. Please try again.");
}
} finally {
setIsLoading(false);
}
}, [page, actionFilter, successFilter, userFilter, debouncedSearch]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
// reset to page 1 when filters change
useEffect(() => {
setPage(1);
}, [actionFilter, successFilter, userFilter, debouncedSearch]);
const formatDate = (dateString: string) => formatDateTime(dateString);
const formatUserAgent = (ua: string | null) => {
if (!ua) return null;
const m = ua.match(/\(([^)]+)\)/);
if (m) return m[1].split(";")[0].trim();
return ua.slice(0, 40);
};
const handleExport = useCallback(async () => {
setIsExporting(true);
try {
const EXPORT_PER_PAGE = 200;
const buildParams = (p: number) => {
const params: Record<string, string> = { page: String(p), per_page: String(EXPORT_PER_PAGE) };
if (actionFilter !== "all") params.action = actionFilter;
if (successFilter !== "all") params.success = successFilter;
if (userFilter) params.user_id = userFilter;
if (debouncedSearch) params.q = debouncedSearch;
return params;
};
const first = await api.admin.getAuditLogs(buildParams(1));
const allLogs = [...(first.audit_logs ?? [])];
const totalPages = first.pages ?? 1;
if (totalPages > 1) {
const remaining = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, i) =>
api.admin.getAuditLogs(buildParams(i + 2))
)
);
for (const r of remaining) allLogs.push(...(r.audit_logs ?? []));
}
const esc = (v: string) => `"${v.replace(/"/g, '""')}"`;
const header = ["ID","Action","Description","User Email","User ID","Resource Type","Resource ID","IP Address","User Agent","Success","Error Message","Created At","Updated At"];
const rows = allLogs.map((l) => [
l.id, l.action, l.description ?? "",
l.user?.email ?? "", l.user_id ?? "",
l.resource_type ?? "", l.resource_id ?? "",
l.ip_address ?? "", l.user_agent ?? "",
l.success ? "Yes" : "No",
l.error_message ?? "",
l.created_at, l.updated_at ?? "",
].map(esc).join(","));
const csv = [header.map(esc).join(","), ...rows].join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error("Export failed:", err);
} finally {
setIsExporting(false);
}
}, [actionFilter, successFilter, userFilter, debouncedSearch]);
return (
<div className="page-container">
{/* Header */}
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="page-title">System Audit Log</h1>
<p className="page-description">
{isAdminView
? `All system events — ${totalCount.toLocaleString()} total`
: "Your account events"}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline" size="sm"
onClick={handleExport}
disabled={isExporting || isLoading}
>
<Download className="w-4 h-4 mr-2" />
{isExporting ? "Exporting…" : "Export CSV"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => fetchLogs()}
disabled={isLoading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search descriptions…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Select value={actionFilter} onValueChange={setActionFilter}>
<SelectTrigger className="w-[200px]">
<Filter className="w-4 h-4 mr-2" />
<SelectValue placeholder="Filter by action" />
</SelectTrigger>
<SelectContent>
{ACTION_FILTER_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={successFilter} onValueChange={setSuccessFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="true">Success only</SelectItem>
<SelectItem value="false">Failures only</SelectItem>
</SelectContent>
</Select>
</div>
{/* Active filter chips */}
{(actionFilter !== "all" || successFilter !== "all" || userFilter) && (
<div className="flex flex-wrap items-center gap-2 mb-4">
{actionFilter !== "all" && (
<Badge variant="secondary" className="gap-1 px-3 py-1">
<span className="text-xs">Action: {getActionLabel(actionFilter)}</span>
<X
className="w-3 h-3 cursor-pointer hover:text-destructive"
onClick={() => setActionFilter("all")}
/>
</Badge>
)}
{userFilter && (
<Badge variant="secondary" className="gap-1 px-3 py-1">
<span className="text-xs">User: {userFilterLabel ?? userFilter.slice(0, 8) + "…"}</span>
<X
className="w-3 h-3 cursor-pointer hover:text-destructive"
onClick={() => { setUserFilter(null); setUserFilterLabel(null); }}
/>
</Badge>
)}
{successFilter !== "all" && (
<Badge variant="secondary" className="gap-1 px-3 py-1">
<span className="text-xs">Status: {successFilter === "true" ? "Success only" : "Failures only"}</span>
<X
className="w-3 h-3 cursor-pointer hover:text-destructive"
onClick={() => setSuccessFilter("all")}
/>
</Badge>
)}
</div>
)}
{/* Table */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading</span>
</div>
) : accessDenied ? (
<div className="py-16 text-center text-muted-foreground">
<Lock className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
<p className="font-medium text-base">Access Restricted</p>
<p className="text-sm mt-1 max-w-sm mx-auto">
You don't have permission to view system-wide audit logs. Contact your administrator to request access.
</p>
</div>
) : error ? (
<div className="py-12 text-center text-destructive">
<AlertTriangle className="w-8 h-8 mx-auto mb-2" />
<p>{error}</p>
</div>
) : logs.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
No audit events match the current filters.
</div>
) : (
<div className="divide-y">
{logs.map((log) => {
const cat = getCategory(log.action);
const meta = CATEGORY_META[cat];
return (
<div key={log.id} className="flex items-start gap-4 px-4 py-3 hover:bg-muted/30 transition-colors">
{/* Icon */}
<div
className={`mt-0.5 w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${
log.success ? meta.color : "bg-destructive/10 text-destructive"
}`}
>
{log.success ? getCategoryIcon(cat) : <XCircle className="w-4 h-4" />}
</div>
{/* Body */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span
className="font-medium text-sm text-foreground cursor-pointer hover:text-primary transition-colors"
onClick={() =>
setActionFilter((prev) => (prev === log.action ? "all" : log.action))
}
>
{getActionLabel(log.action)}
</span>
<Badge variant="secondary" className={`text-xs px-1.5 py-0 ${meta.color}`}>
{meta.label}
</Badge>
{!log.success && (
<Badge variant="destructive" className="text-xs px-1.5 py-0">
Failed
</Badge>
)}
{log.resource_type && (
<Badge variant="outline" className="text-xs px-1.5 py-0 font-mono">
{log.resource_type}
</Badge>
)}
</div>
{/* Description */}
{log.description && (
<p className="mt-0.5 text-sm text-muted-foreground">{log.description}</p>
)}
{log.error_message && (
<p className="mt-0.5 text-xs text-destructive">{log.error_message}</p>
)}
{/* Meta row */}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
{log.user?.email ? (
<span
className="font-medium text-foreground/70 cursor-pointer hover:text-foreground transition-colors"
onClick={() => {
if (log.user_id) {
setUserFilter((prev) => (prev === log.user_id ? null : log.user_id));
setUserFilterLabel((prev) => (prev === log.user.email ? null : log.user.email));
}
}}
>{log.user.email}</span>
) : log.user_id ? (
<span
className="font-mono cursor-pointer hover:text-foreground transition-colors"
onClick={() => {
setUserFilter((prev) => (prev === log.user_id ? null : log.user_id));
setUserFilterLabel((prev) => prev === log.user_id ? null : `${log.user_id!.slice(0, 8)}`);
}}
>{log.user_id.slice(0, 8)}</span>
) : (
<span className="italic">System</span>
)}
{log.ip_address && (
<span className="font-mono">{log.ip_address}</span>
)}
{log.user_agent && (
<span className="truncate max-w-[220px]" title={log.user_agent}>
{formatUserAgent(log.user_agent)}
</span>
)}
{log.resource_id && (
<span className="font-mono">{log.resource_id.slice(0, 8)}</span>
)}
</div>
</div>
{/* Timestamp */}
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<p className="text-xs text-muted-foreground whitespace-nowrap">
{formatDate(log.created_at)}
</p>
{log.success ? (
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
) : (
<XCircle className="w-3.5 h-3.5 text-destructive" />
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
Page {page} of {totalPages} &nbsp;·&nbsp; {totalCount.toLocaleString()} events
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1 || isLoading}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="w-4 h-4" />
Prev
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages || isLoading}
onClick={() => setPage((p) => p + 1)}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
);
}