From c32cb4757a483306d1cd7458965eb3e3c2fc14bc Mon Sep 17 00:00:00 2001 From: James Bhattarai Date: Fri, 27 Feb 2026 21:08:16 +0545 Subject: [PATCH 1/6] Feat(Chore): Implemented Audit-Logs, Department, Principal. --- src/App.tsx | 8 + src/components/layouts/ProtectedLayout.tsx | 8 +- src/components/navigation/AppSidebar.tsx | 6 + src/hooks/useCurrentOrganization.ts | 31 ++ src/hooks/useOrganizations.ts | 5 +- src/lib/api.ts | 349 +++++++++++++++++- src/pages/admin/SystemAuditPage.tsx | 373 ++++++++++++++++++++ src/pages/org/DepartmentsPage.tsx | 308 ++++++++++++++++ src/pages/org/MembersPage.tsx | 260 +++++++------- src/pages/org/OIDCClientsPage.tsx | 263 ++++++++------ src/pages/org/OrgAuditPage.tsx | 233 ++++++------ src/pages/org/OrgOverviewPage.tsx | 96 ++--- src/pages/org/PrincipalsPage.tsx | 390 +++++++++++++++++++++ src/pages/user/ActivityPage.tsx | 259 +++++++------- 14 files changed, 2049 insertions(+), 540 deletions(-) create mode 100644 src/hooks/useCurrentOrganization.ts create mode 100644 src/pages/admin/SystemAuditPage.tsx create mode 100644 src/pages/org/DepartmentsPage.tsx create mode 100644 src/pages/org/PrincipalsPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 8ccdb93..f236de1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,9 @@ import PoliciesPage from "@/pages/org/PoliciesPage"; import CompliancePage from "@/pages/org/CompliancePage"; import OrgAuditPage from "@/pages/org/OrgAuditPage"; import OIDCClientsPage from "@/pages/org/OIDCClientsPage"; +import DepartmentsPage from "@/pages/org/DepartmentsPage"; +import PrincipalsPage from "@/pages/org/PrincipalsPage"; +import SystemAuditPage from "@/pages/admin/SystemAuditPage"; import NotFound from "@/pages/NotFound"; import ApiDevTools from "@/components/dev/ApiDevTools"; @@ -98,10 +101,15 @@ function AppRoutes() { {/* Organization routes */} } /> } /> + } /> + } /> } /> } /> } /> } /> + + {/* Admin routes */} + } /> {/* Catch-all */} diff --git a/src/components/layouts/ProtectedLayout.tsx b/src/components/layouts/ProtectedLayout.tsx index 2df3e4e..6424ae4 100644 --- a/src/components/layouts/ProtectedLayout.tsx +++ b/src/components/layouts/ProtectedLayout.tsx @@ -2,12 +2,14 @@ import { Navigate, Outlet } from 'react-router-dom'; import { useAuth } from '@/contexts/AuthContext'; import AuthenticatedLayout from './AuthenticatedLayout'; import MfaEnforcementLayout from './MfaEnforcementLayout'; +import { useOrganizations } from '@/hooks/useOrganizations'; import { Loader2 } from 'lucide-react'; export default function ProtectedLayout() { const { isAuthenticated, isLoading, requiresMfaEnrollment } = useAuth(); + const { isLoading: isOrgsLoading } = useOrganizations(); - if (isLoading) { + if (isLoading || isOrgsLoading) { return (
@@ -27,8 +29,6 @@ export default function ProtectedLayout() { } return ( - - - + ); } diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index 898e7e0..ad4fd47 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -9,6 +9,9 @@ import { Settings, FileText, Key, + Layers, + GitBranch, + ScrollText, } from "lucide-react"; import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; import { NavLink } from "@/components/NavLink"; @@ -37,12 +40,15 @@ const userNavItems = [ const orgNavItems = [ { title: "Overview", url: "/org", icon: Building2 }, { title: "Members", url: "/org/members", icon: Users }, + { title: "Departments", url: "/org/departments", icon: Layers }, + { title: "Principals", url: "/org/principals", icon: GitBranch }, { title: "Policies", url: "/org/policies", icon: Settings }, { title: "Audit Log", url: "/org/audit", icon: FileText }, ]; const adminNavItems = [ { title: "OIDC Clients", url: "/org/clients", icon: Key }, + { title: "System Logs", url: "/admin/audit", icon: ScrollText }, ]; export function AppSidebar() { diff --git a/src/hooks/useCurrentOrganization.ts b/src/hooks/useCurrentOrganization.ts new file mode 100644 index 0000000..3cec019 --- /dev/null +++ b/src/hooks/useCurrentOrganization.ts @@ -0,0 +1,31 @@ +import { useParams } from "react-router-dom"; +import { useOrganizations } from "@/hooks/useOrganizations"; + +/** + * Custom hook to get the current organization from URL params or first available org. + * This helps with backward compatibility if routes don't include orgId. + */ +export function useCurrentOrganization() { + const params = useParams<{ orgId?: string }>(); + const { data: organizations = [], isLoading } = useOrganizations(); + + // If orgId is in params, use that + if (params.orgId) { + return { + org: organizations.find((org) => org.id === params.orgId) || organizations[0] || null, + isLoading, + }; + } + + // Otherwise, return the first organization (default) + return { org: organizations[0] || null, isLoading }; +} + +/** + * Get the organization ID from URL params or first available org. + * Also returns isLoading so callers can distinguish "no org" from "still loading". + */ +export function useCurrentOrganizationId(): { orgId: string | null; isLoading: boolean } { + const { org, isLoading } = useCurrentOrganization(); + return { orgId: org?.id || null, isLoading }; +} diff --git a/src/hooks/useOrganizations.ts b/src/hooks/useOrganizations.ts index 1b561d1..5e47309 100644 --- a/src/hooks/useOrganizations.ts +++ b/src/hooks/useOrganizations.ts @@ -14,11 +14,8 @@ export function useOrganizations() { return useQuery({ queryKey: ["organizations"], queryFn: async () => { - console.log('[useOrganizations] Fetching organizations...'); const response = await api.users.organizations(); - console.log('[useOrganizations] Response:', response); - console.log('[useOrganizations] Organizations array:', response.organizations); - return response.organizations; + return Array.isArray(response.organizations) ? response.organizations : []; }, // Only fetch when user is authenticated enabled: isAuthenticated, diff --git a/src/lib/api.ts b/src/lib/api.ts index 2458b1b..b9a6578 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -211,42 +211,30 @@ export const tokenManager = { if (token && expiry) { const expiryDate = new Date(expiry); if (expiryDate <= new Date()) { - console.log('[TokenManager] Token expired, clearing'); tokenManager.clearToken(); return null; } } - if (token) { - console.log('[TokenManager] Token retrieved:', token.substring(0, 20) + '...'); - } else { - console.log('[TokenManager] No token found in localStorage'); - } - return token; }, setToken: (token: string, expiresAt?: string | null): void => { - console.log('[TokenManager] Setting token, expiresAt:', expiresAt); localStorage.setItem(TOKEN_KEY, token); if (expiresAt) { localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt); } else { localStorage.removeItem(TOKEN_EXPIRY_KEY); } - console.log('[TokenManager] Token set successfully'); }, clearToken: (): void => { - console.log('[TokenManager] Clearing token from localStorage'); localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_EXPIRY_KEY); }, hasValidToken: (): boolean => { - const hasToken = tokenManager.getToken() !== null; - console.log('[TokenManager] hasValidToken:', hasToken); - return hasToken; + return tokenManager.getToken() !== null; }, }; @@ -292,9 +280,6 @@ async function request( const token = tokenManager.getToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; - console.log('[API] Added Authorization header for endpoint:', endpoint); - } else { - console.log('[API] WARNING: No token available for authenticated endpoint:', endpoint); } } @@ -370,6 +355,43 @@ export const api = { return response; }, + register: async (email: string, password: string, full_name?: string): Promise => { + const response = await request('/auth/register', { + method: 'POST', + body: JSON.stringify({ email, password, password_confirm: password, full_name }), + }, false); + + if (response.token) { + tokenManager.setToken(response.token, response.expires_at ?? null); + } + + return response; + }, + + forgotPassword: (email: string): Promise<{ message: string }> => + request<{ message: string }>('/auth/forgot-password', { + method: 'POST', + body: JSON.stringify({ email }), + }, false), + + resetPassword: (token: string, password: string): Promise<{ message: string }> => + request<{ message: string }>('/auth/reset-password', { + method: 'POST', + body: JSON.stringify({ token, password, password_confirm: password }), + }, false), + + verifyEmail: (token: string): Promise<{ message: string }> => + request<{ message: string }>('/auth/verify-email', { + method: 'POST', + body: JSON.stringify({ token }), + }, false), + + resendVerification: (email: string): Promise<{ message: string }> => + request<{ message: string }>('/auth/resend-verification', { + method: 'POST', + body: JSON.stringify({ email }), + }, false), + logout: async (): Promise => { try { await request('/auth/logout', { @@ -405,6 +427,26 @@ export const api = { new_password_confirm: newPasswordConfirm, }), }, true, { clearTokenOn401: false }), + + // Get audit logs for the currently authenticated user + auditLogs: (params?: Record, requestConfig?: RequestConfig) => + request<{ audit_logs: AuditLogEntry[]; count: number; page: number; per_page: number; pages: number }>( + `/auth/audit-logs${params ? '?' + new URLSearchParams(params).toString() : ''}`, + {}, + true, + requestConfig, + ), + }, + + admin: { + // Get all system audit logs (admin view — returns all logs for org owners, own logs otherwise) + getAuditLogs: (params?: Record, requestConfig?: RequestConfig) => + request<{ audit_logs: AuditLogEntry[]; count: number; page: number; per_page: number; pages: number; is_admin_view: boolean }>( + `/audit-logs${params ? '?' + new URLSearchParams(params).toString() : ''}`, + {}, + true, + requestConfig, + ), }, totp: { @@ -635,8 +677,283 @@ export const api = { credentials: 'include', }), }, + + organizations: { + // Get organization by ID + getById: (orgId: string, requestConfig?: RequestConfig) => + request<{ organization: Organization; member_count: number }>(`/organizations/${orgId}`, {}, true, requestConfig), + + // Get organization members + getMembers: (orgId: string, requestConfig?: RequestConfig) => + request<{ members: OrganizationMember[]; count: number }>(`/organizations/${orgId}/members`, {}, true, requestConfig), + + // Add member to organization + addMember: (orgId: string, email: string, role: string, requestConfig?: RequestConfig) => + request<{ member: OrganizationMember }>(`/organizations/${orgId}/members`, { + method: 'POST', + body: JSON.stringify({ email, role }), + }, true, requestConfig), + + // Remove member from organization + removeMember: (orgId: string, userId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/members/${userId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Update member role + updateMemberRole: (orgId: string, userId: string, role: string, requestConfig?: RequestConfig) => + request<{ member: OrganizationMember }>(`/organizations/${orgId}/members/${userId}/role`, { + method: 'PATCH', + body: JSON.stringify({ role }), + }, true, requestConfig), + + // Get organization audit logs + getAuditLogs: (orgId: string, params?: Record, requestConfig?: RequestConfig) => + request<{ audit_logs: AuditLogEntry[]; count: number }>( + `/organizations/${orgId}/audit-logs${params ? '?' + new URLSearchParams(params).toString() : ''}`, + {}, + true, + requestConfig + ), + + // Get departments + getDepartments: (orgId: string, requestConfig?: RequestConfig) => + request<{ departments: Department[]; count: number }>(`/organizations/${orgId}/departments`, {}, true, requestConfig), + + // Create department + createDepartment: (orgId: string, name: string, description?: string, requestConfig?: RequestConfig) => + request<{ department: Department }>(`/organizations/${orgId}/departments`, { + method: 'POST', + body: JSON.stringify({ name, description }), + }, true, requestConfig), + + // Update department + updateDepartment: (orgId: string, deptId: string, data: { name?: string; description?: string }, requestConfig?: RequestConfig) => + request<{ department: Department }>(`/organizations/${orgId}/departments/${deptId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }, true, requestConfig), + + // Delete department + deleteDepartment: (orgId: string, deptId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/departments/${deptId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Get department members + getDepartmentMembers: (orgId: string, deptId: string, requestConfig?: RequestConfig) => + request<{ members: DepartmentMember[]; count: number }>(`/organizations/${orgId}/departments/${deptId}/members`, {}, true, requestConfig), + + // Add member to department + addDepartmentMember: (orgId: string, deptId: string, email: string, requestConfig?: RequestConfig) => + request<{ member: DepartmentMember }>(`/organizations/${orgId}/departments/${deptId}/members`, { + method: 'POST', + body: JSON.stringify({ email }), + }, true, requestConfig), + + // Remove member from department + removeDepartmentMember: (orgId: string, deptId: string, userId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/departments/${deptId}/members/${userId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Get principals + getPrincipals: (orgId: string, requestConfig?: RequestConfig) => + request<{ principals: Principal[]; count: number }>(`/organizations/${orgId}/principals`, {}, true, requestConfig), + + // Create principal + createPrincipal: (orgId: string, name: string, description?: string, requestConfig?: RequestConfig) => + request<{ principal: Principal }>(`/organizations/${orgId}/principals`, { + method: 'POST', + body: JSON.stringify({ name, description }), + }, true, requestConfig), + + // Update principal + updatePrincipal: (orgId: string, principalId: string, data: { name?: string; description?: string }, requestConfig?: RequestConfig) => + request<{ principal: Principal }>(`/organizations/${orgId}/principals/${principalId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }, true, requestConfig), + + // Delete principal + deletePrincipal: (orgId: string, principalId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/principals/${principalId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Get principal members + getPrincipalMembers: (orgId: string, principalId: string, requestConfig?: RequestConfig) => + request<{ members: PrincipalMember[]; count: number }>(`/organizations/${orgId}/principals/${principalId}/members`, {}, true, requestConfig), + + // Add member to principal + addPrincipalMember: (orgId: string, principalId: string, email: string, requestConfig?: RequestConfig) => + request<{ member: PrincipalMember }>(`/organizations/${orgId}/principals/${principalId}/members`, { + method: 'POST', + body: JSON.stringify({ email }), + }, true, requestConfig), + + // Remove member from principal + removePrincipalMember: (orgId: string, principalId: string, userId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/principals/${principalId}/members/${userId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Link principal to department + linkPrincipalToDepartment: (orgId: string, principalId: string, departmentId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/principals/${principalId}/departments`, { + method: 'POST', + body: JSON.stringify({ department_id: departmentId }), + }, true, requestConfig), + + // Unlink principal from department + unlinkPrincipalFromDepartment: (orgId: string, principalId: string, departmentId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/principals/${principalId}/departments/${departmentId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Create invite token + createInvite: (orgId: string, email: string, role: string, requestConfig?: RequestConfig) => + request<{ invite: OrgInvite }>(`/organizations/${orgId}/invites`, { + method: 'POST', + body: JSON.stringify({ email, role }), + }, true, requestConfig), + + // List OIDC clients + getClients: (orgId: string, requestConfig?: RequestConfig) => + request<{ clients: OIDCClient[]; count: number }>(`/organizations/${orgId}/clients`, {}, true, requestConfig), + + // Create OIDC client + createClient: (orgId: string, name: string, redirect_uris: string[], requestConfig?: RequestConfig) => + request<{ client: OIDCClientWithSecret }>(`/organizations/${orgId}/clients`, { + method: 'POST', + body: JSON.stringify({ name, redirect_uris }), + }, true, requestConfig), + + // Delete OIDC client + deleteClient: (orgId: string, clientId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/clients/${clientId}`, { + method: 'DELETE', + }, true, requestConfig), + + // Send MFA reminder to a member + sendMfaReminder: (orgId: string, userId: string, requestConfig?: RequestConfig) => + request<{ message: string }>(`/organizations/${orgId}/members/${userId}/send-mfa-reminder`, { + method: 'POST', + }, true, requestConfig), + }, + + invites: { + // Get invite details by token (unauthenticated) + getInfo: (token: string) => + request<{ email: string; organization: { id: string; name: string }; role: string }>( + `/invites/${token}`, + {}, + false, + ), + + // Accept invite (unauthenticated) + accept: (token: string, full_name: string, password: string) => + request( + `/invites/${token}/accept`, + { + method: 'POST', + body: JSON.stringify({ full_name, password, password_confirm: password }), + }, + false, + ), + }, }; +// Organization types +export interface OrganizationMember { + id: string; + user_id: string; + organization_id: string; + role: string; + created_at: string; + updated_at: string; + user?: User; +} + +export interface AuditLogEntry { + id: string; + action: string; + user_id: string | null; + organization_id: string | null; + resource_type: string | null; + resource_id: string | null; + ip_address: string | null; + user_agent: string | null; + request_id: string | null; + description: string | null; + success: boolean; + error_message: string | null; + metadata?: Record; + created_at: string; + updated_at: string; + user?: User; +} + +export interface Department { + id: string; + organization_id: string; + name: string; + description: string | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +export interface DepartmentMember { + id: string; + user_id: string; + department_id: string; + created_at: string; + updated_at: string; + user?: User; +} + +export interface Principal { + id: string; + organization_id: string; + name: string; + description: string | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +export interface PrincipalMember { + id: string; + user_id: string; + principal_id: string; + created_at: string; + updated_at: string; + user?: User; +} + +export interface OrgInvite { + id: string; + email: string; + role: string; + expires_at: string; +} + +export interface OIDCClient { + id: string; + name: string; + client_id: string; + redirect_uris: string[]; + scopes: string[]; + grant_types: string[]; + is_active: boolean; + created_at: string; +} + +export interface OIDCClientWithSecret extends OIDCClient { + client_secret: string; +} + // Policy types export interface OrgPolicyResponse { security_policy: { diff --git a/src/pages/admin/SystemAuditPage.tsx b/src/pages/admin/SystemAuditPage.tsx new file mode 100644 index 0000000..dced7e4 --- /dev/null +++ b/src/pages/admin/SystemAuditPage.tsx @@ -0,0 +1,373 @@ +import { useState, useEffect, useCallback } from "react"; +import { + Search, + Filter, + RefreshCw, + ChevronLeft, + ChevronRight, + LogIn, + LogOut, + Key, + UserPlus, + Shield, + Settings, + AlertTriangle, + Fingerprint, + Smartphone, + Terminal, + Loader2, + CheckCircle2, + XCircle, + Globe, +} 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 { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api, AuditLogEntry } from "@/lib/api"; + +// ─── category helpers ──────────────────────────────────────────────────────── + +type Category = "auth" | "ssh" | "org" | "user" | "security" | "token" | "other"; + +const getCategory = (action: string): Category => { + const a = action.toLowerCase(); + if (a.startsWith("session") || a.includes("login") || a.includes("logout") || a.includes("external_auth")) + return "auth"; + if (a.startsWith("ssh")) + return "ssh"; + 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")) + return "token"; + 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" }, + 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" }, + other: { label: "Other", color: "bg-muted text-muted-foreground" }, +}; + +const getCategoryIcon = (category: Category) => { + const cls = "w-4 h-4"; + switch (category) { + case "auth": return ; + case "ssh": return ; + case "org": return ; + case "user": return ; + case "security": return ; + case "token": return ; + default: return ; + } +}; + +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: "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" }, +]; + +export default function SystemAuditPage() { + const [logs, setLogs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + 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); + try { + 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.admin.getAuditLogs(params); + setLogs(resp.audit_logs ?? []); + setTotalCount(resp.count ?? 0); + 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."); + } 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) => { + 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 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 ( +
+ {/* Header */} +
+
+

System Audit Log

+

+ {isAdminView + ? `All system events — ${totalCount.toLocaleString()} total` + : "Your account events"} +

+
+ +
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+ + +
+ + {/* Table */} + + + {isLoading ? ( +
+ + Loading… +
+ ) : error ? ( +
+ +

{error}

+
+ ) : logs.length === 0 ? ( +
+ No audit events match the current filters. +
+ ) : ( +
+ {logs.map((log) => { + const cat = getCategory(log.action); + const meta = CATEGORY_META[cat]; + return ( +
+ {/* Icon */} +
+ {log.success ? getCategoryIcon(cat) : } +
+ + {/* Body */} +
+
+ + {getActionLabel(log.action)} + + + {meta.label} + + {!log.success && ( + + Failed + + )} + {log.resource_type && ( + + {log.resource_type} + + )} +
+ + {/* Description */} + {log.description && ( +

{log.description}

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

{log.error_message}

+ )} + + {/* 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.user_agent && ( + + {formatUserAgent(log.user_agent)} + + )} + {log.resource_id && ( + {log.resource_id.slice(0, 8)}… + )} +
+
+ + {/* Timestamp */} +
+

+ {formatDate(log.created_at)} +

+ {log.success ? ( + + ) : ( + + )} +
+
+ ); + })} +
+ )} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

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

+
+ + +
+
+ )} +
+ ); +} diff --git a/src/pages/org/DepartmentsPage.tsx b/src/pages/org/DepartmentsPage.tsx new file mode 100644 index 0000000..a6d746c --- /dev/null +++ b/src/pages/org/DepartmentsPage.tsx @@ -0,0 +1,308 @@ +import { useState, useEffect } from "react"; +import { Search, Plus, MoreHorizontal, Users, Loader2, Trash2, Edit2 } from "lucide-react"; +import { useParams } from "react-router-dom"; +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 { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { api, Department } from "@/lib/api"; +import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; + +export default function DepartmentsPage() { + const params = useParams<{ orgId?: string }>(); + const { orgId: fallbackOrgId } = useCurrentOrganizationId(); + const orgId = params.orgId || fallbackOrgId; + + const [search, setSearch] = useState(""); + const [departments, setDepartments] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [editingDept, setEditingDept] = useState(null); + const [formData, setFormData] = useState({ name: "", description: "" }); + + const fetchDepartments = async (currentOrgId: string) => { + try { + setIsLoading(true); + setError(null); + const response = await api.organizations.getDepartments(currentOrgId); + setDepartments(response.departments || []); + } catch (err) { + console.error("Failed to fetch departments:", err); + setError("Failed to load departments. Please try again."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + setError(null); + setDepartments([]); + if (!orgId) { + setIsLoading(false); + return; + } + fetchDepartments(orgId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orgId]); + + const handleCreateDepartment = async () => { + if (!orgId || !formData.name.trim()) return; + + try { + await api.organizations.createDepartment( + orgId, + formData.name, + formData.description || undefined + ); + setFormData({ name: "", description: "" }); + setIsCreateDialogOpen(false); + await fetchDepartments(orgId); + } catch (err) { + console.error("Failed to create department:"); + setError("Failed to create department."); + } + }; + + const handleUpdateDepartment = async () => { + if (!orgId || !editingDept || !formData.name.trim()) return; + + try { + await api.organizations.updateDepartment( + orgId, + editingDept.id, + { + name: formData.name, + description: formData.description || undefined, + } + ); + setFormData({ name: "", description: "" }); + setEditingDept(null); + setIsEditDialogOpen(false); + await fetchDepartments(orgId); + } catch (err) { + console.error("Failed to update department:"); + setError("Failed to update department."); + } + }; + + const handleDeleteDepartment = async (deptId: string) => { + if (!orgId || !confirm("Are you sure you want to delete this department?")) return; + + try { + await api.organizations.deleteDepartment(orgId, deptId); + await fetchDepartments(orgId); + } catch (err) { + console.error("Failed to delete department:"); + setError("Failed to delete department."); + } + }; + + const openEditDialog = (dept: Department) => { + setEditingDept(dept); + setFormData({ name: dept.name, description: dept.description || "" }); + setIsEditDialogOpen(true); + }; + + const filteredDepartments = departments.filter((dept) => { + const searchLower = search.toLowerCase(); + return ( + dept.name.toLowerCase().includes(searchLower) || + (dept.description?.toLowerCase().includes(searchLower) ?? false) + ); + }); + + return ( +
+
+
+

Departments

+

+ Manage departments and organize team members +

+
+ +
+ +
+
+ + setSearch(e.target.value)} + className="pl-10 max-w-sm" + /> +
+
+ + + + {isLoading ? ( +
+ + Loading departments... +
+ ) : error ? ( +
+ {error} +
+ ) : filteredDepartments.length === 0 ? ( +
+ No departments found +
+ ) : ( +
+ {filteredDepartments.map((dept) => ( +
+
+ +
+
+
+

+ {dept.name} +

+
+ {dept.description && ( +

+ {dept.description} +

+ )} +
+ Created {new Date(dept.created_at).toLocaleDateString()} +
+
+ + + + + + openEditDialog(dept)}> + + Edit + + + handleDeleteDepartment(dept.id)} + > + + Delete + + + +
+ ))} +
+ )} +
+
+ + {/* Create Department Dialog */} + + + + Create Department + + Create a new department to organize team members + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+
+ +