Feat(Chore): Implemented Audit-Logs, Department, Principal.
This commit is contained in:
@@ -33,6 +33,9 @@ import PoliciesPage from "@/pages/org/PoliciesPage";
|
|||||||
import CompliancePage from "@/pages/org/CompliancePage";
|
import CompliancePage from "@/pages/org/CompliancePage";
|
||||||
import OrgAuditPage from "@/pages/org/OrgAuditPage";
|
import OrgAuditPage from "@/pages/org/OrgAuditPage";
|
||||||
import OIDCClientsPage from "@/pages/org/OIDCClientsPage";
|
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 NotFound from "@/pages/NotFound";
|
||||||
import ApiDevTools from "@/components/dev/ApiDevTools";
|
import ApiDevTools from "@/components/dev/ApiDevTools";
|
||||||
@@ -98,10 +101,15 @@ function AppRoutes() {
|
|||||||
{/* Organization routes */}
|
{/* Organization routes */}
|
||||||
<Route path="/org" element={<OrgOverviewPage />} />
|
<Route path="/org" element={<OrgOverviewPage />} />
|
||||||
<Route path="/org/members" element={<MembersPage />} />
|
<Route path="/org/members" element={<MembersPage />} />
|
||||||
|
<Route path="/org/departments" element={<DepartmentsPage />} />
|
||||||
|
<Route path="/org/principals" element={<PrincipalsPage />} />
|
||||||
<Route path="/org/policies" element={<PoliciesPage />} />
|
<Route path="/org/policies" element={<PoliciesPage />} />
|
||||||
<Route path="/org/policies/compliance" element={<CompliancePage />} />
|
<Route path="/org/policies/compliance" element={<CompliancePage />} />
|
||||||
<Route path="/org/audit" element={<OrgAuditPage />} />
|
<Route path="/org/audit" element={<OrgAuditPage />} />
|
||||||
<Route path="/org/clients" element={<OIDCClientsPage />} />
|
<Route path="/org/clients" element={<OIDCClientsPage />} />
|
||||||
|
|
||||||
|
{/* Admin routes */}
|
||||||
|
<Route path="/admin/audit" element={<SystemAuditPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Catch-all */}
|
{/* Catch-all */}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { Navigate, Outlet } from 'react-router-dom';
|
|||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import AuthenticatedLayout from './AuthenticatedLayout';
|
import AuthenticatedLayout from './AuthenticatedLayout';
|
||||||
import MfaEnforcementLayout from './MfaEnforcementLayout';
|
import MfaEnforcementLayout from './MfaEnforcementLayout';
|
||||||
|
import { useOrganizations } from '@/hooks/useOrganizations';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
export default function ProtectedLayout() {
|
export default function ProtectedLayout() {
|
||||||
const { isAuthenticated, isLoading, requiresMfaEnrollment } = useAuth();
|
const { isAuthenticated, isLoading, requiresMfaEnrollment } = useAuth();
|
||||||
|
const { isLoading: isOrgsLoading } = useOrganizations();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading || isOrgsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
@@ -27,8 +29,6 @@ export default function ProtectedLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout>
|
<AuthenticatedLayout />
|
||||||
<Outlet />
|
|
||||||
</AuthenticatedLayout>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
FileText,
|
FileText,
|
||||||
Key,
|
Key,
|
||||||
|
Layers,
|
||||||
|
GitBranch,
|
||||||
|
ScrollText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { GatehouseLogo } from "@/components/branding/GatehouseLogo";
|
import { GatehouseLogo } from "@/components/branding/GatehouseLogo";
|
||||||
import { NavLink } from "@/components/NavLink";
|
import { NavLink } from "@/components/NavLink";
|
||||||
@@ -37,12 +40,15 @@ const userNavItems = [
|
|||||||
const orgNavItems = [
|
const orgNavItems = [
|
||||||
{ title: "Overview", url: "/org", icon: Building2 },
|
{ title: "Overview", url: "/org", icon: Building2 },
|
||||||
{ title: "Members", url: "/org/members", icon: Users },
|
{ 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: "Policies", url: "/org/policies", icon: Settings },
|
||||||
{ title: "Audit Log", url: "/org/audit", icon: FileText },
|
{ title: "Audit Log", url: "/org/audit", icon: FileText },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
{ title: "OIDC Clients", url: "/org/clients", icon: Key },
|
{ title: "OIDC Clients", url: "/org/clients", icon: Key },
|
||||||
|
{ title: "System Logs", url: "/admin/audit", icon: ScrollText },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -14,11 +14,8 @@ export function useOrganizations() {
|
|||||||
return useQuery<Organization[], ApiError>({
|
return useQuery<Organization[], ApiError>({
|
||||||
queryKey: ["organizations"],
|
queryKey: ["organizations"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log('[useOrganizations] Fetching organizations...');
|
|
||||||
const response = await api.users.organizations();
|
const response = await api.users.organizations();
|
||||||
console.log('[useOrganizations] Response:', response);
|
return Array.isArray(response.organizations) ? response.organizations : [];
|
||||||
console.log('[useOrganizations] Organizations array:', response.organizations);
|
|
||||||
return response.organizations;
|
|
||||||
},
|
},
|
||||||
// Only fetch when user is authenticated
|
// Only fetch when user is authenticated
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
|
|||||||
+333
-16
@@ -211,42 +211,30 @@ export const tokenManager = {
|
|||||||
if (token && expiry) {
|
if (token && expiry) {
|
||||||
const expiryDate = new Date(expiry);
|
const expiryDate = new Date(expiry);
|
||||||
if (expiryDate <= new Date()) {
|
if (expiryDate <= new Date()) {
|
||||||
console.log('[TokenManager] Token expired, clearing');
|
|
||||||
tokenManager.clearToken();
|
tokenManager.clearToken();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token) {
|
|
||||||
console.log('[TokenManager] Token retrieved:', token.substring(0, 20) + '...');
|
|
||||||
} else {
|
|
||||||
console.log('[TokenManager] No token found in localStorage');
|
|
||||||
}
|
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
|
|
||||||
setToken: (token: string, expiresAt?: string | null): void => {
|
setToken: (token: string, expiresAt?: string | null): void => {
|
||||||
console.log('[TokenManager] Setting token, expiresAt:', expiresAt);
|
|
||||||
localStorage.setItem(TOKEN_KEY, token);
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
if (expiresAt) {
|
if (expiresAt) {
|
||||||
localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt);
|
localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
||||||
}
|
}
|
||||||
console.log('[TokenManager] Token set successfully');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
clearToken: (): void => {
|
clearToken: (): void => {
|
||||||
console.log('[TokenManager] Clearing token from localStorage');
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
||||||
},
|
},
|
||||||
|
|
||||||
hasValidToken: (): boolean => {
|
hasValidToken: (): boolean => {
|
||||||
const hasToken = tokenManager.getToken() !== null;
|
return tokenManager.getToken() !== null;
|
||||||
console.log('[TokenManager] hasValidToken:', hasToken);
|
|
||||||
return hasToken;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -292,9 +280,6 @@ async function request<T>(
|
|||||||
const token = tokenManager.getToken();
|
const token = tokenManager.getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
headers['Authorization'] = `Bearer ${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;
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
register: async (email: string, password: string, full_name?: string): Promise<LoginResponse> => {
|
||||||
|
const response = await request<LoginResponse>('/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<void> => {
|
logout: async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await request<void>('/auth/logout', {
|
await request<void>('/auth/logout', {
|
||||||
@@ -405,6 +427,26 @@ export const api = {
|
|||||||
new_password_confirm: newPasswordConfirm,
|
new_password_confirm: newPasswordConfirm,
|
||||||
}),
|
}),
|
||||||
}, true, { clearTokenOn401: false }),
|
}, true, { clearTokenOn401: false }),
|
||||||
|
|
||||||
|
// Get audit logs for the currently authenticated user
|
||||||
|
auditLogs: (params?: Record<string, string>, 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<string, string>, 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: {
|
totp: {
|
||||||
@@ -635,8 +677,283 @@ export const api = {
|
|||||||
credentials: 'include',
|
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<string, string>, 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<LoginResponse>(
|
||||||
|
`/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<string, unknown>;
|
||||||
|
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
|
// Policy types
|
||||||
export interface OrgPolicyResponse {
|
export interface OrgPolicyResponse {
|
||||||
security_policy: {
|
security_policy: {
|
||||||
|
|||||||
@@ -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<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" },
|
||||||
|
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} />;
|
||||||
|
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: "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<AuditLogEntry[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<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) {
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
) : 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} · {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Department[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [editingDept, setEditingDept] = useState<Department | null>(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 (
|
||||||
|
<div className="page-container">
|
||||||
|
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Departments</h1>
|
||||||
|
<p className="page-description">
|
||||||
|
Manage departments and organize team members
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => { setFormData({ name: "", description: "" }); setIsCreateDialogOpen(true); }}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Department
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search departments..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10 max-w-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground">Loading departments...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-8 text-center text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : filteredDepartments.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
|
No departments found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{filteredDepartments.map((dept) => (
|
||||||
|
<div key={dept.id} className="p-4 flex items-start gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-accent/10 text-accent flex items-center justify-center flex-shrink-0">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
{dept.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{dept.description && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{dept.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Created {new Date(dept.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => openEditDialog(dept)}>
|
||||||
|
<Edit2 className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => handleDeleteDepartment(dept.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create Department Dialog */}
|
||||||
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Department</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new department to organize team members
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="dept-name">Department Name</Label>
|
||||||
|
<Input
|
||||||
|
id="dept-name"
|
||||||
|
placeholder="e.g., Engineering"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="dept-desc">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="dept-desc"
|
||||||
|
placeholder="Optional description..."
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateDepartment}>
|
||||||
|
Create Department
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Department Dialog */}
|
||||||
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Department</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update department information
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-dept-name">Department Name</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-dept-name"
|
||||||
|
placeholder="e.g., Engineering"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-dept-desc">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-dept-desc"
|
||||||
|
placeholder="Optional description..."
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleUpdateDepartment}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+125
-135
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Search, Plus, MoreHorizontal, Shield, User, Mail, Clock } from "lucide-react";
|
import { Search, Plus, MoreHorizontal, Shield, User, Mail, Clock, Loader2 } from "lucide-react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -13,59 +14,62 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { api, OrganizationMember } from "@/lib/api";
|
||||||
|
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||||
|
|
||||||
const members = [
|
const getInitials = (name: string | null | undefined): string => {
|
||||||
{
|
if (!name) return "?";
|
||||||
id: "1",
|
return name
|
||||||
name: "John Doe",
|
.split(" ")
|
||||||
email: "john@example.com",
|
.map((n) => n[0])
|
||||||
role: "admin",
|
.join("")
|
||||||
status: "active",
|
.toUpperCase()
|
||||||
lastActive: "2 hours ago",
|
.slice(0, 2);
|
||||||
avatar: null,
|
};
|
||||||
initials: "JD",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
name: "Jane Smith",
|
|
||||||
email: "jane@example.com",
|
|
||||||
role: "member",
|
|
||||||
status: "active",
|
|
||||||
lastActive: "5 minutes ago",
|
|
||||||
avatar: null,
|
|
||||||
initials: "JS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
name: "Bob Wilson",
|
|
||||||
email: "bob@example.com",
|
|
||||||
role: "member",
|
|
||||||
status: "disabled",
|
|
||||||
lastActive: "3 days ago",
|
|
||||||
avatar: null,
|
|
||||||
initials: "BW",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const pendingInvites = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
email: "alice@example.com",
|
|
||||||
role: "member",
|
|
||||||
sentAt: "2 days ago",
|
|
||||||
expiresAt: "5 days",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
email: "charlie@example.com",
|
|
||||||
role: "admin",
|
|
||||||
sentAt: "1 hour ago",
|
|
||||||
expiresAt: "7 days",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function MembersPage() {
|
export default function MembersPage() {
|
||||||
|
const params = useParams<{ orgId?: string }>();
|
||||||
|
const { orgId: fallbackOrgId } = useCurrentOrganizationId();
|
||||||
|
const orgId = params.orgId || fallbackOrgId;
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [members, setMembers] = useState<OrganizationMember[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(null);
|
||||||
|
setMembers([]);
|
||||||
|
if (!orgId) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMembers = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await api.organizations.getMembers(orgId);
|
||||||
|
setMembers(response.members || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch members:", err);
|
||||||
|
setError("Failed to load members. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMembers();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [orgId]);
|
||||||
|
|
||||||
|
const filteredMembers = members.filter((member) => {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
(member.user?.full_name?.toLowerCase().includes(searchLower) ?? false) ||
|
||||||
|
(member.user?.email.toLowerCase().includes(searchLower) ?? false)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
@@ -88,7 +92,7 @@ export default function MembersPage() {
|
|||||||
Members ({members.length})
|
Members ({members.length})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="invites">
|
<TabsTrigger value="invites">
|
||||||
Pending Invites ({pendingInvites.length})
|
Pending Invites (0)
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -107,63 +111,76 @@ export default function MembersPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="divide-y">
|
{isLoading ? (
|
||||||
{members.map((member) => (
|
<div className="flex items-center justify-center p-8">
|
||||||
<div key={member.id} className="p-4 flex items-center gap-4">
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
<Avatar className="w-10 h-10">
|
<span className="ml-2 text-muted-foreground">Loading members...</span>
|
||||||
<AvatarImage src={member.avatar || undefined} />
|
</div>
|
||||||
<AvatarFallback className="bg-primary text-primary-foreground">
|
) : error ? (
|
||||||
{member.initials}
|
<div className="p-8 text-center text-destructive">
|
||||||
</AvatarFallback>
|
{error}
|
||||||
</Avatar>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
) : filteredMembers.length === 0 ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
<p className="font-medium text-foreground truncate">
|
No members found
|
||||||
{member.name}
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{filteredMembers.map((member) => (
|
||||||
|
<div key={member.id} className="p-4 flex items-center gap-4">
|
||||||
|
<Avatar className="w-10 h-10">
|
||||||
|
<AvatarImage src={member.user?.avatar_url || undefined} />
|
||||||
|
<AvatarFallback className="bg-primary text-primary-foreground">
|
||||||
|
{getInitials(member.user?.full_name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-foreground truncate">
|
||||||
|
{member.user?.full_name || member.user?.email}
|
||||||
|
</p>
|
||||||
|
{member.role === "admin" && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
<Shield className="w-3 h-3 mr-1" />
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{member.role === "owner" && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
<Shield className="w-3 h-3 mr-1" />
|
||||||
|
Owner
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{member.user?.email}
|
||||||
</p>
|
</p>
|
||||||
{member.role === "admin" && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
<Shield className="w-3 h-3 mr-1" />
|
|
||||||
Admin
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{member.status === "disabled" && (
|
|
||||||
<Badge variant="destructive" className="text-xs">
|
|
||||||
Disabled
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
<DropdownMenu>
|
||||||
{member.email}
|
<DropdownMenuTrigger asChild>
|
||||||
</p>
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<User className="w-4 h-4 mr-2" />
|
||||||
|
View profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
Change role
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="text-destructive">
|
||||||
|
Remove member
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground hidden sm:block">
|
))}
|
||||||
Active {member.lastActive}
|
</div>
|
||||||
</p>
|
)}
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<User className="w-4 h-4 mr-2" />
|
|
||||||
View profile
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Shield className="w-4 h-4 mr-2" />
|
|
||||||
Change role
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem className="text-destructive">
|
|
||||||
{member.status === "active" ? "Disable" : "Enable"} account
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -171,35 +188,8 @@ export default function MembersPage() {
|
|||||||
<TabsContent value="invites">
|
<TabsContent value="invites">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="divide-y">
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
{pendingInvites.map((invite) => (
|
No pending invitations
|
||||||
<div key={invite.id} className="p-4 flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
|
||||||
<Mail className="w-4 h-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-medium text-foreground truncate">
|
|
||||||
{invite.email}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>Invited as {invite.role}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
Expires in {invite.expiresAt}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
Resend
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" className="text-destructive">
|
|
||||||
Revoke
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Plus, Key, ExternalLink, MoreHorizontal, Copy, RefreshCw, Trash2 } from "lucide-react";
|
import { Plus, Key, MoreHorizontal, Copy, Trash2, Loader2, AlertCircle, CheckCircle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -21,39 +21,75 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { api, OIDCClient, OIDCClientWithSecret } from "@/lib/api";
|
||||||
const clients = [
|
import { useToast } from "@/hooks/use-toast";
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
name: "GitLab",
|
|
||||||
clientId: "gitlab_prod_xxxxxxxxxxxxx",
|
|
||||||
redirectUris: ["https://gitlab.example.com/callback"],
|
|
||||||
scopes: ["openid", "profile", "email"],
|
|
||||||
createdAt: "2024-01-10",
|
|
||||||
lastUsed: "2 hours ago",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
name: "Grafana",
|
|
||||||
clientId: "grafana_prod_xxxxxxxxxxxxx",
|
|
||||||
redirectUris: ["https://grafana.example.com/login/generic_oauth"],
|
|
||||||
scopes: ["openid", "profile"],
|
|
||||||
createdAt: "2024-01-08",
|
|
||||||
lastUsed: "5 minutes ago",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
name: "OAuth2 Proxy",
|
|
||||||
clientId: "oauth2proxy_xxxxxxxxxxxxx",
|
|
||||||
redirectUris: ["https://auth.example.com/oauth2/callback"],
|
|
||||||
scopes: ["openid", "profile", "email", "groups"],
|
|
||||||
createdAt: "2024-01-05",
|
|
||||||
lastUsed: "1 day ago",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function OIDCClientsPage() {
|
export default function OIDCClientsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [orgId, setOrgId] = useState<string | null>(null);
|
||||||
|
const [clients, setClients] = useState<OIDCClient[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [newSecret, setNewSecret] = useState<{ clientId: string; secret: string } | null>(null);
|
||||||
|
|
||||||
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const urisRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const loadData = (id: string) => {
|
||||||
|
api.organizations.getClients(id)
|
||||||
|
.then((data) => setClients(data.clients))
|
||||||
|
.catch(() => toast({ title: "Error", description: "Failed to load OIDC clients.", variant: "destructive" }))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.users.organizations()
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.organizations.length) { setIsLoading(false); return; }
|
||||||
|
const id = data.organizations[0].id;
|
||||||
|
setOrgId(id);
|
||||||
|
loadData(id);
|
||||||
|
})
|
||||||
|
.catch(() => { setIsLoading(false); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!orgId || !nameRef.current || !urisRef.current) return;
|
||||||
|
const name = nameRef.current.value.trim();
|
||||||
|
const uris = urisRef.current.value.trim().split(/[\n,]+/).map((u) => u.trim()).filter(Boolean);
|
||||||
|
if (!name || !uris.length) return;
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const result = await api.organizations.createClient(orgId, name, uris);
|
||||||
|
const created = result.client as OIDCClientWithSecret;
|
||||||
|
setClients((prev) => [...prev, created]);
|
||||||
|
setNewSecret({ clientId: created.client_id, secret: created.client_secret });
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Error", description: "Failed to create client.", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (clientId: string) => {
|
||||||
|
if (!orgId) return;
|
||||||
|
try {
|
||||||
|
await api.organizations.deleteClient(orgId, clientId);
|
||||||
|
setClients((prev) => prev.filter((c) => c.id !== clientId));
|
||||||
|
toast({ title: "Client deleted", description: "OIDC client deactivated successfully." });
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Error", description: "Failed to delete client.", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() =>
|
||||||
|
toast({ title: "Copied", description: "Copied to clipboard." })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
@@ -81,7 +117,7 @@ export default function OIDCClientsPage() {
|
|||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="clientName">Client name</Label>
|
<Label htmlFor="clientName">Client name</Label>
|
||||||
<Input id="clientName" placeholder="My Application" />
|
<Input id="clientName" placeholder="My Application" ref={nameRef} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="redirectUris">Redirect URIs</Label>
|
<Label htmlFor="redirectUris">Redirect URIs</Label>
|
||||||
@@ -89,17 +125,18 @@ export default function OIDCClientsPage() {
|
|||||||
id="redirectUris"
|
id="redirectUris"
|
||||||
placeholder="https://myapp.example.com/callback"
|
placeholder="https://myapp.example.com/callback"
|
||||||
className="min-h-[80px]"
|
className="min-h-[80px]"
|
||||||
|
ref={urisRef}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
One URI per line. These are the allowed callback URLs.
|
One URI per line. These are the allowed callback URLs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)}>
|
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={isCreating}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setIsCreateOpen(false)}>
|
<Button onClick={handleCreate} disabled={isCreating}>
|
||||||
Create client
|
{isCreating ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Creating...</> : "Create client"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,71 +144,101 @@ export default function OIDCClientsPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Show new client secret once */}
|
||||||
{clients.map((client) => (
|
{newSecret && (
|
||||||
<Card key={client.id}>
|
<Card className="mb-4 border-success/50 bg-success/5">
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-4 flex items-start gap-3">
|
||||||
<div className="flex items-start justify-between">
|
<CheckCircle className="w-5 h-5 text-success mt-0.5 flex-shrink-0" />
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
<p className="font-medium text-foreground">Client created — save your secret now</p>
|
||||||
<Key className="w-6 h-6 text-primary" />
|
<p className="text-sm text-muted-foreground mb-2">This secret will not be shown again.</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs bg-muted px-2 py-1 rounded font-mono break-all">{newSecret.secret}</code>
|
||||||
|
<Button variant="ghost" size="icon" className="w-6 h-6 flex-shrink-0" onClick={() => copyToClipboard(newSecret.secret)}>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" className="w-6 h-6" onClick={() => setNewSecret(null)}>×</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : clients.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="text-center py-12">
|
||||||
|
<AlertCircle className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
|
||||||
|
<p className="text-muted-foreground">No OIDC clients configured yet.</p>
|
||||||
|
<Button className="mt-4" onClick={() => setIsCreateOpen(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add your first client
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{clients.map((client) => (
|
||||||
|
<Card key={client.id}>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Key className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-foreground">{client.name}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
|
||||||
|
{client.client_id}
|
||||||
|
</code>
|
||||||
|
<Button variant="ghost" size="icon" className="w-6 h-6" onClick={() => copyToClipboard(client.client_id)}>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-3">
|
||||||
|
{(client.scopes ?? []).map((scope) => (
|
||||||
|
<Badge key={scope} variant="secondary" className="text-xs">
|
||||||
|
{scope}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<DropdownMenu>
|
||||||
<h3 className="font-semibold text-foreground">{client.name}</h3>
|
<DropdownMenuTrigger asChild>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<Button variant="ghost" size="icon">
|
||||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
{client.clientId}
|
|
||||||
</code>
|
|
||||||
<Button variant="ghost" size="icon" className="w-6 h-6">
|
|
||||||
<Copy className="w-3 h-3" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DropdownMenuTrigger>
|
||||||
<div className="flex flex-wrap gap-1 mt-3">
|
<DropdownMenuContent align="end">
|
||||||
{client.scopes.map((scope) => (
|
<DropdownMenuSeparator />
|
||||||
<Badge key={scope} variant="secondary" className="text-xs">
|
<DropdownMenuItem
|
||||||
{scope}
|
className="text-destructive"
|
||||||
</Badge>
|
onClick={() => handleDelete(client.id)}
|
||||||
))}
|
>
|
||||||
</div>
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete client
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 pt-4 border-t flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Created {new Date(client.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(client.redirect_uris ?? []).length} redirect URI{(client.redirect_uris ?? []).length !== 1 ? "s" : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
</CardContent>
|
||||||
<DropdownMenuTrigger asChild>
|
</Card>
|
||||||
<Button variant="ghost" size="icon">
|
))}
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
</div>
|
||||||
</Button>
|
)}
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
|
||||||
View details
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Rotate secret
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem className="text-destructive">
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
Delete client
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 pt-4 border-t flex items-center justify-between text-sm text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span>Created {client.createdAt}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Last used {client.lastUsed}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{client.redirectUris.length} redirect URI{client.redirectUris.length > 1 ? "s" : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+130
-103
@@ -1,90 +1,77 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Search, Filter, Download, User, Settings, Key, UserPlus, AlertTriangle } from "lucide-react";
|
import { Search, Filter, Download, User, Settings, Key, UserPlus, AlertTriangle, Loader2 } from "lucide-react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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";
|
||||||
|
|
||||||
const auditEvents = [
|
const getEventIcon = (action: string) => {
|
||||||
{
|
if (action.includes("member") || action.includes("MEMBER")) {
|
||||||
id: "1",
|
return <UserPlus className="w-4 h-4" />;
|
||||||
type: "member_invited",
|
|
||||||
actor: "John Doe",
|
|
||||||
target: "alice@example.com",
|
|
||||||
timestamp: "2024-01-15T10:30:00Z",
|
|
||||||
details: "Invited as member",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
type: "policy_changed",
|
|
||||||
actor: "John Doe",
|
|
||||||
target: "Password Policy",
|
|
||||||
timestamp: "2024-01-15T09:00:00Z",
|
|
||||||
details: "Minimum length changed from 8 to 12",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
type: "member_disabled",
|
|
||||||
actor: "Jane Smith",
|
|
||||||
target: "bob@example.com",
|
|
||||||
timestamp: "2024-01-14T15:45:00Z",
|
|
||||||
details: "Account disabled",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
type: "client_created",
|
|
||||||
actor: "John Doe",
|
|
||||||
target: "GitLab",
|
|
||||||
timestamp: "2024-01-14T12:00:00Z",
|
|
||||||
details: "OIDC client created",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5",
|
|
||||||
type: "role_changed",
|
|
||||||
actor: "John Doe",
|
|
||||||
target: "jane@example.com",
|
|
||||||
timestamp: "2024-01-13T09:00:00Z",
|
|
||||||
details: "Role changed from member to admin",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const getEventIcon = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case "member_invited":
|
|
||||||
case "role_changed":
|
|
||||||
return <UserPlus className="w-4 h-4" />;
|
|
||||||
case "policy_changed":
|
|
||||||
return <Settings className="w-4 h-4" />;
|
|
||||||
case "member_disabled":
|
|
||||||
return <AlertTriangle className="w-4 h-4" />;
|
|
||||||
case "client_created":
|
|
||||||
return <Key className="w-4 h-4" />;
|
|
||||||
default:
|
|
||||||
return <User className="w-4 h-4" />;
|
|
||||||
}
|
}
|
||||||
|
if (action.includes("policy") || action.includes("POLICY") || action.includes("mfa")) {
|
||||||
|
return <Settings className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
if (action.includes("delete") || action.includes("DELETE") || action.includes("disable")) {
|
||||||
|
return <AlertTriangle className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
if (action.includes("client") || action.includes("oidc") || action.includes("key")) {
|
||||||
|
return <Key className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
return <User className="w-4 h-4" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEventTitle = (type: string) => {
|
const getEventTitle = (action: string) => {
|
||||||
switch (type) {
|
const parts = action.split(".");
|
||||||
case "member_invited":
|
return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
|
||||||
return "Member invited";
|
};
|
||||||
case "policy_changed":
|
|
||||||
return "Policy changed";
|
const getActionCategory = (action: string): string => {
|
||||||
case "member_disabled":
|
if (action.includes("member") || action.includes("MEMBER")) return "members";
|
||||||
return "Member disabled";
|
if (action.includes("policy") || action.includes("POLICY") || action.includes("mfa")) return "policies";
|
||||||
case "client_created":
|
if (action.includes("client") || action.includes("OIDC")) return "clients";
|
||||||
return "OIDC client created";
|
return "other";
|
||||||
case "role_changed":
|
|
||||||
return "Role changed";
|
|
||||||
default:
|
|
||||||
return "Event";
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function OrgAuditPage() {
|
export default function OrgAuditPage() {
|
||||||
|
const params = useParams<{ orgId?: string }>();
|
||||||
|
const { orgId: fallbackOrgId } = useCurrentOrganizationId();
|
||||||
|
const orgId = params.orgId || fallbackOrgId;
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [typeFilter, setTypeFilter] = useState("all");
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
|
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchAuditLogs = useCallback(async (currentOrgId: string) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await api.organizations.getAuditLogs(currentOrgId);
|
||||||
|
setAuditLogs(response.audit_logs || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch audit logs:", err);
|
||||||
|
setError("Failed to load audit logs. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
@@ -96,6 +83,20 @@ export default function OrgAuditPage() {
|
|||||||
}).format(date);
|
}).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;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
@@ -137,39 +138,65 @@ export default function OrgAuditPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="divide-y">
|
{isLoading ? (
|
||||||
{auditEvents.map((event) => (
|
<div className="flex items-center justify-center p-8">
|
||||||
<div key={event.id} className="p-4 flex items-start gap-4">
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
<div
|
<span className="ml-2 text-muted-foreground">Loading audit logs...</span>
|
||||||
className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
</div>
|
||||||
event.type === "member_disabled"
|
) : error ? (
|
||||||
? "bg-destructive/10 text-destructive"
|
<div className="p-8 text-center text-destructive">
|
||||||
: "bg-accent/10 text-accent"
|
{error}
|
||||||
}`}
|
</div>
|
||||||
>
|
) : filteredLogs.length === 0 ? (
|
||||||
{getEventIcon(event.type)}
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
</div>
|
No audit events found
|
||||||
<div className="flex-1 min-w-0">
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
) : (
|
||||||
<p className="font-medium text-foreground">
|
<div className="divide-y">
|
||||||
{getEventTitle(event.type)}
|
{filteredLogs.map((log) => (
|
||||||
</p>
|
<div key={log.id} className="p-4 flex items-start gap-4">
|
||||||
<Badge variant="secondary" className="text-xs">
|
<div
|
||||||
{event.target}
|
className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||||
</Badge>
|
!log.success
|
||||||
|
? "bg-destructive/10 text-destructive"
|
||||||
|
: "bg-accent/10 text-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getEventIcon(log.action)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-sm text-muted-foreground">
|
<div className="flex-1 min-w-0">
|
||||||
<span>by {event.actor}</span>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="mx-2">•</span>
|
<p className="font-medium text-foreground">
|
||||||
<span>{event.details}</span>
|
{getEventTitle(log.action)}
|
||||||
|
</p>
|
||||||
|
{log.resource_type && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{log.resource_type}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!log.success && (
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
Failed
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
<span>by {log.user?.full_name || log.user?.email || "System"}</span>
|
||||||
|
{log.description && (
|
||||||
|
<>
|
||||||
|
<span className="mx-2">•</span>
|
||||||
|
<span>{log.description}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDate(log.created_at)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground whitespace-nowrap">
|
))}
|
||||||
{formatDate(event.timestamp)}
|
</div>
|
||||||
</p>
|
)}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
import { Building2, Users, Shield, Key, ArrowRight, TrendingUp } from "lucide-react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Building2, Users, Shield, Key, ArrowRight, TrendingUp, Loader2 } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { api, Organization, OIDCClient } from "@/lib/api";
|
||||||
|
|
||||||
export default function OrgOverviewPage() {
|
export default function OrgOverviewPage() {
|
||||||
// Mock organization data
|
const [org, setOrg] = useState<Organization | null>(null);
|
||||||
const org = {
|
const [memberCount, setMemberCount] = useState<number>(0);
|
||||||
name: "Acme Corp",
|
const [clientCount, setClientCount] = useState<number>(0);
|
||||||
createdAt: "January 2024",
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
stats: {
|
|
||||||
totalMembers: 24,
|
useEffect(() => {
|
||||||
activeToday: 18,
|
api.users.organizations()
|
||||||
pendingInvites: 3,
|
.then(async (data) => {
|
||||||
oidcClients: 5,
|
if (!data.organizations.length) return;
|
||||||
},
|
const first = data.organizations[0];
|
||||||
};
|
setOrg(first);
|
||||||
|
|
||||||
|
const [membersResp, clientsResp] = await Promise.allSettled([
|
||||||
|
api.organizations.getMembers(first.id),
|
||||||
|
api.organizations.getClients(first.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (membersResp.status === "fulfilled") setMemberCount(membersResp.value.count);
|
||||||
|
if (clientsResp.status === "fulfilled") setClientCount((clientsResp.value as { clients: OIDCClient[]; count: number }).count);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const quickLinks = [
|
const quickLinks = [
|
||||||
{
|
{
|
||||||
@@ -37,6 +50,18 @@ export default function OrgOverviewPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="page-container flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = org?.created_at
|
||||||
|
? new Date(org.created_at).toLocaleDateString("en-US", { month: "long", year: "numeric" })
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -45,42 +70,20 @@ export default function OrgOverviewPage() {
|
|||||||
<Building2 className="w-7 h-7 text-primary" />
|
<Building2 className="w-7 h-7 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">{org.name}</h1>
|
<h1 className="page-title">{org?.name ?? "Organization"}</h1>
|
||||||
<p className="page-description">Created {org.createdAt}</p>
|
{createdAt && <p className="page-description">Created {createdAt}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4 mb-8">
|
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3 mb-8">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Total Members</p>
|
<p className="text-sm text-muted-foreground">Total Members</p>
|
||||||
<p className="text-2xl font-semibold">{org.stats.totalMembers}</p>
|
<p className="text-2xl font-semibold">{memberCount}</p>
|
||||||
</div>
|
|
||||||
<Users className="w-8 h-8 text-muted-foreground/30" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Active Today</p>
|
|
||||||
<p className="text-2xl font-semibold">{org.stats.activeToday}</p>
|
|
||||||
</div>
|
|
||||||
<TrendingUp className="w-8 h-8 text-muted-foreground/30" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">Pending Invites</p>
|
|
||||||
<p className="text-2xl font-semibold">{org.stats.pendingInvites}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Users className="w-8 h-8 text-muted-foreground/30" />
|
<Users className="w-8 h-8 text-muted-foreground/30" />
|
||||||
</div>
|
</div>
|
||||||
@@ -91,12 +94,23 @@ export default function OrgOverviewPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">OIDC Clients</p>
|
<p className="text-sm text-muted-foreground">OIDC Clients</p>
|
||||||
<p className="text-2xl font-semibold">{org.stats.oidcClients}</p>
|
<p className="text-2xl font-semibold">{clientCount}</p>
|
||||||
</div>
|
</div>
|
||||||
<Key className="w-8 h-8 text-muted-foreground/30" />
|
<Key className="w-8 h-8 text-muted-foreground/30" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Org ID</p>
|
||||||
|
<p className="text-xs font-mono text-muted-foreground mt-1 truncate max-w-[140px]">{org?.id ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
<TrendingUp className="w-8 h-8 text-muted-foreground/30" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Links */}
|
{/* Quick Links */}
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Search, Plus, MoreHorizontal, Users, Loader2, Trash2, Edit2, Link as LinkIcon, Unlink } 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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { api, Principal, Department } from "@/lib/api";
|
||||||
|
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||||
|
|
||||||
|
export default function PrincipalsPage() {
|
||||||
|
const params = useParams<{ orgId?: string }>();
|
||||||
|
const { orgId: fallbackOrgId } = useCurrentOrganizationId();
|
||||||
|
const orgId = params.orgId || fallbackOrgId;
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [principals, setPrincipals] = useState<Principal[]>([]);
|
||||||
|
const [departments, setDepartments] = useState<Department[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false);
|
||||||
|
const [editingPrincipal, setEditingPrincipal] = useState<Principal | null>(null);
|
||||||
|
const [selectedPrincipalForLink, setSelectedPrincipalForLink] = useState<Principal | null>(null);
|
||||||
|
const [selectedDepartmentId, setSelectedDepartmentId] = useState("");
|
||||||
|
const [formData, setFormData] = useState({ name: "", description: "" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setError(null);
|
||||||
|
setPrincipals([]);
|
||||||
|
setDepartments([]);
|
||||||
|
if (!orgId) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchData(orgId);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [orgId]);
|
||||||
|
|
||||||
|
const fetchData = async (currentOrgId: string) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const [principalsRes, deptRes] = await Promise.all([
|
||||||
|
api.organizations.getPrincipals(currentOrgId),
|
||||||
|
api.organizations.getDepartments(currentOrgId),
|
||||||
|
]);
|
||||||
|
setPrincipals(principalsRes.principals || []);
|
||||||
|
setDepartments(deptRes.departments || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch data:", err);
|
||||||
|
setError("Failed to load data. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreatePrincipal = async () => {
|
||||||
|
if (!orgId || !formData.name.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.organizations.createPrincipal(
|
||||||
|
orgId,
|
||||||
|
formData.name,
|
||||||
|
formData.description || undefined
|
||||||
|
);
|
||||||
|
setFormData({ name: "", description: "" });
|
||||||
|
setIsCreateDialogOpen(false);
|
||||||
|
await fetchData(orgId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create principal:");
|
||||||
|
setError("Failed to create principal.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdatePrincipal = async () => {
|
||||||
|
if (!orgId || !editingPrincipal || !formData.name.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.organizations.updatePrincipal(
|
||||||
|
orgId,
|
||||||
|
editingPrincipal.id,
|
||||||
|
{
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setFormData({ name: "", description: "" });
|
||||||
|
setEditingPrincipal(null);
|
||||||
|
setIsEditDialogOpen(false);
|
||||||
|
await fetchData(orgId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update principal:");
|
||||||
|
setError("Failed to update principal.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePrincipal = async (principalId: string) => {
|
||||||
|
if (!orgId || !confirm("Are you sure you want to delete this principal?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.organizations.deletePrincipal(orgId, principalId);
|
||||||
|
await fetchData(orgId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete principal:");
|
||||||
|
setError("Failed to delete principal.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkPrincipal = async () => {
|
||||||
|
if (!orgId || !selectedPrincipalForLink || !selectedDepartmentId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.organizations.linkPrincipalToDepartment(
|
||||||
|
orgId,
|
||||||
|
selectedPrincipalForLink.id,
|
||||||
|
selectedDepartmentId
|
||||||
|
);
|
||||||
|
setSelectedPrincipalForLink(null);
|
||||||
|
setSelectedDepartmentId("");
|
||||||
|
setIsLinkDialogOpen(false);
|
||||||
|
await fetchData(orgId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to link principal:");
|
||||||
|
setError("Failed to link principal to department.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (principal: Principal) => {
|
||||||
|
setEditingPrincipal(principal);
|
||||||
|
setFormData({ name: principal.name, description: principal.description || "" });
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openLinkDialog = (principal: Principal) => {
|
||||||
|
setSelectedPrincipalForLink(principal);
|
||||||
|
setSelectedDepartmentId("");
|
||||||
|
setIsLinkDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredPrincipals = principals.filter((principal) => {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
principal.name.toLowerCase().includes(searchLower) ||
|
||||||
|
(principal.description?.toLowerCase().includes(searchLower) ?? false)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container">
|
||||||
|
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Principals</h1>
|
||||||
|
<p className="page-description">
|
||||||
|
Manage principals and link them to departments
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => { setFormData({ name: "", description: "" }); setIsCreateDialogOpen(true); }}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Principal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search principals..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10 max-w-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-muted-foreground">Loading principals...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-8 text-center text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : filteredPrincipals.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
|
No principals found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{filteredPrincipals.map((principal) => (
|
||||||
|
<div key={principal.id} className="p-4 flex items-start gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
{principal.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{principal.description && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{principal.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Created {new Date(principal.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => openEditDialog(principal)}>
|
||||||
|
<Edit2 className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => openLinkDialog(principal)}>
|
||||||
|
<LinkIcon className="w-4 h-4 mr-2" />
|
||||||
|
Link to Department
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => handleDeletePrincipal(principal.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create Principal Dialog */}
|
||||||
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Principal</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new principal to manage access and permissions
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="principal-name">Principal Name</Label>
|
||||||
|
<Input
|
||||||
|
id="principal-name"
|
||||||
|
placeholder="e.g., Backend Team"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="principal-desc">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="principal-desc"
|
||||||
|
placeholder="Optional description..."
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreatePrincipal}>
|
||||||
|
Create Principal
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Principal Dialog */}
|
||||||
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Principal</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update principal information
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-principal-name">Principal Name</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-principal-name"
|
||||||
|
placeholder="e.g., Backend Team"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-principal-desc">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-principal-desc"
|
||||||
|
placeholder="Optional description..."
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleUpdatePrincipal}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Link Principal to Department Dialog */}
|
||||||
|
<Dialog open={isLinkDialogOpen} onOpenChange={setIsLinkDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Link Principal to Department</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Associate this principal with a department
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="dept-select">Select Department</Label>
|
||||||
|
<Select value={selectedDepartmentId} onValueChange={setSelectedDepartmentId}>
|
||||||
|
<SelectTrigger id="dept-select">
|
||||||
|
<SelectValue placeholder="Choose a department..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{departments.map((dept) => (
|
||||||
|
<SelectItem key={dept.id} value={dept.id}>
|
||||||
|
{dept.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsLinkDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleLinkPrincipal} disabled={!selectedDepartmentId}>
|
||||||
|
Link Principal
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+120
-139
@@ -1,104 +1,53 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle, CheckCircle, MapPin } from "lucide-react";
|
import { LogIn, LogOut, Key, Fingerprint, Smartphone, AlertTriangle, Loader2, RefreshCw } from "lucide-react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
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";
|
||||||
|
|
||||||
const activityEvents = [
|
// Map audit log action strings to display info
|
||||||
{
|
const getEventDisplay = (action: string) => {
|
||||||
id: "1",
|
const a = action.toLowerCase();
|
||||||
type: "login_success",
|
if (a.includes("login") && a.includes("fail")) {
|
||||||
method: "password",
|
return { icon: <AlertTriangle className="w-4 h-4" />, title: "Failed login attempt", failed: true };
|
||||||
timestamp: "2024-01-15T10:30:00Z",
|
|
||||||
location: "San Francisco, CA",
|
|
||||||
device: "Chrome on macOS",
|
|
||||||
ip: "192.168.1.1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
type: "login_success",
|
|
||||||
method: "passkey",
|
|
||||||
timestamp: "2024-01-14T15:45:00Z",
|
|
||||||
location: "San Francisco, CA",
|
|
||||||
device: "Safari on iOS",
|
|
||||||
ip: "192.168.1.2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
type: "login_failed",
|
|
||||||
method: "password",
|
|
||||||
timestamp: "2024-01-14T12:00:00Z",
|
|
||||||
location: "Unknown",
|
|
||||||
device: "Firefox on Windows",
|
|
||||||
ip: "10.0.0.5",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
type: "mfa_enabled",
|
|
||||||
method: "totp",
|
|
||||||
timestamp: "2024-01-13T09:00:00Z",
|
|
||||||
location: "San Francisco, CA",
|
|
||||||
device: "Chrome on macOS",
|
|
||||||
ip: "192.168.1.1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5",
|
|
||||||
type: "passkey_added",
|
|
||||||
method: "passkey",
|
|
||||||
timestamp: "2024-01-12T14:30:00Z",
|
|
||||||
location: "San Francisco, CA",
|
|
||||||
device: "Safari on macOS",
|
|
||||||
ip: "192.168.1.1",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const getEventIcon = (type: string, method: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case "login_success":
|
|
||||||
return method === "passkey" ? (
|
|
||||||
<Fingerprint className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<LogIn className="w-4 h-4" />
|
|
||||||
);
|
|
||||||
case "login_failed":
|
|
||||||
return <AlertTriangle className="w-4 h-4" />;
|
|
||||||
case "mfa_enabled":
|
|
||||||
return <Smartphone className="w-4 h-4" />;
|
|
||||||
case "passkey_added":
|
|
||||||
return <Fingerprint className="w-4 h-4" />;
|
|
||||||
case "logout":
|
|
||||||
return <LogOut className="w-4 h-4" />;
|
|
||||||
default:
|
|
||||||
return <Key className="w-4 h-4" />;
|
|
||||||
}
|
}
|
||||||
};
|
if (a.includes("login") || a.includes("authenticate")) {
|
||||||
|
return { icon: <LogIn className="w-4 h-4" />, title: "Signed in", failed: false };
|
||||||
const getEventTitle = (type: string, method: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case "login_success":
|
|
||||||
return `Signed in with ${method}`;
|
|
||||||
case "login_failed":
|
|
||||||
return "Failed login attempt";
|
|
||||||
case "mfa_enabled":
|
|
||||||
return "Two-factor authentication enabled";
|
|
||||||
case "passkey_added":
|
|
||||||
return "Passkey added";
|
|
||||||
case "logout":
|
|
||||||
return "Signed out";
|
|
||||||
default:
|
|
||||||
return "Security event";
|
|
||||||
}
|
}
|
||||||
};
|
if (a.includes("logout") || a.includes("sign_out")) {
|
||||||
|
return { icon: <LogOut className="w-4 h-4" />, title: "Signed out", failed: false };
|
||||||
const getEventStatus = (type: string) => {
|
|
||||||
if (type === "login_failed") {
|
|
||||||
return { variant: "destructive" as const, label: "Failed" };
|
|
||||||
}
|
}
|
||||||
return { variant: "default" as const, label: "Success" };
|
if (a.includes("passkey") || a.includes("webauthn")) {
|
||||||
|
return { icon: <Fingerprint className="w-4 h-4" />, title: "Passkey event", failed: false };
|
||||||
|
}
|
||||||
|
if (a.includes("mfa") || a.includes("totp") || a.includes("2fa")) {
|
||||||
|
return { icon: <Smartphone className="w-4 h-4" />, title: "MFA event", failed: false };
|
||||||
|
}
|
||||||
|
if (a.includes("ssh")) {
|
||||||
|
return { icon: <Key className="w-4 h-4" />, title: "SSH key event", failed: false };
|
||||||
|
}
|
||||||
|
return { icon: <Key className="w-4 h-4" />, title: action.replace(/_/g, " "), failed: !action.includes("success") && a.includes("fail") };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const [filter, setFilter] = useState("all");
|
const [filter, setFilter] = useState("all");
|
||||||
|
const [events, setEvents] = useState<AuditLogEntry[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const loadEvents = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
api.users.auditLogs({ per_page: "50" })
|
||||||
|
.then((data) => {
|
||||||
|
setEvents(data.audit_logs ?? []);
|
||||||
|
})
|
||||||
|
.catch(() => setError("Failed to load activity. Please try again."))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadEvents(); }, []);
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
@@ -110,6 +59,16 @@ export default function ActivityPage() {
|
|||||||
}).format(date);
|
}).format(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
@@ -119,62 +78,84 @@ export default function ActivityPage() {
|
|||||||
Your recent account activity and security events
|
Your recent account activity and security events
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Select value={filter} onValueChange={setFilter}>
|
<div className="flex items-center gap-2">
|
||||||
<SelectTrigger className="w-[180px]">
|
<Select value={filter} onValueChange={setFilter}>
|
||||||
<SelectValue placeholder="Filter events" />
|
<SelectTrigger className="w-[180px]">
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="Filter events" />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
<SelectItem value="all">All events</SelectItem>
|
<SelectContent>
|
||||||
<SelectItem value="logins">Logins only</SelectItem>
|
<SelectItem value="all">All events</SelectItem>
|
||||||
<SelectItem value="security">Security changes</SelectItem>
|
<SelectItem value="logins">Logins only</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="security">Security changes</SelectItem>
|
||||||
</Select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="icon" onClick={loadEvents} disabled={isLoading}>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="divide-y">
|
{isLoading ? (
|
||||||
{activityEvents.map((event) => {
|
<div className="flex items-center justify-center py-12">
|
||||||
const status = getEventStatus(event.type);
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
return (
|
</div>
|
||||||
<div key={event.id} className="p-4 flex items-start gap-4">
|
) : error ? (
|
||||||
<div
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
<AlertTriangle className="w-8 h-8 mx-auto mb-2 text-destructive" />
|
||||||
event.type === "login_failed"
|
<p>{error}</p>
|
||||||
? "bg-destructive/10 text-destructive"
|
</div>
|
||||||
: "bg-accent/10 text-accent"
|
) : filteredEvents.length === 0 ? (
|
||||||
}`}
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
>
|
<p>No activity events found.</p>
|
||||||
{getEventIcon(event.type, event.method)}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
<div className="flex-1 min-w-0">
|
<div className="divide-y">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
{filteredEvents.map((event) => {
|
||||||
<p className="font-medium text-foreground">
|
const display = getEventDisplay(event.action);
|
||||||
{getEventTitle(event.type, event.method)}
|
return (
|
||||||
</p>
|
<div key={event.id} className="p-4 flex items-start gap-4">
|
||||||
{event.type === "login_failed" && (
|
<div
|
||||||
<Badge variant="destructive" className="text-xs">
|
className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||||
Failed
|
display.failed || !event.success
|
||||||
</Badge>
|
? "bg-destructive/10 text-destructive"
|
||||||
)}
|
: "bg-accent/10 text-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{display.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-sm text-muted-foreground space-y-0.5">
|
<div className="flex-1 min-w-0">
|
||||||
<p>{event.device}</p>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<div className="flex items-center gap-2">
|
<p className="font-medium text-foreground capitalize">
|
||||||
<MapPin className="w-3 h-3" />
|
{display.title}
|
||||||
<span>{event.location}</span>
|
</p>
|
||||||
<span className="text-muted-foreground/50">•</span>
|
{(!event.success || display.failed) && (
|
||||||
<span className="font-mono text-xs">{event.ip}</span>
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
Failed
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground space-y-0.5">
|
||||||
|
{event.description && <p>{event.description}</p>}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{event.ip_address && (
|
||||||
|
<span className="font-mono text-xs">{event.ip_address}</span>
|
||||||
|
)}
|
||||||
|
{event.user_agent && (
|
||||||
|
<span className="truncate max-w-[200px]">{event.user_agent}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDate(event.created_at)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground whitespace-nowrap">
|
);
|
||||||
{formatDate(event.timestamp)}
|
})}
|
||||||
</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user