Files
gatehouse-ui/src/pages/admin/SystemAuditPage.tsx
T
JamesBhattarai 2dd6823b0f Fix: Verbose Syslog
Special email configured at backend to access these page via .env
2026-03-22 15:38:28 +05:45

396 lines
16 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 {
Search,
Filter,
RefreshCw,
ChevronLeft,
ChevronRight,
LogIn,
Key,
UserPlus,
Shield,
Settings,
AlertTriangle,
Terminal,
Loader2,
CheckCircle2,
XCircle,
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 [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");
// 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 (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, debouncedSearch]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
// reset to page 1 when filters change
useEffect(() => {
setPage(1);
}, [actionFilter, successFilter, 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);
};
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>
<Button
variant="outline"
size="sm"
onClick={() => fetchLogs()}
disabled={isLoading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
Refresh
</Button>
</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>
{/* 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">
{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">{log.user.email}</span>
) : log.user_id ? (
<span className="font-mono">{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>
);
}