diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index 19234a4..4b68272 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -66,14 +66,15 @@ const adminNavItems = [ { title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck }, { title: "OIDC Clients", url: "/org/clients", icon: Key }, { title: "Org Audit Log", url: "/org/audit", icon: FileText }, - { title: "System Logs", url: "/admin/audit", icon: ScrollText }, ]; +const systemLogNavItem = { title: "System Logs", url: "/admin/audit", icon: ScrollText }; + export function AppSidebar() { const { state } = useSidebar(); const collapsed = state === "collapsed"; const location = useLocation(); - const { isOrgAdmin, isOrgMember } = useAuth(); + const { isOrgAdmin, isOrgMember, canViewSystemLogs } = useAuth(); const isActive = (path: string) => location.pathname === path; const isOrgActive = orgAdminNavItems.some((item) => isActive(item.url)) || adminNavItems.some((item) => isActive(item.url)); @@ -180,7 +181,7 @@ export function AppSidebar() { )} - {adminNavItems.map((item) => ( + {[...adminNavItems, ...(canViewSystemLogs ? [systemLogNavItem] : [])].map((item) => ( Promise; @@ -265,7 +267,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { await checkOrgAdmin(); if (!skipNavigate) { - navigate('/profile'); + const orgsData = await api.users.organizations(); + const hasOrg = orgsData.organizations && orgsData.organizations.length > 0; + + if (hasOrg) { + navigate('/profile'); + } else { + navigate('/org-setup'); + } } }, [navigate, checkOrgAdmin]); @@ -291,6 +300,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: !!user, isOrgAdmin, isOrgMember, + canViewSystemLogs: user?.can_view_system_logs ?? false, mfaCompliance, requiresMfaEnrollment, login, diff --git a/src/lib/api.ts b/src/lib/api.ts index 9e60c1c..23f3f5b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -33,6 +33,10 @@ export interface User { has_password?: boolean; totp_enabled?: boolean; linked_providers?: string[]; + /** Session-derived group memberships (from OIDC claims or session device_info). */ + groups?: string[]; + /** Whether the current user is allowed to access the system-wide audit log. */ + can_view_system_logs?: boolean; } export interface Organization { @@ -938,7 +942,7 @@ export const api = { // Get organization audit logs getAuditLogs: (orgId: string, params?: Record, requestConfig?: RequestConfig) => - request<{ audit_logs: AuditLogEntry[]; count: number }>( + request<{ audit_logs: AuditLogEntry[]; count: number; page: number; per_page: number; pages: number }>( `/organizations/${orgId}/audit-logs${params ? '?' + new URLSearchParams(params).toString() : ''}`, {}, true, diff --git a/src/pages/admin/SystemAuditPage.tsx b/src/pages/admin/SystemAuditPage.tsx index dced7e4..378fe4e 100644 --- a/src/pages/admin/SystemAuditPage.tsx +++ b/src/pages/admin/SystemAuditPage.tsx @@ -6,23 +6,21 @@ import { ChevronLeft, ChevronRight, LogIn, - LogOut, Key, UserPlus, Shield, Settings, AlertTriangle, - Fingerprint, - Smartphone, Terminal, Loader2, CheckCircle2, XCircle, Globe, + Lock, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Select, @@ -31,25 +29,28 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { api, AuditLogEntry } from "@/lib/api"; +import { api, AuditLogEntry, ApiError } from "@/lib/api"; +import { formatDateTime } from "@/lib/date"; // ─── category helpers ──────────────────────────────────────────────────────── -type Category = "auth" | "ssh" | "org" | "user" | "security" | "token" | "other"; +type Category = "auth" | "ssh" | "org" | "user" | "security" | "token" | "admin" | "other"; const getCategory = (action: string): Category => { const a = action.toLowerCase(); - if (a.startsWith("session") || a.includes("login") || a.includes("logout") || a.includes("external_auth")) + 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")) + if (a.includes("token") || a.includes("oidc") || a.includes("client") || a.startsWith("external_auth")) return "token"; return "other"; }; @@ -61,6 +62,7 @@ const CATEGORY_META: Record = { 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" }, }; @@ -73,6 +75,7 @@ const getCategoryIcon = (category: Category) => { case "user": return ; case "security": return ; case "token": return ; + case "admin": return ; default: return ; } }; @@ -86,26 +89,42 @@ const getActionLabel = (action: string) => // ─── 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: "USER_REGISTER", label: "Register" }, - { value: "SSH_KEY_ADDED", label: "SSH Key Added" }, - { value: "SSH_KEY_VERIFIED", label: "SSH Key Verified" }, - { 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_ROLE_CHANGE", label: "Role Changed" }, + { 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([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [accessDenied, setAccessDenied] = useState(false); const [isAdminView, setIsAdminView] = useState(false); // filters @@ -129,6 +148,7 @@ export default function SystemAuditPage() { const fetchLogs = useCallback(async () => { setIsLoading(true); setError(null); + setAccessDenied(false); try { const params: Record = { page: String(page), @@ -144,8 +164,12 @@ export default function SystemAuditPage() { setTotalPages(resp.pages ?? 1); setIsAdminView(resp.is_admin_view ?? false); } catch (err) { - console.error("Failed to fetch system audit logs:", err); - setError("Failed to load audit logs. Please try again."); + 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); } @@ -160,17 +184,7 @@ export default function SystemAuditPage() { setPage(1); }, [actionFilter, successFilter, debouncedSearch]); - const formatDate = (dateString: string) => { - const d = new Date(dateString); - return new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: false, - }).format(d); - }; + const formatDate = (dateString: string) => formatDateTime(dateString); const formatUserAgent = (ua: string | null) => { if (!ua) return null; @@ -244,6 +258,14 @@ export default function SystemAuditPage() { Loading… + ) : accessDenied ? ( +
+ +

Access Restricted

+

+ You don't have permission to view system-wide audit logs. Contact your administrator to request access. +

+
) : error ? (
diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index d95d7d1..6d08970 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -230,8 +230,14 @@ export default function LoginPage() { finishCliFlow(response.token); } else { await refreshUser(); - await checkOrgAdmin(); - navigate('/profile'); + const orgsData = await api.users.organizations(); + const hasOrg = orgsData.organizations && orgsData.organizations.length > 0; + + if (hasOrg) { + navigate('/profile'); + } else { + navigate('/org-setup'); + } } } else { // Fallback to regular TOTP verification @@ -357,7 +363,10 @@ export default function LoginPage() { const token = tokenManager.getToken(); if (token) finishCliFlow(token); } else { - navigate('/profile'); + // Verify org membership before navigating to prevent showing org-setup briefly + const orgsData = await api.users.organizations(); + const hasOrg = orgsData.organizations && orgsData.organizations.length > 0; + navigate(hasOrg ? '/profile' : '/org-setup'); } toast({ @@ -434,7 +443,10 @@ export default function LoginPage() { } else { await refreshUser(); await checkOrgAdmin(); - navigate('/profile'); + // Verify org membership before navigating to prevent showing org-setup briefly + const orgsData = await api.users.organizations(); + const hasOrg = orgsData.organizations && orgsData.organizations.length > 0; + navigate(hasOrg ? '/profile' : '/org-setup'); toast({ title: "Welcome back", description: `Signed in as ${result.user.email}`, diff --git a/src/pages/org/OrgAuditPage.tsx b/src/pages/org/OrgAuditPage.tsx index a62bee4..791e89e 100644 --- a/src/pages/org/OrgAuditPage.tsx +++ b/src/pages/org/OrgAuditPage.tsx @@ -1,204 +1,403 @@ import { useState, useEffect, useCallback } from "react"; -import { Search, Filter, Download, User, Settings, Key, UserPlus, AlertTriangle, Loader2 } from "lucide-react"; -import { useParams } from "react-router-dom"; +import { + Search, Filter, RefreshCw, ChevronLeft, ChevronRight, + LogIn, Key, UserPlus, Shield, Settings, + AlertTriangle, Terminal, Loader2, + CheckCircle2, XCircle, Link2, UserCog, +} 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 { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; import { api, AuditLogEntry } from "@/lib/api"; import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; +import { formatDateTime } from "@/lib/date"; -const getEventIcon = (action: string) => { - if (action.includes("member") || action.includes("MEMBER")) { - return ; - } - if (action.includes("policy") || action.includes("POLICY") || action.includes("mfa")) { - return ; - } - if (action.includes("delete") || action.includes("DELETE") || action.includes("disable")) { - return ; - } - if (action.includes("client") || action.includes("oidc") || action.includes("key")) { - return ; - } - return ; -}; +// ─── category / display helpers ────────────────────────────────────────────── -const getEventTitle = (action: string) => { - const parts = action.split("."); - return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(" "); -}; +type Category = "auth" | "ssh" | "admin" | "member" | "policy" | "security" | "oauth" | "other"; -const getActionCategory = (action: string): string => { - if (action.includes("member") || action.includes("MEMBER")) return "members"; - if (action.includes("policy") || action.includes("POLICY") || action.includes("mfa")) return "policies"; - if (action.includes("client") || action.includes("OIDC")) return "clients"; +const getCategory = (action: string): Category => { + const a = action.toLowerCase(); + if (a.startsWith("session") || a === "user.login" || a === "user.logout") return "auth"; + if (a.startsWith("ssh")) return "ssh"; + if (a.startsWith("admin.")) return "admin"; + if (a.includes("member") || a.includes("invite") || a.startsWith("org.member")) return "member"; + if (a.includes("policy") || a.includes("mfa.policy") || a.startsWith("org.security")) return "policy"; + if (a.includes("mfa") || a.includes("totp") || a.includes("webauthn") || a.includes("passkey") || a.includes("password")) return "security"; + if (a.startsWith("external_auth")) return "oauth"; return "other"; }; +const CATEGORY_META: Record = { + 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" }, + admin: { label: "Admin", color: "bg-red-500/10 text-red-600 dark:text-red-400" }, + member: { label: "Member", color: "bg-violet-500/10 text-violet-600 dark:text-violet-400" }, + policy: { label: "Policy", 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" }, + oauth: { label: "OAuth", color: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400" }, + other: { label: "Other", color: "bg-muted text-muted-foreground" }, +}; + +const getCategoryIcon = (cat: Category) => { + const cls = "w-4 h-4"; + switch (cat) { + case "auth": return ; + case "ssh": return ; + case "admin": return ; + case "member": return ; + case "policy": return ; + case "security": return ; + case "oauth": return ; + default: return ; + } +}; + +const ACTION_LABELS: Record = { + // Sessions + "session.create": "Signed in", + "session.revoke": "Signed out", + "user.login": "Signed in", + "user.logout": "Signed out", + // Members + "org.member.add": "Member added", + "org.member.remove": "Member removed", + "org.member.role_change": "Member role changed", + "org.ownership.transferred": "Ownership transferred", + // Admin actions + "admin.mfa.remove": "MFA removed by admin", + "admin.oauth.unlink": "OAuth unlinked by admin", + "admin.password.set": "Password set by admin", + "admin.email.verify": "Email verified by admin", + // Security / policy + "org.security_policy.update": "Security policy updated", + "user.security_policy.override_update":"User policy override updated", + "mfa.policy.user_suspended": "User suspended (MFA policy)", + "mfa.policy.user_compliant": "User MFA compliant", + // Password + "user.password_change": "Password changed", + "user.password_reset": "Password reset", + // SSH + "ssh.key.added": "SSH key added", + "ssh.key.verified": "SSH key verified", + "ssh.key.deleted": "SSH key removed", + "ssh.cert.requested": "SSH certificate requested", + "ssh.cert.issued": "SSH certificate issued", + "ssh.cert.failed": "SSH certificate request failed", + "ssh.cert.revoked": "SSH certificate revoked", + // WebAuthn / Passkey + "webauthn.register.completed": "Passkey registered", + "webauthn.credential.deleted": "Passkey removed", + "webauthn.login.success": "Signed in with passkey", + "webauthn.login.failed": "Passkey login failed", + // TOTP + "totp.enroll.completed": "TOTP enrolled", + "totp.disabled": "TOTP disabled", + "totp.verify.failed": "TOTP verification failed", + // External auth + "external_auth.link.completed": "OAuth account linked", + "external_auth.unlink": "OAuth account unlinked", + "external_auth.login": "Signed in via OAuth", + "external_auth.login.failed": "OAuth login failed", + // Org + "org.create": "Organisation created", + "org.update": "Organisation updated", + "org.delete": "Organisation deleted", + // User lifecycle + "user.register": "User registered", + "user.suspend": "User suspended", + "user.unsuspend":"User unsuspended", + "user.delete": "User deleted", +}; + +const getActionLabel = (action: string) => + ACTION_LABELS[action] ?? + action.replace(/[._]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + +// ─── action filter options (value = enum dot-notation) ─────────────────────── + +const ACTION_FILTER_OPTIONS = [ + { value: "all", label: "All actions" }, + // Auth + { value: "session.create", label: "Sign in" }, + { value: "session.revoke", label: "Sign out" }, + { value: "external_auth.login", label: "OAuth login" }, + // Members + { value: "org.member.add", label: "Member added" }, + { value: "org.member.remove", label: "Member removed" }, + { value: "org.member.role_change", label: "Role changed" }, + // Admin actions + { value: "admin.mfa.remove", label: "MFA removed (admin)" }, + { value: "admin.oauth.unlink", label: "OAuth unlinked (admin)" }, + { value: "admin.password.set", label: "Password set (admin)" }, + // Security / policy + { value: "org.security_policy.update", label: "Security policy changed" }, + { value: "user.password_change", label: "Password changed" }, + { value: "user.password_reset", label: "Password reset" }, + // SSH + { value: "ssh.key.added", label: "SSH key added" }, + { value: "ssh.key.verified", label: "SSH key verified" }, + { value: "ssh.cert.issued", label: "SSH cert issued" }, + { value: "ssh.cert.revoked", label: "SSH cert revoked" }, + // MFA + { 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" }, + // User lifecycle + { value: "user.register", label: "User registered" }, + { value: "user.suspend", label: "User suspended" }, +]; + +const PER_PAGE = 50; + +// ─── cert metadata detail ───────────────────────────────────────────────────── + +function CertDetail({ metadata }: { metadata?: Record | null }) { + if (!metadata) return null; + const principal = metadata.principal as string | undefined; + const principals = metadata.principals as string[] | undefined; + const serial = metadata.serial_number ?? metadata.serial ?? metadata.cert_serial; + const principalList = principal ? [principal] : Array.isArray(principals) ? principals : []; + if (!principalList.length && !serial) return null; + return ( + + {principalList.length > 0 && <>principal: {principalList.join(", ")}} + {principalList.length > 0 && serial && " · "} + {serial != null && <>serial: {String(serial)}} + + ); +} + +// ─── component ──────────────────────────────────────────────────────────────── + export default function OrgAuditPage() { - const params = useParams<{ orgId?: string }>(); - const { orgId: fallbackOrgId } = useCurrentOrganizationId(); - const orgId = params.orgId || fallbackOrgId; + const { orgId } = useCurrentOrganizationId(); - const [search, setSearch] = useState(""); - const [typeFilter, setTypeFilter] = useState("all"); - const [auditLogs, setAuditLogs] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [actionFilter, setActionFilter] = useState("all"); + const [successFilter, setSuccessFilter] = useState("all"); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [totalCount, setTotalCount] = useState(0); + const [auditLogs, setAuditLogs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - const fetchAuditLogs = useCallback(async (currentOrgId: string) => { + // debounce search + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 400); + return () => clearTimeout(t); + }, [search]); + + // reset page on filter change + useEffect(() => { setPage(1); }, [actionFilter, successFilter, debouncedSearch]); + + const fetchLogs = useCallback(async () => { + if (!orgId) { setIsLoading(false); return; } + setIsLoading(true); + setError(null); try { - setIsLoading(true); - setError(null); - const response = await api.organizations.getAuditLogs(currentOrgId); - setAuditLogs(response.audit_logs || []); + const params: Record = { + 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.organizations.getAuditLogs(orgId, params); + setAuditLogs(resp.audit_logs ?? []); + setTotalCount(resp.count ?? 0); + setTotalPages(resp.pages ?? 1); } catch (err) { - console.error("Failed to fetch audit logs:", err); + console.error("Failed to fetch org audit logs:", err); setError("Failed to load audit logs. Please try again."); } finally { setIsLoading(false); } - }, []); + }, [orgId, page, actionFilter, successFilter, debouncedSearch]); - useEffect(() => { - setError(null); - setAuditLogs([]); - if (!orgId) { - setIsLoading(false); - return; - } - fetchAuditLogs(orgId); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [orgId]); - - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }).format(date); - }; - - const filteredLogs = auditLogs.filter((log) => { - const matchesSearch = - search === "" || - log.description?.toLowerCase().includes(search.toLowerCase()) || - log.action.toLowerCase().includes(search.toLowerCase()) || - log.user?.email.toLowerCase().includes(search.toLowerCase()); - - const matchesFilter = - typeFilter === "all" || - getActionCategory(log.action) === typeFilter; - - return matchesSearch && matchesFilter; - }); + useEffect(() => { fetchLogs(); }, [fetchLogs]); return (
+ {/* Header */}
-

Audit Log

+

Org Audit Log

- View all administrative actions and changes + All organisation activity — user events, admin actions, policy changes + {totalCount > 0 && ` · ${totalCount.toLocaleString()} total`}

-
-
+ {/* Filters */} +
setSearch(e.target.value)} className="pl-10" />
- + - + - All events - Member changes - Policy changes - OIDC clients + {ACTION_FILTER_OPTIONS.map((o) => ( + {o.label} + ))} + + +
+ {/* Table */} {isLoading ? ( -
+
- Loading audit logs... + Loading…
) : error ? ( -
- {error} +
+ +

{error}

- ) : filteredLogs.length === 0 ? ( -
- No audit events found + ) : auditLogs.length === 0 ? ( +
+ No audit events match the current filters.
) : (
- {filteredLogs.map((log) => ( -
-
- {getEventIcon(log.action)} -
-
-
-

- {getEventTitle(log.action)} -

- {log.resource_type && ( - - {log.resource_type} - - )} - {!log.success && ( - - Failed - - )} + {auditLogs.map((log) => { + const cat = getCategory(log.action); + const meta = CATEGORY_META[cat]; + const isCert = log.action.startsWith("ssh.cert"); + return ( +
+ {/* Icon */} +
+ {log.success ? getCategoryIcon(cat) : }
-
- by {log.user?.full_name || log.user?.email || "System"} + + {/* Body */} +
+
+ + {getActionLabel(log.action)} + + + {meta.label} + + {!log.success && ( + Failed + )} +
+ + {/* Description */} {log.description && ( - <> - - {log.description} - +

+ {log.description} + {isCert && } +

+ )} + {log.error_message && ( +

{log.error_message}

+ )} + + {/* Actor / meta row */} +
+ {log.user?.email ? ( + {log.user.email} + ) : log.user_id ? ( + {log.user_id.slice(0, 8)}… + ) : ( + System + )} + {log.ip_address && ( + {log.ip_address} + )} + {log.resource_type && ( + + {log.resource_type} + + )} +
+
+ + {/* Timestamp */} +
+

+ {formatDateTime(log.created_at)} +

+ {log.success ? ( + + ) : ( + )}
-

- {formatDate(log.created_at)} -

-
- ))} + ); + })}
)} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages}  ·  {totalCount.toLocaleString()} events +

+
+ + +
+
+ )}
); -} +} \ No newline at end of file diff --git a/src/pages/user/ActivityPage.tsx b/src/pages/user/ActivityPage.tsx index c9e2104..6f1de39 100644 --- a/src/pages/user/ActivityPage.tsx +++ b/src/pages/user/ActivityPage.tsx @@ -1,111 +1,224 @@ -import { useState, useEffect } from "react"; -import { LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle, Loader2, RefreshCw, Users } from "lucide-react"; +import { useState, useEffect, useCallback } from "react"; +import { + LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle, + Loader2, RefreshCw, Link2, Terminal, CheckCircle2, XCircle, + ChevronLeft, ChevronRight, Search, +} from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { api, AuditLogEntry } from "@/lib/api"; -import { useAuth } from "@/contexts/AuthContext"; import { formatDateTime } from "@/lib/date"; -// Map audit log action strings to display info -const getEventDisplay = (action: string) => { +// ─── event display mapping ──────────────────────────────────────────────────── + +interface EventDisplay { + icon: React.ReactNode; + title: string; +} + +const getEventDisplay = (action: string): EventDisplay => { const a = action.toLowerCase(); - if (a.includes("login") && a.includes("fail")) { - return { icon: , title: "Failed login attempt", failed: true }; - } - if (a.includes("login") || a.includes("authenticate")) { - return { icon: , title: "Signed in", failed: false }; - } - if (a.includes("logout") || a.includes("sign_out")) { - return { icon: , title: "Signed out", failed: false }; - } - if (a.includes("passkey") || a.includes("webauthn")) { - return { icon: , title: "Passkey event", failed: false }; - } - if (a.includes("mfa") || a.includes("totp") || a.includes("2fa")) { - return { icon: , title: "MFA event", failed: false }; - } - if (a.includes("ssh")) { - return { icon: , title: "SSH key event", failed: false }; - } - return { icon: , title: action.replace(/_/g, " "), failed: !action.includes("success") && a.includes("fail") }; + + // Sessions + if (a === "session.create") return { icon: , title: "Signed in" }; + if (a === "session.revoke") return { icon: , title: "Signed out" }; + if (a === "user.login") return { icon: , title: "Signed in" }; + if (a === "user.logout") return { icon: , title: "Signed out" }; + + // OAuth / external auth + if (a === "external_auth.link.completed") return { icon: , title: "OAuth account linked" }; + if (a === "external_auth.link.initiated") return { icon: , title: "OAuth link started" }; + if (a === "external_auth.link.failed") return { icon: , title: "OAuth link failed" }; + if (a === "external_auth.unlink") return { icon: , title: "OAuth account unlinked" }; + if (a === "external_auth.login") return { icon: , title: "Signed in via OAuth" }; + if (a === "external_auth.login.failed") return { icon: , title: "OAuth login failed" }; + + // SSH keys + if (a === "ssh.key.added") return { icon: , title: "SSH key added" }; + if (a === "ssh.key.verified") return { icon: , title: "SSH key verified" }; + if (a === "ssh.key.deleted") return { icon: , title: "SSH key removed" }; + if (a === "ssh.key.validation.failed")return { icon: , title: "SSH key validation failed" }; + if (a === "ssh.cert.requested") return { icon: , title: "SSH certificate requested" }; + if (a === "ssh.cert.issued") return { icon: , title: "SSH certificate issued" }; + if (a === "ssh.cert.failed") return { icon: , title: "SSH certificate request failed" }; + if (a === "ssh.cert.revoked") return { icon: , title: "SSH certificate revoked" }; + + // WebAuthn / Passkey + if (a === "webauthn.register.completed") return { icon: , title: "Passkey registered" }; + if (a === "webauthn.register.initiated") return { icon: , title: "Passkey registration started" }; + if (a === "webauthn.register.failed") return { icon: , title: "Passkey registration failed" }; + if (a === "webauthn.login.success") return { icon: , title: "Signed in with passkey" }; + if (a === "webauthn.login.failed") return { icon: , title: "Passkey login failed" }; + if (a === "webauthn.credential.deleted") return { icon: , title: "Passkey removed" }; + if (a === "webauthn.credential.renamed") return { icon: , title: "Passkey renamed" }; + + // TOTP / MFA + if (a === "totp.enroll.completed") return { icon: , title: "TOTP authenticator enrolled" }; + if (a === "totp.enroll.initiated") return { icon: , title: "TOTP enrolment started" }; + if (a === "totp.verify.success") return { icon: , title: "TOTP code verified" }; + if (a === "totp.verify.failed") return { icon: , title: "TOTP verification failed" }; + if (a === "totp.disabled") return { icon: , title: "TOTP disabled" }; + if (a === "totp.backup_code.used") return { icon: , title: "TOTP backup code used" }; + if (a === "totp.backup_codes.regenerated")return { icon: , title: "TOTP backup codes regenerated" }; + + // Password + if (a === "user.password_change") return { icon: , title: "Password changed" }; + if (a === "user.password_reset") return { icon: , title: "Password reset" }; + + // Generic fallback + return { + icon: , + title: action.replace(/[._]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), + }; }; +// ─── cert metadata detail row ───────────────────────────────────────────────── + +function CertDetail({ metadata }: { metadata?: Record | null }) { + if (!metadata) return null; + const principal = metadata.principal as string | undefined; + const principals = metadata.principals as string[] | undefined; + const serial = metadata.serial_number ?? metadata.serial ?? metadata.cert_serial; + const expiry = metadata.expiry ?? metadata.expires_at ?? metadata.valid_until; + const principalList = principal + ? [principal] + : Array.isArray(principals) + ? principals + : []; + + if (!principalList.length && !serial) return null; + + return ( +
+ {principalList.length > 0 && ( + + Principal{principalList.length > 1 ? "s" : ""}:{" "} + {principalList.join(", ")} + + )} + {serial != null && ( + + Serial: {String(serial)} + + )} + {expiry && ( + + Expires: {new Date(String(expiry)).toLocaleDateString()} + + )} +
+ ); +} + +// ─── filter options ──────────────────────────────────────────────────────────── + +const FILTER_OPTIONS = [ + { value: "all", label: "All events" }, + { value: "session.create", label: "Signed in" }, + { value: "session.revoke", label: "Signed out" }, + { value: "external_auth.login", label: "OAuth login" }, + { value: "external_auth.link.completed", label: "OAuth linked" }, + { value: "external_auth.unlink", label: "OAuth unlinked" }, + { value: "ssh.key.added", label: "SSH key added" }, + { value: "ssh.key.verified", label: "SSH key verified" }, + { value: "ssh.cert.issued", label: "SSH cert issued" }, + { value: "ssh.cert.failed", label: "SSH cert failed" }, + { value: "webauthn.register.completed", label: "Passkey registered" }, + { value: "totp.enroll.completed", label: "TOTP enrolled" }, + { value: "user.password_change", label: "Password changed" }, +]; + +const PER_PAGE = 50; + +// ─── component ──────────────────────────────────────────────────────────────── + export default function ActivityPage() { - const { isOrgAdmin } = useAuth(); - const [filter, setFilter] = useState("all"); - const [view, setView] = useState<"mine" | "org">("mine"); + const [actionFilter, setActionFilter] = useState("all"); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [totalCount, setTotalCount] = useState(0); const [events, setEvents] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); - const loadEvents = () => { + // debounce search + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 400); + return () => clearTimeout(t); + }, [search]); + + // reset page when filters change + useEffect(() => { setPage(1); }, [actionFilter, debouncedSearch]); + + const loadEvents = useCallback(async () => { setIsLoading(true); setError(""); - const req = - view === "org" && isOrgAdmin - ? api.admin.getAuditLogs({ per_page: "100" }).then((d) => d.audit_logs ?? []) - : api.users.auditLogs({ per_page: "50" }).then((d) => d.audit_logs ?? []); + try { + const params: Record = { + page: String(page), + per_page: String(PER_PAGE), + }; + if (actionFilter !== "all") params.action = actionFilter; + if (debouncedSearch) params.q = debouncedSearch; - req - .then((logs) => setEvents(logs)) - .catch(() => setError("Failed to load activity. Please try again.")) - .finally(() => setIsLoading(false)); - }; + const data = await api.users.auditLogs(params); + setEvents(data.audit_logs ?? []); + setTotalCount(data.count ?? 0); + setTotalPages(data.pages ?? 1); + } catch { + setError("Failed to load activity. Please try again."); + } finally { + setIsLoading(false); + } + }, [page, actionFilter, debouncedSearch]); - useEffect(() => { loadEvents(); }, [view]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { loadEvents(); }, [loadEvents]); - const formatDate = (dateString: string) => formatDateTime(dateString, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); - - const filteredEvents = events.filter((e) => { - if (filter === "all") return true; - const a = e.action.toLowerCase(); - if (filter === "logins") - return a.includes("session_create") || a.includes("session_revoke") || a.includes("external_auth") || a.includes("login") || a.includes("logout"); - if (filter === "security") - return a.includes("mfa") || a.includes("passkey") || a.includes("ssh") || a.includes("totp") || a.includes("password") || a.includes("webauthn"); - return true; - }); + const formatDate = (dateString: string) => + formatDateTime(dateString, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); return (
+ {/* Header */}
-

Activity

-

- {view === "org" ? "Organization-wide audit log" : "Your recent account activity and security events"} -

-
-
- {isOrgAdmin && ( - setView(v as "mine" | "org")}> - - My Activity - - - Org Logs - - - - )} - - +

My Activity

+

Your recent account activity and security events

+
+ {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+ +
+ + {/* Log list */} {isLoading ? ( @@ -117,19 +230,20 @@ export default function ActivityPage() {

{error}

- ) : filteredEvents.length === 0 ? ( + ) : events.length === 0 ? (

No activity events found.

) : (
- {filteredEvents.map((event) => { + {events.map((event) => { const display = getEventDisplay(event.action); + const isCert = event.action.startsWith("ssh.cert"); return (
-

- {display.title} -

- {(!event.success || display.failed) && ( - - Failed - +

{display.title}

+ {!event.success && ( + Failed )}
-
- {view === "org" && event.user_id && ( -

User: {event.user_id}

+ {event.description && ( +

{event.description}

+ )} + {/* Cert-specific: principal + serial */} + {isCert && } +
+ {event.ip_address && ( + {event.ip_address} + )} + {event.user_agent && ( + + {event.user_agent.match(/\(([^)]+)\)/)?.[1]?.split(";")[0]?.trim() ?? event.user_agent.slice(0, 40)} + )} - {event.description &&

{event.description}

} -
- {event.ip_address && ( - {event.ip_address} - )} - {event.user_agent && ( - {event.user_agent} - )} -
-

- {formatDate(event.created_at)} -

+
+

+ {formatDate(event.created_at)} +

+ {event.success ? ( + + ) : ( + + )} +
); })} @@ -172,6 +290,31 @@ export default function ActivityPage() { )} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages}  ·  {totalCount.toLocaleString()} events +

+
+ + +
+
+ )}
); }