mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-19 02:22:11 +01:00
update full UI
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Modal, Text, ActionIcon } from '@mantine/core';
|
||||
import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import Overview from '@app/components/shared/config/configSections/Overview';
|
||||
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
|
||||
import { NavKey } from '@app/components/shared/config/types';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import '@app/components/shared/AppConfigModal.css';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
@@ -15,20 +16,50 @@ interface AppConfigModalProps {
|
||||
}
|
||||
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [active, setActive] = useState<NavKey>('overview');
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// Extract section from URL path (e.g., /settings/people -> people)
|
||||
const getSectionFromPath = (pathname: string): NavKey | null => {
|
||||
const match = pathname.match(/\/settings\/([^/]+)/);
|
||||
if (match && match[1]) {
|
||||
const validSections: NavKey[] = [
|
||||
'overview', 'people', 'teams', 'general', 'hotkeys',
|
||||
'adminGeneral', 'adminSecurity', 'adminConnections', 'adminLegal',
|
||||
'adminPrivacy', 'adminDatabase', 'adminPremium', 'adminFeatures',
|
||||
'adminPlan', 'adminAudit', 'adminUsage', 'adminEndpoints', 'adminAdvanced'
|
||||
];
|
||||
const section = match[1] as NavKey;
|
||||
return validSections.includes(section) ? section : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Sync active state with URL path
|
||||
useEffect(() => {
|
||||
const section = getSectionFromPath(location.pathname);
|
||||
if (opened && section) {
|
||||
setActive(section);
|
||||
} else if (opened && location.pathname.startsWith('/settings') && !section) {
|
||||
// If at /settings without a section, redirect to overview
|
||||
navigate('/settings/overview', { replace: true });
|
||||
}
|
||||
}, [location.pathname, opened, navigate]);
|
||||
|
||||
// Handle custom events for backwards compatibility
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
||||
if (detail?.key) {
|
||||
setActive(detail.key);
|
||||
navigate(`/settings/${detail.key}`);
|
||||
}
|
||||
};
|
||||
window.addEventListener('appConfig:navigate', handler as EventListener);
|
||||
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
|
||||
}, []);
|
||||
}, [navigate]);
|
||||
|
||||
const colors = useMemo(() => ({
|
||||
navBg: 'var(--modal-nav-bg)',
|
||||
@@ -46,17 +77,19 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
console.log('Logout placeholder for SaaS compatibility');
|
||||
};
|
||||
|
||||
// Get isAdmin from app config (based on JWT role)
|
||||
// Get isAdmin and runningEE from app config
|
||||
const isAdmin = config?.isAdmin ?? false;
|
||||
const runningEE = config?.runningEE ?? false;
|
||||
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useMemo(() =>
|
||||
createConfigNavSections(
|
||||
Overview,
|
||||
handleLogout,
|
||||
isAdmin
|
||||
isAdmin,
|
||||
runningEE
|
||||
),
|
||||
[isAdmin]
|
||||
[isAdmin, runningEE]
|
||||
);
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
@@ -75,10 +108,16 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
return null;
|
||||
}, [configNavSections, active]);
|
||||
|
||||
const handleClose = () => {
|
||||
// Navigate back to home when closing modal
|
||||
navigate('/', { replace: true });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
onClose={handleClose}
|
||||
title={null}
|
||||
size={isMobile ? "100%" : 980}
|
||||
centered
|
||||
@@ -109,15 +148,24 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
<div className="modal-nav-section-items">
|
||||
{section.items.map(item => {
|
||||
const isActive = active === item.key;
|
||||
const isDisabled = item.disabled ?? false;
|
||||
const color = isActive ? colors.navItemActive : colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
return (
|
||||
|
||||
const navItemContent = (
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => setActive(item.key)}
|
||||
onClick={() => {
|
||||
if (!isDisabled) {
|
||||
setActive(item.key);
|
||||
navigate(`/settings/${item.key}`);
|
||||
}
|
||||
}}
|
||||
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: isActive ? colors.navItemActiveBg : 'transparent',
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
||||
@@ -128,6 +176,20 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return isDisabled && item.disabledTooltip ? (
|
||||
<Tooltip
|
||||
key={item.key}
|
||||
label={item.disabledTooltip}
|
||||
position="right"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
{navItemContent}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={item.key}>{navItemContent}</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,7 +209,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="lg">{activeLabel}</Text>
|
||||
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
|
||||
<ActionIcon variant="subtle" onClick={handleClose} aria-label="Close">
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useRef, forwardRef, useEffect } from "react";
|
||||
import { ActionIcon, Stack, Divider } from "@mantine/core";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { useIsOverflowing } from '@app/hooks/useIsOverflowing';
|
||||
@@ -23,6 +24,8 @@ import {
|
||||
|
||||
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
@@ -34,6 +37,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const isOverflow = useIsOverflowing(scrollableRef);
|
||||
|
||||
// Open modal if URL is at /settings/*
|
||||
useEffect(() => {
|
||||
const isSettings = location.pathname.startsWith('/settings');
|
||||
setConfigModalOpen(isSettings);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = getActiveNavButton(selectedToolKey, readerMode);
|
||||
setActiveButton(next);
|
||||
@@ -180,6 +189,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
size: 'lg',
|
||||
type: 'modal',
|
||||
onClick: () => {
|
||||
navigate('/settings/overview');
|
||||
setConfigModalOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,17 @@ import AdminLegalSection from '@app/components/shared/config/configSections/Admi
|
||||
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
|
||||
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
|
||||
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
|
||||
import AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection';
|
||||
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
|
||||
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: string;
|
||||
component: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string;
|
||||
}
|
||||
|
||||
export interface ConfigNavSection {
|
||||
@@ -40,7 +45,8 @@ export interface ConfigColors {
|
||||
export const createConfigNavSections = (
|
||||
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
|
||||
onLogoutClick: () => void,
|
||||
isAdmin: boolean = false
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false
|
||||
): ConfigNavSection[] => {
|
||||
const sections: ConfigNavSection[] = [
|
||||
{
|
||||
@@ -90,10 +96,11 @@ export const createConfigNavSections = (
|
||||
},
|
||||
];
|
||||
|
||||
// Add Admin Settings section if user is admin
|
||||
// Add Admin sections if user is admin
|
||||
if (isAdmin) {
|
||||
// Configuration
|
||||
sections.push({
|
||||
title: 'Admin Settings',
|
||||
title: 'Configuration',
|
||||
items: [
|
||||
{
|
||||
key: 'adminGeneral',
|
||||
@@ -101,42 +108,6 @@ export const createConfigNavSections = (
|
||||
icon: 'settings-rounded',
|
||||
component: <AdminGeneralSection />
|
||||
},
|
||||
{
|
||||
key: 'adminSecurity',
|
||||
label: 'Security',
|
||||
icon: 'shield-rounded',
|
||||
component: <AdminSecuritySection />
|
||||
},
|
||||
{
|
||||
key: 'adminConnections',
|
||||
label: 'Connections',
|
||||
icon: 'link-rounded',
|
||||
component: <AdminConnectionsSection />
|
||||
},
|
||||
{
|
||||
key: 'adminLegal',
|
||||
label: 'Legal',
|
||||
icon: 'gavel-rounded',
|
||||
component: <AdminLegalSection />
|
||||
},
|
||||
{
|
||||
key: 'adminPrivacy',
|
||||
label: 'Privacy',
|
||||
icon: 'visibility-rounded',
|
||||
component: <AdminPrivacySection />
|
||||
},
|
||||
{
|
||||
key: 'adminDatabase',
|
||||
label: 'Database',
|
||||
icon: 'storage-rounded',
|
||||
component: <AdminDatabaseSection />
|
||||
},
|
||||
{
|
||||
key: 'adminPremium',
|
||||
label: 'Premium',
|
||||
icon: 'star-rounded',
|
||||
component: <AdminPremiumSection />
|
||||
},
|
||||
{
|
||||
key: 'adminFeatures',
|
||||
label: 'Features',
|
||||
@@ -149,6 +120,12 @@ export const createConfigNavSections = (
|
||||
icon: 'api-rounded',
|
||||
component: <AdminEndpointsSection />
|
||||
},
|
||||
{
|
||||
key: 'adminDatabase',
|
||||
label: 'Database',
|
||||
icon: 'storage-rounded',
|
||||
component: <AdminDatabaseSection />
|
||||
},
|
||||
{
|
||||
key: 'adminAdvanced',
|
||||
label: 'Advanced',
|
||||
@@ -157,6 +134,79 @@ export const createConfigNavSections = (
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Security & Authentication
|
||||
sections.push({
|
||||
title: 'Security & Authentication',
|
||||
items: [
|
||||
{
|
||||
key: 'adminSecurity',
|
||||
label: 'Security',
|
||||
icon: 'shield-rounded',
|
||||
component: <AdminSecuritySection />
|
||||
},
|
||||
{
|
||||
key: 'adminConnections',
|
||||
label: 'Connections',
|
||||
icon: 'link-rounded',
|
||||
component: <AdminConnectionsSection />
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Licensing & Analytics
|
||||
sections.push({
|
||||
title: 'Licensing & Analytics',
|
||||
items: [
|
||||
{
|
||||
key: 'adminPremium',
|
||||
label: 'Premium',
|
||||
icon: 'star-rounded',
|
||||
component: <AdminPremiumSection />
|
||||
},
|
||||
{
|
||||
key: 'adminPlan',
|
||||
label: 'Plan',
|
||||
icon: 'receipt-long-rounded',
|
||||
component: <AdminPlanSection />
|
||||
},
|
||||
{
|
||||
key: 'adminAudit',
|
||||
label: 'Audit',
|
||||
icon: 'fact-check-rounded',
|
||||
component: <AdminAuditSection />,
|
||||
disabled: !runningEE,
|
||||
disabledTooltip: 'Requires Enterprise license'
|
||||
},
|
||||
{
|
||||
key: 'adminUsage',
|
||||
label: 'Usage Analytics',
|
||||
icon: 'analytics-rounded',
|
||||
component: <AdminUsageSection />,
|
||||
disabled: !runningEE,
|
||||
disabledTooltip: 'Requires Enterprise license'
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Policies & Privacy
|
||||
sections.push({
|
||||
title: 'Policies & Privacy',
|
||||
items: [
|
||||
{
|
||||
key: 'adminLegal',
|
||||
label: 'Legal',
|
||||
icon: 'gavel-rounded',
|
||||
component: <AdminLegalSection />
|
||||
},
|
||||
{
|
||||
key: 'adminPrivacy',
|
||||
label: 'Privacy',
|
||||
icon: 'visibility-rounded',
|
||||
component: <AdminPrivacySection />
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Tabs, Loader, Alert, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService, { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
|
||||
import AuditSystemStatus from './audit/AuditSystemStatus';
|
||||
import AuditChartsSection from './audit/AuditChartsSection';
|
||||
import AuditEventsTable from './audit/AuditEventsTable';
|
||||
import AuditExportSection from './audit/AuditExportSection';
|
||||
|
||||
const AdminAuditSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSystemStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const status = await auditService.getSystemStatus();
|
||||
setSystemStatus(status);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load audit system status');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSystemStatus();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title={t('audit.error.title', 'Error loading audit system')}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!systemStatus) {
|
||||
return (
|
||||
<Alert color="yellow" title={t('audit.notAvailable', 'Audit system not available')}>
|
||||
{t('audit.notAvailableMessage', 'The audit system is not configured or not available.')}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<AuditSystemStatus status={systemStatus} />
|
||||
|
||||
{systemStatus.enabled ? (
|
||||
<Tabs defaultValue="dashboard">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="dashboard">
|
||||
{t('audit.tabs.dashboard', 'Dashboard')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="events">
|
||||
{t('audit.tabs.events', 'Audit Events')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="export">
|
||||
{t('audit.tabs.export', 'Export')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="dashboard" pt="md">
|
||||
<AuditChartsSection />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="events" pt="md">
|
||||
<AuditEventsTable />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="export" pt="md">
|
||||
<AuditExportSection />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : (
|
||||
<Alert color="blue" title={t('audit.disabled', 'Audit logging is disabled')}>
|
||||
{t(
|
||||
'audit.disabledMessage',
|
||||
'Enable audit logging in your application configuration to track system events.'
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminAuditSection;
|
||||
@@ -0,0 +1,196 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
Button,
|
||||
SegmentedControl,
|
||||
Loader,
|
||||
Alert,
|
||||
Card,
|
||||
Checkbox,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services/usageAnalyticsService';
|
||||
import UsageAnalyticsChart from './usage/UsageAnalyticsChart';
|
||||
import UsageAnalyticsTable from './usage/UsageAnalyticsTable';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
const AdminUsageSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useState<EndpointStatisticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [displayMode, setDisplayMode] = useState<'top10' | 'top20' | 'all'>('top10');
|
||||
const [includeHome, setIncludeHome] = useState(true);
|
||||
const [includeLogin, setIncludeLogin] = useState(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const limit = displayMode === 'all' ? undefined : displayMode === 'top10' ? 10 : 20;
|
||||
const response = await usageAnalyticsService.getEndpointStatistics(
|
||||
limit,
|
||||
includeHome,
|
||||
includeLogin
|
||||
);
|
||||
|
||||
setData(response);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load usage statistics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [displayMode, includeHome, includeLogin]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const getDisplayModeLabel = () => {
|
||||
switch (displayMode) {
|
||||
case 'top10':
|
||||
return t('usage.showing.top10', 'Top 10');
|
||||
case 'top20':
|
||||
return t('usage.showing.top20', 'Top 20');
|
||||
case 'all':
|
||||
return t('usage.showing.all', 'All');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Early returns for loading/error states
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title={t('usage.error', 'Error loading usage statistics')}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Alert color="yellow" title={t('usage.noData', 'No data available')}>
|
||||
{t('usage.noDataMessage', 'No usage statistics are currently available.')}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = data.endpoints.map((e) => ({ label: e.endpoint, value: e.visits }));
|
||||
|
||||
const displayedVisits = data.endpoints.reduce((sum, e) => sum + e.visits, 0);
|
||||
|
||||
const displayedPercentage = data.totalVisits > 0
|
||||
? ((displayedVisits / data.totalVisits) * 100).toFixed(1)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Controls */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group>
|
||||
<SegmentedControl
|
||||
value={displayMode}
|
||||
onChange={(value) => setDisplayMode(value as 'top10' | 'top20' | 'all')}
|
||||
data={[
|
||||
{
|
||||
value: 'top10',
|
||||
label: t('usage.controls.top10', 'Top 10'),
|
||||
},
|
||||
{
|
||||
value: 'top20',
|
||||
label: t('usage.controls.top20', 'Top 20'),
|
||||
},
|
||||
{
|
||||
value: 'all',
|
||||
label: t('usage.controls.all', 'All'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
>
|
||||
{t('usage.controls.refresh', 'Refresh')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Checkbox
|
||||
label={t('usage.controls.includeHome', "Include Homepage ('/')")}
|
||||
checked={includeHome}
|
||||
onChange={(event) => setIncludeHome(event.currentTarget.checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t('usage.controls.includeLogin', "Include Login Page ('/login')")}
|
||||
checked={includeLogin}
|
||||
onChange={(event) => setIncludeLogin(event.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Statistics Summary */}
|
||||
<Group gap="xl" style={{ flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('usage.stats.totalEndpoints', 'Total Endpoints')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{data.totalEndpoints}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('usage.stats.totalVisits', 'Total Visits')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{data.totalVisits.toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('usage.stats.showing', 'Showing')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{getDisplayModeLabel()}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('usage.stats.selectedVisits', 'Selected Visits')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{displayedVisits.toLocaleString()} ({displayedPercentage}%)
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Chart and Table */}
|
||||
<UsageAnalyticsChart data={chartData} totalVisits={data.totalVisits} />
|
||||
<UsageAnalyticsTable data={data.endpoints} totalVisits={data.totalVisits} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsageSection;
|
||||
@@ -40,6 +40,16 @@ export default function PeopleSection() {
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [inviteMode, setInviteMode] = useState<'email' | 'direct'>('direct');
|
||||
|
||||
// License information
|
||||
const [licenseInfo, setLicenseInfo] = useState<{
|
||||
maxAllowedUsers: number;
|
||||
availableSlots: number;
|
||||
grandfatheredUserCount: number;
|
||||
licenseMaxUsers: number;
|
||||
premiumEnabled: boolean;
|
||||
totalUsers: number;
|
||||
} | null>(null);
|
||||
|
||||
// Form state for direct invite
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
username: '',
|
||||
@@ -89,6 +99,16 @@ export default function PeopleSection() {
|
||||
|
||||
setUsers(enrichedUsers);
|
||||
setTeams(teamsData);
|
||||
|
||||
// Store license information
|
||||
setLicenseInfo({
|
||||
maxAllowedUsers: adminData.maxAllowedUsers,
|
||||
availableSlots: adminData.availableSlots,
|
||||
grandfatheredUserCount: adminData.grandfatheredUserCount,
|
||||
licenseMaxUsers: adminData.licenseMaxUsers,
|
||||
premiumEnabled: adminData.premiumEnabled,
|
||||
totalUsers: adminData.totalUsers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch people data:', error);
|
||||
alert({ alertType: 'error', title: 'Failed to load people data' });
|
||||
@@ -325,6 +345,39 @@ export default function PeopleSection() {
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* License Information - Compact */}
|
||||
{licenseInfo && (
|
||||
<Group gap="md" c="dimmed" style={{ fontSize: '0.875rem' }}>
|
||||
<Text size="sm" span>
|
||||
<Text component="span" fw={600} c="inherit">{licenseInfo.totalUsers}</Text>
|
||||
<Text component="span" c="dimmed"> / </Text>
|
||||
<Text component="span" fw={600} c="inherit">{licenseInfo.maxAllowedUsers}</Text>
|
||||
<Text component="span" c="dimmed" ml={4}>{t('workspace.people.license.users', 'users')}</Text>
|
||||
</Text>
|
||||
|
||||
{licenseInfo.availableSlots === 0 && (
|
||||
<Badge color="red" variant="light" size="sm">
|
||||
{t('workspace.people.license.noSlotsAvailable', 'No slots available')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{licenseInfo.grandfatheredUserCount > 0 && (
|
||||
<Text size="sm" c="dimmed" span>
|
||||
•
|
||||
<Text component="span" ml={4}>
|
||||
{t('workspace.people.license.grandfatheredShort', '{{count}} grandfathered', { count: licenseInfo.grandfatheredUserCount })}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{licenseInfo.premiumEnabled && licenseInfo.licenseMaxUsers > 0 && (
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
+{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Header Actions */}
|
||||
<Group justify="space-between">
|
||||
<TextInput
|
||||
@@ -334,77 +387,149 @@ export default function PeopleSection() {
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
style={{ maxWidth: 300 }}
|
||||
/>
|
||||
<Button leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />} onClick={() => setInviteModalOpened(true)}>
|
||||
{t('workspace.people.addMembers')}
|
||||
</Button>
|
||||
<Tooltip
|
||||
label={t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
|
||||
disabled={!licenseInfo || licenseInfo.availableSlots > 0}
|
||||
position="bottom"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
|
||||
onClick={() => setInviteModalOpened(true)}
|
||||
disabled={licenseInfo && licenseInfo.availableSlots === 0}
|
||||
>
|
||||
{t('workspace.people.addMembers')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{/* Members Table */}
|
||||
<Paper withBorder p="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
style={{
|
||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('workspace.people.user')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w={100}>
|
||||
{t('workspace.people.role')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('workspace.people.team')}
|
||||
</Table.Th>
|
||||
<Table.Th w={50}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('workspace.people.user')}</Table.Th>
|
||||
<Table.Th>{t('workspace.people.role')}</Table.Th>
|
||||
<Table.Th>{t('workspace.people.team')}</Table.Th>
|
||||
<Table.Th>{t('workspace.people.status')}</Table.Th>
|
||||
<Table.Th style={{ width: 50 }}></Table.Th>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('workspace.people.noMembersFound')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('workspace.people.noMembersFound')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{user.isActive && (
|
||||
<div
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<Table.Tr
|
||||
key={user.id}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--mantine-color-gray-3)',
|
||||
}}
|
||||
>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: user.enabled
|
||||
? 'var(--mantine-color-blue-1)'
|
||||
: 'var(--mantine-color-gray-2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem',
|
||||
color: user.enabled
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-gray-6)',
|
||||
flexShrink: 0,
|
||||
border: user.isActive ? '2px solid var(--mantine-color-green-6)' : 'none',
|
||||
opacity: user.enabled ? 1 : 0.5,
|
||||
}}
|
||||
title={
|
||||
!user.enabled
|
||||
? t('workspace.people.disabled', 'Disabled')
|
||||
: user.isActive
|
||||
? t('workspace.people.activeSession', 'Active session')
|
||||
: t('workspace.people.active', 'Active')
|
||||
}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<Tooltip label={user.username} disabled={user.username.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={200}
|
||||
style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--mantine-color-green-6)',
|
||||
flexShrink: 0,
|
||||
lineHeight: 1.3,
|
||||
opacity: user.enabled ? 1 : 0.6,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={t('workspace.people.activeSession', 'Active session')}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
>
|
||||
{user.username}
|
||||
</Text>
|
||||
{user.email && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
{user.email && (
|
||||
<Text size="xs" c="dimmed" truncate style={{ lineHeight: 1.3 }}>
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'gray'}
|
||||
variant="light"
|
||||
>
|
||||
{(user.rolesAsString || '').includes('ROLE_ADMIN') ? t('workspace.people.admin') : t('workspace.people.member')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{user.team?.name || '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={user.enabled ? 'green' : 'red'} variant="light">
|
||||
{user.enabled ? t('workspace.people.active') : t('workspace.people.disabled')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td w={100}>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'gray'}
|
||||
>
|
||||
{(user.rolesAsString || '').includes('ROLE_ADMIN')
|
||||
? t('workspace.people.admin', 'Admin')
|
||||
: t('workspace.people.member', 'Member')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{user.team?.name ? (
|
||||
<Tooltip label={user.team.name} disabled={user.team.name.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
maw={150}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{user.team.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">—</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{/* Info icon with tooltip */}
|
||||
@@ -456,9 +581,8 @@ export default function PeopleSection() {
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{/* Add Member Modal */}
|
||||
<Modal
|
||||
@@ -476,8 +600,8 @@ export default function PeopleSection() {
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
@@ -495,6 +619,32 @@ export default function PeopleSection() {
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* License Warning/Info */}
|
||||
{licenseInfo && (
|
||||
<Paper withBorder p="sm" bg={licenseInfo.availableSlots === 0 ? 'var(--mantine-color-red-light)' : 'var(--mantine-color-blue-light)'}>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
|
||||
<Text size="sm" fw={500}>
|
||||
{licenseInfo.availableSlots > 0
|
||||
? t('workspace.people.license.slotsAvailable', {
|
||||
count: licenseInfo.availableSlots,
|
||||
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
|
||||
})
|
||||
: t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('workspace.people.license.currentUsage', {
|
||||
current: licenseInfo.totalUsers,
|
||||
max: licenseInfo.maxAllowedUsers,
|
||||
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Mode Toggle */}
|
||||
<Tooltip
|
||||
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
|
||||
@@ -629,8 +779,8 @@ export default function PeopleSection() {
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -42,6 +42,11 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
// License information
|
||||
const [licenseInfo, setLicenseInfo] = useState<{
|
||||
availableSlots: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTeamDetails();
|
||||
fetchAllTeams();
|
||||
@@ -50,12 +55,20 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
const fetchTeamDetails = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await teamService.getTeamDetails(teamId);
|
||||
const [data, adminData] = await Promise.all([
|
||||
teamService.getTeamDetails(teamId),
|
||||
userManagementService.getUsers(),
|
||||
]);
|
||||
console.log('[TeamDetailsSection] Raw data:', data);
|
||||
setTeam(data.team);
|
||||
setTeamUsers(Array.isArray(data.teamUsers) ? data.teamUsers : []);
|
||||
setAvailableUsers(Array.isArray(data.availableUsers) ? data.availableUsers : []);
|
||||
setUserLastRequest(data.userLastRequest || {});
|
||||
|
||||
// Store license information
|
||||
setLicenseInfo({
|
||||
availableSlots: adminData.availableSlots,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch team details:', error);
|
||||
alert({ alertType: 'error', title: 'Failed to load team details' });
|
||||
@@ -227,65 +240,131 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
|
||||
{/* Add Member Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
|
||||
onClick={() => setAddMemberModalOpened(true)}
|
||||
disabled={team.name === 'Internal'}
|
||||
<Tooltip
|
||||
label={t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
|
||||
disabled={!licenseInfo || licenseInfo.availableSlots > 0}
|
||||
position="bottom"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
{t('workspace.teams.addMember')}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
|
||||
onClick={() => setAddMemberModalOpened(true)}
|
||||
disabled={team.name === 'Internal' || (licenseInfo && licenseInfo.availableSlots === 0)}
|
||||
>
|
||||
{t('workspace.teams.addMember')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{/* Members Table */}
|
||||
<Paper withBorder p="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('workspace.people.user')}</Table.Th>
|
||||
<Table.Th>{t('workspace.people.role')}</Table.Th>
|
||||
<Table.Th>{t('workspace.people.status')}</Table.Th>
|
||||
<Table.Th style={{ width: 50 }}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
style={{
|
||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('workspace.people.user')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w={100}>
|
||||
{t('workspace.people.role')}
|
||||
</Table.Th>
|
||||
<Table.Th w={50}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{teamUsers.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Table.Td colSpan={3}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('workspace.teams.noMembers', 'No members in this team')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
teamUsers.map((user) => (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{user.username}
|
||||
</Text>
|
||||
{user.email && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'gray'}
|
||||
variant="light"
|
||||
>
|
||||
{(user.rolesAsString || '').includes('ROLE_ADMIN')
|
||||
? t('workspace.people.admin')
|
||||
: t('workspace.people.member')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={user.enabled ? 'green' : 'red'} variant="light">
|
||||
{user.enabled ? t('workspace.people.active') : t('workspace.people.disabled')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
teamUsers.map((user) => {
|
||||
const isActive = userLastRequest[user.username] &&
|
||||
(Date.now() - userLastRequest[user.username]) < 5 * 60 * 1000; // Active within last 5 minutes
|
||||
|
||||
return (
|
||||
<Table.Tr
|
||||
key={user.id}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--mantine-color-gray-3)',
|
||||
}}
|
||||
>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: user.enabled
|
||||
? 'var(--mantine-color-blue-1)'
|
||||
: 'var(--mantine-color-gray-2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem',
|
||||
color: user.enabled
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-gray-6)',
|
||||
flexShrink: 0,
|
||||
border: isActive ? '2px solid var(--mantine-color-green-6)' : 'none',
|
||||
opacity: user.enabled ? 1 : 0.5,
|
||||
}}
|
||||
title={
|
||||
!user.enabled
|
||||
? t('workspace.people.disabled', 'Disabled')
|
||||
: isActive
|
||||
? t('workspace.people.activeSession', 'Active session')
|
||||
: t('workspace.people.active', 'Active')
|
||||
}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<Tooltip label={user.username} disabled={user.username.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={200}
|
||||
style={{
|
||||
lineHeight: 1.3,
|
||||
opacity: user.enabled ? 1 : 0.6,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{user.email && (
|
||||
<Text size="xs" c="dimmed" truncate style={{ lineHeight: 1.3 }}>
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td w={100}>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'gray'}
|
||||
variant="light"
|
||||
>
|
||||
{(user.rolesAsString || '').includes('ROLE_ADMIN')
|
||||
? t('workspace.people.admin')
|
||||
: t('workspace.people.member')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{/* Info icon with tooltip */}
|
||||
@@ -352,11 +431,11 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Table>
|
||||
|
||||
{/* Add Member Modal */}
|
||||
<Modal
|
||||
@@ -374,8 +453,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
@@ -433,8 +512,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Paper,
|
||||
Select,
|
||||
CloseButton,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert } from '@app/components/toast';
|
||||
@@ -224,15 +225,24 @@ export default function TeamsSection() {
|
||||
</Group>
|
||||
|
||||
{/* Teams Table */}
|
||||
<Paper withBorder p="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('workspace.teams.teamName')}</Table.Th>
|
||||
<Table.Th>{t('workspace.teams.totalMembers')}</Table.Th>
|
||||
<Table.Th style={{ width: 50 }}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
style={{
|
||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
|
||||
{t('workspace.teams.teamName')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
|
||||
{t('workspace.teams.totalMembers')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ width: 50 }}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{teams.length === 0 ? (
|
||||
<Table.Tr>
|
||||
@@ -246,23 +256,44 @@ export default function TeamsSection() {
|
||||
teams.map((team) => (
|
||||
<Table.Tr
|
||||
key={team.id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid var(--mantine-color-gray-3)',
|
||||
}}
|
||||
onClick={() => setViewingTeamId(team.id)}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-0)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{team.name}
|
||||
</Text>
|
||||
<Tooltip label={team.name} disabled={team.name.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
c="dark"
|
||||
maw={200}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{team.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{team.name === 'Internal' && (
|
||||
<Badge size="xs" color="gray">
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
{t('workspace.teams.system')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{team.userCount || 0}</Text>
|
||||
<Text size="sm" c="dimmed">{team.userCount || 0}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td onClick={(e) => e.stopPropagation()}>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
@@ -297,8 +328,7 @@ export default function TeamsSection() {
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Table>
|
||||
|
||||
{/* Create Team Modal */}
|
||||
<Modal
|
||||
@@ -316,8 +346,8 @@ export default function TeamsSection() {
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
@@ -361,8 +391,8 @@ export default function TeamsSection() {
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
@@ -409,8 +439,8 @@ export default function TeamsSection() {
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Text, Group, Stack, SegmentedControl, Loader, Alert } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService, { AuditChartsData } from '@app/services/auditService';
|
||||
|
||||
interface SimpleBarChartProps {
|
||||
data: { label: string; value: number }[];
|
||||
title: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, title, color = 'blue' }) => {
|
||||
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{data.map((item, index) => (
|
||||
<div key={index}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" maw={200} style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="xs" fw={600}>
|
||||
{item.value}
|
||||
</Text>
|
||||
</Group>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 8,
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${(item.value / maxValue) * 100}%`,
|
||||
height: '100%',
|
||||
backgroundColor: `var(--mantine-color-${color}-6)`,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface AuditChartsSectionProps {}
|
||||
|
||||
const AuditChartsSection: React.FC<AuditChartsSectionProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week');
|
||||
const [chartsData, setChartsData] = useState<AuditChartsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChartsData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await auditService.getChartsData(timePeriod);
|
||||
setChartsData(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load charts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchChartsData();
|
||||
}, [timePeriod]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Loader size="lg" my="xl" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title={t('audit.charts.error', 'Error loading charts')}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chartsData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventsByTypeData = chartsData.eventsByType.labels.map((label, index) => ({
|
||||
label,
|
||||
value: chartsData.eventsByType.values[index],
|
||||
}));
|
||||
|
||||
const eventsByUserData = chartsData.eventsByUser.labels.map((label, index) => ({
|
||||
label,
|
||||
value: chartsData.eventsByUser.values[index],
|
||||
}));
|
||||
|
||||
const eventsOverTimeData = chartsData.eventsOverTime.labels.map((label, index) => ({
|
||||
label,
|
||||
value: chartsData.eventsOverTime.values[index],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('audit.charts.title', 'Audit Dashboard')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={timePeriod}
|
||||
onChange={(value) => setTimePeriod(value as 'day' | 'week' | 'month')}
|
||||
data={[
|
||||
{ label: t('audit.charts.day', 'Day'), value: 'day' },
|
||||
{ label: t('audit.charts.week', 'Week'), value: 'week' },
|
||||
{ label: t('audit.charts.month', 'Month'), value: 'month' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<SimpleBarChart
|
||||
data={eventsByTypeData}
|
||||
title={t('audit.charts.byType', 'Events by Type')}
|
||||
color="blue"
|
||||
/>
|
||||
<SimpleBarChart
|
||||
data={eventsByUserData}
|
||||
title={t('audit.charts.byUser', 'Events by User')}
|
||||
color="green"
|
||||
/>
|
||||
<SimpleBarChart
|
||||
data={eventsOverTimeData}
|
||||
title={t('audit.charts.overTime', 'Events Over Time')}
|
||||
color="purple"
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditChartsSection;
|
||||
@@ -0,0 +1,300 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Stack,
|
||||
Select,
|
||||
TextInput,
|
||||
Button,
|
||||
Pagination,
|
||||
Modal,
|
||||
Code,
|
||||
Loader,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService, { AuditEvent, AuditFilters } from '@app/services/auditService';
|
||||
|
||||
interface AuditEventsTableProps {}
|
||||
|
||||
const AuditEventsTable: React.FC<AuditEventsTableProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedEvent, setSelectedEvent] = useState<AuditEvent | null>(null);
|
||||
const [eventTypes, setEventTypes] = useState<string[]>([]);
|
||||
const [users, setUsers] = useState<string[]>([]);
|
||||
|
||||
// Filters
|
||||
const [filters, setFilters] = useState<AuditFilters>({
|
||||
eventType: undefined,
|
||||
username: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
page: 0,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
const [types, usersList] = await Promise.all([
|
||||
auditService.getEventTypes(),
|
||||
auditService.getUsers(),
|
||||
]);
|
||||
setEventTypes(types);
|
||||
setUsers(usersList);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch metadata:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMetadata();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await auditService.getEvents({
|
||||
...filters,
|
||||
page: currentPage - 1,
|
||||
});
|
||||
setEvents(response.events);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load events');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEvents();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
const handleFilterChange = (key: keyof AuditFilters, value: any) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilters({
|
||||
eventType: undefined,
|
||||
username: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
page: 0,
|
||||
pageSize: 20,
|
||||
});
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('audit.events.title', 'Audit Events')}
|
||||
</Text>
|
||||
|
||||
{/* Filters */}
|
||||
<Group>
|
||||
<Select
|
||||
placeholder={t('audit.events.filterByType', 'Filter by type')}
|
||||
data={eventTypes.map((type) => ({ value: type, label: type }))}
|
||||
value={filters.eventType}
|
||||
onChange={(value) => handleFilterChange('eventType', value || undefined)}
|
||||
clearable
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder={t('audit.events.filterByUser', 'Filter by user')}
|
||||
data={users.map((user) => ({ value: user, label: user }))}
|
||||
value={filters.username}
|
||||
onChange={(value) => handleFilterChange('username', value || undefined)}
|
||||
clearable
|
||||
searchable
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder={t('audit.events.startDate', 'Start date')}
|
||||
value={filters.startDate ? new Date(filters.startDate) : null}
|
||||
onChange={(value) =>
|
||||
handleFilterChange('startDate', value ? value.toISOString() : undefined)
|
||||
}
|
||||
clearable
|
||||
style={{ flex: 1, minWidth: 150 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder={t('audit.events.endDate', 'End date')}
|
||||
value={filters.endDate ? new Date(filters.endDate) : null}
|
||||
onChange={(value) =>
|
||||
handleFilterChange('endDate', value ? value.toISOString() : undefined)
|
||||
}
|
||||
clearable
|
||||
style={{ flex: 1, minWidth: 150 }}
|
||||
/>
|
||||
<Button variant="outline" onClick={handleClearFilters}>
|
||||
{t('audit.events.clearFilters', 'Clear')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Loader size="lg" my="xl" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert color="red" title={t('audit.events.error', 'Error loading events')}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
borderBottom: '2px solid var(--mantine-color-gray-3)',
|
||||
}}
|
||||
>
|
||||
<th style={{ textAlign: 'left' }}>
|
||||
{t('audit.events.timestamp', 'Timestamp')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'left' }}>
|
||||
{t('audit.events.type', 'Type')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'left' }}>
|
||||
{t('audit.events.user', 'User')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'left' }}>
|
||||
{t('audit.events.ipAddress', 'IP Address')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'center' }}>
|
||||
{t('audit.events.actions', 'Actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<Text c="dimmed">{t('audit.events.noEvents', 'No events found')}</Text>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<tr
|
||||
key={event.id}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
'var(--mantine-color-gray-0)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<Text size="sm">{formatDate(event.timestamp)}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="sm">{event.eventType}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="sm">{event.username}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="sm">{event.ipAddress}</Text>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
>
|
||||
{t('audit.events.viewDetails', 'View Details')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Group justify="center" mt="md">
|
||||
<Pagination
|
||||
value={currentPage}
|
||||
onChange={setCurrentPage}
|
||||
total={totalPages}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Event Details Modal */}
|
||||
<Modal
|
||||
opened={selectedEvent !== null}
|
||||
onClose={() => setSelectedEvent(null)}
|
||||
title={t('audit.events.eventDetails', 'Event Details')}
|
||||
size="lg"
|
||||
>
|
||||
{selectedEvent && (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.timestamp', 'Timestamp')}
|
||||
</Text>
|
||||
<Text size="sm">{formatDate(selectedEvent.timestamp)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.type', 'Type')}
|
||||
</Text>
|
||||
<Text size="sm">{selectedEvent.eventType}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.user', 'User')}
|
||||
</Text>
|
||||
<Text size="sm">{selectedEvent.username}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.ipAddress', 'IP Address')}
|
||||
</Text>
|
||||
<Text size="sm">{selectedEvent.ipAddress}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t('audit.events.details', 'Details')}
|
||||
</Text>
|
||||
<Code block mah={300} style={{ overflow: 'auto' }}>
|
||||
{JSON.stringify(selectedEvent.details, null, 2)}
|
||||
</Code>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditEventsTable;
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Stack,
|
||||
Select,
|
||||
Button,
|
||||
SegmentedControl,
|
||||
} from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import auditService, { AuditFilters } from '@app/services/auditService';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface AuditExportSectionProps {}
|
||||
|
||||
const AuditExportSection: React.FC<AuditExportSectionProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [eventTypes, setEventTypes] = useState<string[]>([]);
|
||||
const [users, setUsers] = useState<string[]>([]);
|
||||
|
||||
// Filters for export
|
||||
const [filters, setFilters] = useState<AuditFilters>({
|
||||
eventType: undefined,
|
||||
username: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
const [types, usersList] = await Promise.all([
|
||||
auditService.getEventTypes(),
|
||||
auditService.getUsers(),
|
||||
]);
|
||||
setEventTypes(types);
|
||||
setUsers(usersList);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch metadata:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMetadata();
|
||||
}, []);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
|
||||
const blob = await auditService.exportData(exportFormat, filters);
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `audit-export-${new Date().toISOString()}.${exportFormat}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
alert(t('audit.export.error', 'Failed to export data'));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (key: keyof AuditFilters, value: any) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilters({
|
||||
eventType: undefined,
|
||||
username: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('audit.export.title', 'Export Audit Data')}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'audit.export.description',
|
||||
'Export audit events to CSV or JSON format. Use filters to limit the exported data.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Format Selection */}
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t('audit.export.format', 'Export Format')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={exportFormat}
|
||||
onChange={(value) => setExportFormat(value as 'csv' | 'json')}
|
||||
data={[
|
||||
{ label: 'CSV', value: 'csv' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t('audit.export.filters', 'Filters (Optional)')}
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<Group>
|
||||
<Select
|
||||
placeholder={t('audit.export.filterByType', 'Filter by type')}
|
||||
data={eventTypes.map((type) => ({ value: type, label: type }))}
|
||||
value={filters.eventType}
|
||||
onChange={(value) => handleFilterChange('eventType', value || undefined)}
|
||||
clearable
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder={t('audit.export.filterByUser', 'Filter by user')}
|
||||
data={users.map((user) => ({ value: user, label: user }))}
|
||||
value={filters.username}
|
||||
onChange={(value) => handleFilterChange('username', value || undefined)}
|
||||
clearable
|
||||
searchable
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
</Group>
|
||||
<Group>
|
||||
<DateInput
|
||||
placeholder={t('audit.export.startDate', 'Start date')}
|
||||
value={filters.startDate ? new Date(filters.startDate) : null}
|
||||
onChange={(value) =>
|
||||
handleFilterChange('startDate', value ? value.toISOString() : undefined)
|
||||
}
|
||||
clearable
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder={t('audit.export.endDate', 'End date')}
|
||||
value={filters.endDate ? new Date(filters.endDate) : null}
|
||||
onChange={(value) =>
|
||||
handleFilterChange('endDate', value ? value.toISOString() : undefined)
|
||||
}
|
||||
clearable
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
<Button variant="outline" onClick={handleClearFilters}>
|
||||
{t('audit.export.clearFilters', 'Clear')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Export Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="download" width="1rem" height="1rem" />}
|
||||
onClick={handleExport}
|
||||
loading={exporting}
|
||||
disabled={exporting}
|
||||
>
|
||||
{t('audit.export.exportButton', 'Export Data')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditExportSection;
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { Card, Group, Stack, Badge, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
|
||||
|
||||
interface AuditSystemStatusProps {
|
||||
status: AuditStatus;
|
||||
}
|
||||
|
||||
const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('audit.systemStatus.title', 'System Status')}
|
||||
</Text>
|
||||
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.systemStatus.status', 'Audit Logging')}
|
||||
</Text>
|
||||
<Badge color={status.enabled ? 'green' : 'red'} variant="light" size="lg" mt="xs">
|
||||
{status.enabled
|
||||
? t('audit.systemStatus.enabled', 'Enabled')
|
||||
: t('audit.systemStatus.disabled', 'Disabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.systemStatus.level', 'Audit Level')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600} mt="xs">
|
||||
{status.level}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.systemStatus.retention', 'Retention Period')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600} mt="xs">
|
||||
{status.retentionDays} {t('audit.systemStatus.days', 'days')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('audit.systemStatus.totalEvents', 'Total Events')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600} mt="xs">
|
||||
{status.totalEvents.toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditSystemStatus;
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { Card, Text, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SimpleBarChartProps {
|
||||
data: { label: string; value: number }[];
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, maxValue }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{data.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t('usage.noData', 'No data available')}
|
||||
</Text>
|
||||
) : (
|
||||
data.map((item, index) => (
|
||||
<div key={index}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }} mb={4}>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{
|
||||
maxWidth: '60%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Text size="xs" fw={600}>
|
||||
{item.value}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
({((item.value / maxValue) * 100).toFixed(1)}%)
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 8,
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${(item.value / maxValue) * 100}%`,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-blue-6)',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface UsageAnalyticsChartProps {
|
||||
data: { label: string; value: number }[];
|
||||
totalVisits: number;
|
||||
}
|
||||
|
||||
const UsageAnalyticsChart: React.FC<UsageAnalyticsChartProps> = ({ data, totalVisits }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('usage.chart.title', 'Endpoint Usage Chart')}
|
||||
</Text>
|
||||
<SimpleBarChart data={data} maxValue={Math.max(...data.map((d) => d.value), 1)} />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageAnalyticsChart;
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import { Card, Text, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EndpointStatistic } from '@app/services/usageAnalyticsService';
|
||||
|
||||
interface UsageAnalyticsTableProps {
|
||||
data: EndpointStatistic[];
|
||||
totalVisits: number;
|
||||
}
|
||||
|
||||
const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data, totalVisits }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('usage.table.title', 'Detailed Statistics')}
|
||||
</Text>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
borderBottom: '2px solid var(--mantine-color-gray-3)',
|
||||
}}
|
||||
>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
fontSize: '0.875rem',
|
||||
width: '5%',
|
||||
}}
|
||||
>
|
||||
#
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
fontSize: '0.875rem',
|
||||
width: '55%',
|
||||
}}
|
||||
>
|
||||
{t('usage.table.endpoint', 'Endpoint')}
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
fontSize: '0.875rem',
|
||||
width: '20%',
|
||||
}}
|
||||
>
|
||||
{t('usage.table.visits', 'Visits')}
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
fontSize: '0.875rem',
|
||||
width: '20%',
|
||||
}}
|
||||
>
|
||||
{t('usage.table.percentage', 'Percentage')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<Text c="dimmed">{t('usage.table.noData', 'No data available')}</Text>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((stat, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-0)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{index + 1}
|
||||
</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{stat.endpoint}
|
||||
</Text>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{stat.visits.toLocaleString()}
|
||||
</Text>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{stat.percentage.toFixed(2)}%
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageAnalyticsTable;
|
||||
@@ -23,6 +23,9 @@ export type NavKey =
|
||||
| 'adminLegal'
|
||||
| 'adminPremium'
|
||||
| 'adminFeatures'
|
||||
| 'adminPlan'
|
||||
| 'adminAudit'
|
||||
| 'adminUsage'
|
||||
| 'adminEndpoints';
|
||||
|
||||
|
||||
|
||||
115
frontend/src/core/services/auditService.ts
Normal file
115
frontend/src/core/services/auditService.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
export interface AuditSystemStatus {
|
||||
enabled: boolean;
|
||||
level: string;
|
||||
retentionDays: number;
|
||||
totalEvents: number;
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
eventType: string;
|
||||
username: string;
|
||||
ipAddress: string;
|
||||
details: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AuditEventsResponse {
|
||||
events: AuditEvent[];
|
||||
totalEvents: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
labels: string[];
|
||||
values: number[];
|
||||
}
|
||||
|
||||
export interface AuditChartsData {
|
||||
eventsByType: ChartData;
|
||||
eventsByUser: ChartData;
|
||||
eventsOverTime: ChartData;
|
||||
}
|
||||
|
||||
export interface AuditFilters {
|
||||
eventType?: string;
|
||||
username?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const auditService = {
|
||||
/**
|
||||
* Get audit system status
|
||||
*/
|
||||
async getSystemStatus(): Promise<AuditSystemStatus> {
|
||||
const response = await apiClient.get<any>('/api/v1/proprietary/ui-data/audit-dashboard');
|
||||
const data = response.data;
|
||||
|
||||
// Map V1 response to expected format
|
||||
return {
|
||||
enabled: data.auditEnabled,
|
||||
level: data.auditLevel,
|
||||
retentionDays: data.retentionDays,
|
||||
totalEvents: 0, // Will be fetched separately
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get audit events with pagination and filters
|
||||
*/
|
||||
async getEvents(filters: AuditFilters = {}): Promise<AuditEventsResponse> {
|
||||
const response = await apiClient.get<AuditEventsResponse>('/api/v1/proprietary/ui-data/audit-events', {
|
||||
params: filters,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get chart data for dashboard
|
||||
*/
|
||||
async getChartsData(timePeriod: 'day' | 'week' | 'month' = 'week'): Promise<AuditChartsData> {
|
||||
const response = await apiClient.get<AuditChartsData>('/api/v1/proprietary/ui-data/audit-charts', {
|
||||
params: { period: timePeriod },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Export audit data
|
||||
*/
|
||||
async exportData(
|
||||
format: 'csv' | 'json',
|
||||
filters: AuditFilters = {}
|
||||
): Promise<Blob> {
|
||||
const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-export', {
|
||||
params: { format, ...filters },
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get available event types for filtering
|
||||
*/
|
||||
async getEventTypes(): Promise<string[]> {
|
||||
const response = await apiClient.get<string[]>('/api/v1/proprietary/ui-data/audit-event-types');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get list of users for filtering
|
||||
*/
|
||||
async getUsers(): Promise<string[]> {
|
||||
const response = await apiClient.get<string[]>('/api/v1/proprietary/ui-data/audit-users');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default auditService;
|
||||
62
frontend/src/core/services/usageAnalyticsService.ts
Normal file
62
frontend/src/core/services/usageAnalyticsService.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
export interface EndpointStatistic {
|
||||
endpoint: string;
|
||||
visits: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface EndpointStatisticsResponse {
|
||||
endpoints: EndpointStatistic[];
|
||||
totalEndpoints: number;
|
||||
totalVisits: number;
|
||||
}
|
||||
|
||||
export interface UsageChartData {
|
||||
labels: string[];
|
||||
values: number[];
|
||||
}
|
||||
|
||||
const usageAnalyticsService = {
|
||||
/**
|
||||
* Get endpoint statistics
|
||||
*/
|
||||
async getEndpointStatistics(
|
||||
limit?: number,
|
||||
includeHome: boolean = true,
|
||||
includeLogin: boolean = true
|
||||
): Promise<EndpointStatisticsResponse> {
|
||||
const params: Record<string, any> = {
|
||||
includeHome,
|
||||
includeLogin,
|
||||
};
|
||||
|
||||
if (limit !== undefined) {
|
||||
params.limit = limit;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<EndpointStatisticsResponse>(
|
||||
'/api/v1/proprietary/ui-data/usage-endpoint-statistics',
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get chart data for endpoint usage
|
||||
*/
|
||||
async getChartData(
|
||||
limit?: number,
|
||||
includeHome: boolean = true,
|
||||
includeLogin: boolean = true
|
||||
): Promise<UsageChartData> {
|
||||
const stats = await this.getEndpointStatistics(limit, includeHome, includeLogin);
|
||||
|
||||
return {
|
||||
labels: stats.endpoints.map((e) => e.endpoint),
|
||||
values: stats.endpoints.map((e) => e.visits),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default usageAnalyticsService;
|
||||
@@ -31,6 +31,12 @@ export interface AdminSettingsData {
|
||||
roleDetails?: Record<string, string>;
|
||||
teams?: any[];
|
||||
maxPaidUsers?: number;
|
||||
// License information
|
||||
maxAllowedUsers: number;
|
||||
availableSlots: number;
|
||||
grandfatheredUserCount: number;
|
||||
licenseMaxUsers: number;
|
||||
premiumEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
|
||||
53
frontend/src/core/utils/settingsNavigation.ts
Normal file
53
frontend/src/core/utils/settingsNavigation.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NavKey } from '@app/components/shared/config/types';
|
||||
|
||||
/**
|
||||
* Navigate to a specific settings section
|
||||
*
|
||||
* @param section - The settings section key to navigate to
|
||||
*
|
||||
* @example
|
||||
* // Navigate to People section
|
||||
* navigateToSettings('people');
|
||||
*
|
||||
* // Navigate to Admin Premium section
|
||||
* navigateToSettings('adminPremium');
|
||||
*/
|
||||
export function navigateToSettings(section: NavKey) {
|
||||
const basePath = window.location.pathname.split('/settings')[0] || '';
|
||||
const newPath = `${basePath}/settings/${section}`;
|
||||
window.history.pushState({}, '', newPath);
|
||||
|
||||
// Trigger a popstate event to notify components
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL path for a settings section
|
||||
* Useful for creating links
|
||||
*
|
||||
* @param section - The settings section key
|
||||
* @returns The URL path for the settings section
|
||||
*
|
||||
* @example
|
||||
* <a href={getSettingsUrl('people')}>Go to People Settings</a>
|
||||
* // Returns: "/settings/people"
|
||||
*/
|
||||
export function getSettingsUrl(section: NavKey): string {
|
||||
return `/settings/${section}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently viewing a settings section
|
||||
*
|
||||
* @param section - Optional section key to check for specific section
|
||||
* @returns True if in settings (and matching specific section if provided)
|
||||
*/
|
||||
export function isInSettings(section?: NavKey): boolean {
|
||||
const pathname = window.location.pathname;
|
||||
|
||||
if (!section) {
|
||||
return pathname.startsWith('/settings');
|
||||
}
|
||||
|
||||
return pathname === `/settings/${section}`;
|
||||
}
|
||||
Reference in New Issue
Block a user