Add audit system, invite links, and usage analytics (#4749)

# Description of Changes

New Features
Audit System: Complete audit logging with dashboard, event tracking, and
export capabilities

Invite Links: Secure invite system with email notifications and
expiration

Usage Analytics: Endpoint usage statistics and visualization

License Management: User counting with grandfathering and license
enforcement
## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
Anthony Stirling
2025-11-06 17:29:34 +00:00
committed by GitHub
co-authored by James Brunton
parent f5c67a3239
commit ac3e10eb99
64 changed files with 5269 additions and 461 deletions
@@ -1,3 +1,4 @@
import React from 'react';
import { Box } from '@mantine/core';
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
@@ -7,7 +8,7 @@ import { useNavigationState, useNavigationActions } from '@app/contexts/Navigati
import { isBaseWorkbench } from '@app/types/workbench';
import { useViewer } from '@app/contexts/ViewerContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import '@app/components/layout/Workbench.css';
import styles from '@app/components/layout/Workbench.module.css';
import TopControls from '@app/components/shared/TopControls';
import FileEditor from '@app/components/fileEditor/FileEditor';
@@ -181,7 +182,7 @@ export default function Workbench() {
{/* Main content area */}
<Box
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
style={{
transition: 'opacity 0.15s ease-in-out',
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
@@ -191,7 +192,7 @@ export default function Workbench() {
</Box>
<Footer
analyticsEnabled={config?.enableAnalytics === true}
analyticsEnabled={config?.enableAnalytics ?? undefined}
termsAndConditions={config?.termsAndConditions}
privacyPolicy={config?.privacyPolicy}
cookiePolicy={config?.cookiePolicy}
@@ -1,13 +1,13 @@
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 { NavKey, VALID_NAV_KEYS } 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 +15,44 @@ interface AppConfigModalProps {
}
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const [active, setActive] = useState<NavKey>('overview');
const navigate = useNavigate();
const location = useLocation();
const [active, setActive] = useState<NavKey>('general');
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 section = match[1] as NavKey;
return VALID_NAV_KEYS.includes(section as NavKey) ? 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 general
navigate('/settings/general', { 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)',
@@ -40,23 +64,19 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
headerBorder: 'var(--modal-header-border)',
}), []);
// Placeholder logout handler (not needed in open-source but keeps SaaS compatibility)
const handleLogout = () => {
// In SaaS this would sign out, in open-source it does nothing
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;
console.log('[AppConfigModal] Config:', { isAdmin, runningEE, fullConfig: config });
// Left navigation structure and icons
const configNavSections = useMemo(() =>
createConfigNavSections(
Overview,
handleLogout,
isAdmin
isAdmin,
runningEE
),
[isAdmin]
[isAdmin, runningEE]
);
const activeLabel = useMemo(() => {
@@ -75,10 +95,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 +135,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 +163,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 +196,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);
}
}
@@ -2,8 +2,6 @@ import React from 'react';
import { NavKey } from '@app/components/shared/config/types';
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
import PeopleSection from '@app/components/shared/config/configSections/PeopleSection';
import TeamsSection from '@app/components/shared/config/configSections/TeamsSection';
import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection';
import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection';
import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection';
@@ -14,12 +12,16 @@ 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 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 {
@@ -38,39 +40,10 @@ 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[] = [
{
title: 'Account',
items: [
{
key: 'overview',
label: 'Overview',
icon: 'person-rounded',
component: <Overview onLogoutClick={onLogoutClick} />
},
],
},
{
title: 'Workspace',
items: [
{
key: 'people',
label: 'People',
icon: 'group-rounded',
component: <PeopleSection />
},
{
key: 'teams',
label: 'Teams',
icon: 'groups-rounded',
component: <TeamsSection />
},
],
},
{
title: 'Preferences',
items: [
@@ -90,53 +63,18 @@ 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',
label: 'General',
label: 'System Settings',
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 +87,12 @@ export const createConfigNavSections = (
icon: 'api-rounded',
component: <AdminEndpointsSection />
},
{
key: 'adminDatabase',
label: 'Database',
icon: 'storage-rounded',
component: <AdminDatabaseSection />
},
{
key: 'adminAdvanced',
label: 'Advanced',
@@ -157,6 +101,73 @@ 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: '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 '@app/components/shared/config/configSections/audit/AuditSystemStatus';
import AuditChartsSection from '@app/components/shared/config/configSections/audit/AuditChartsSection';
import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable';
import AuditExportSection from '@app/components/shared/config/configSections/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;
@@ -165,9 +165,9 @@ export default function AdminGeneralSection() {
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.general.title', 'General')}</Text>
<Text fw={600} size="lg">{t('admin.settings.general.title', 'System Settings')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.general.description', 'Configure general application settings including branding and default behaviour.')}
{t('admin.settings.general.description', 'Configure system-wide application settings including branding and default behaviour.')}
</Text>
</div>
@@ -6,17 +6,27 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
interface MailSettingsData {
enabled?: boolean;
enableInvites?: boolean;
inviteLinkExpiryHours?: number;
host?: string;
port?: number;
username?: string;
password?: string;
from?: string;
frontendUrl?: string;
}
interface ApiResponseWithPending<T> {
_pending?: Partial<T>;
}
type MailApiResponse = MailSettingsData & ApiResponseWithPending<MailSettingsData>;
type SystemApiResponse = { frontendUrl?: string } & ApiResponseWithPending<{ frontendUrl?: string }>;
export default function AdminMailSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
@@ -31,6 +41,47 @@ export default function AdminMailSection() {
isFieldPending,
} = useAdminSettings<MailSettingsData>({
sectionName: 'mail',
fetchTransformer: async () => {
const [mailResponse, systemResponse] = await Promise.all([
apiClient.get<MailApiResponse>('/api/v1/admin/settings/section/mail'),
apiClient.get<SystemApiResponse>('/api/v1/admin/settings/section/system')
]);
const mail = mailResponse.data || {};
const system = systemResponse.data || {};
const result: MailSettingsData & ApiResponseWithPending<MailSettingsData> = {
...mail,
frontendUrl: system.frontendUrl || ''
};
// Merge pending blocks from both endpoints
const pendingBlock: Partial<MailSettingsData> = {};
if (mail._pending) {
Object.assign(pendingBlock, mail._pending);
}
if (system._pending?.frontendUrl !== undefined) {
pendingBlock.frontendUrl = system._pending.frontendUrl;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
}
return result;
},
saveTransformer: (settings) => {
const { frontendUrl, ...mailSettings } = settings;
const deltaSettings: Record<string, any> = {
'system.frontendUrl': frontendUrl
};
return {
sectionData: mailSettings,
deltaSettings
};
}
});
useEffect(() => {
@@ -175,6 +226,21 @@ export default function AdminMailSection() {
placeholder="[email protected]"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.frontendUrl', 'Frontend URL')}</span>
<PendingBadge show={isFieldPending('frontendUrl')} />
</Group>
}
description={t('admin.settings.mail.frontendUrl.description', 'Base URL for frontend (e.g. https://pdf.example.com). Used for generating invite links in emails. Leave empty to use backend URL.')}
value={settings.frontendUrl || ''}
onChange={(e) => setSettings({ ...settings, frontendUrl: e.target.value })}
placeholder="https://pdf.example.com"
/>
</div>
</Stack>
</Paper>
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, Alert } from '@mantine/core';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, Alert, List } from '@mantine/core';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
@@ -73,12 +73,12 @@ export default function AdminPremiumSection() {
<Text size="sm">
{t('admin.settings.premium.movedFeatures.message', 'Premium and Enterprise features are now organized in their respective sections:')}
</Text>
<ul style={{ marginTop: '8px', marginBottom: 0, paddingLeft: '20px' }}>
<li><Text size="sm" component="span"><strong>SSO Auto Login</strong> (PRO) - Connections</Text></li>
<li><Text size="sm" component="span"><strong>Custom Metadata</strong> (PRO) - General</Text></li>
<li><Text size="sm" component="span"><strong>Audit Logging</strong> (ENTERPRISE) - Security</Text></li>
<li><Text size="sm" component="span"><strong>Database Configuration</strong> (ENTERPRISE) - Database</Text></li>
</ul>
<List mt="xs" size="sm">
<List.Item><Text size="sm" component="span"><strong>SSO Auto Login</strong> (PRO) - Connections</Text></List.Item>
<List.Item><Text size="sm" component="span"><strong>Custom Metadata</strong> (PRO) - General</Text></List.Item>
<List.Item><Text size="sm" component="span"><strong>Audit Logging</strong> (ENTERPRISE) - Security</Text></List.Item>
<List.Item><Text size="sm" component="span"><strong>Database Configuration</strong> (ENTERPRISE) - Database</Text></List.Item>
</List>
</Alert>
{/* License Configuration */}
@@ -67,32 +67,35 @@ export default function AdminSecuritySection() {
const premiumData = premiumResponse.data || {};
const systemData = systemResponse.data || {};
console.log('[AdminSecuritySection] Raw backend data:');
console.log('Security:', JSON.parse(JSON.stringify(securityData)));
console.log('Premium:', JSON.parse(JSON.stringify(premiumData)));
console.log('System:', JSON.parse(JSON.stringify(systemData)));
const { _pending: securityPending, ...securityActive } = securityData;
const { _pending: premiumPending, ...premiumActive } = premiumData;
const { _pending: systemPending, ...systemActive } = systemData;
console.log('[AdminSecuritySection] Extracted pending blocks:', {
securityPending: JSON.parse(JSON.stringify(securityPending || {})),
premiumPending: JSON.parse(JSON.stringify(premiumPending || {})),
systemPending: JSON.parse(JSON.stringify(systemPending || {}))
});
const combined: any = {
...securityActive,
audit: premiumActive.enterpriseFeatures?.audit || {
enabled: false,
level: 2,
retentionDays: 90
},
html: systemActive.html || {
urlSecurity: {
enabled: true,
level: 'MEDIUM',
allowedDomains: [],
blockedDomains: [],
internalTlds: ['.local', '.internal', '.corp', '.home'],
blockPrivateNetworks: true,
blockLocalhost: true,
blockLinkLocal: true,
blockCloudMetadata: true
}
}
...securityActive
};
// Only add audit if it exists (don't create defaults)
if (premiumActive.enterpriseFeatures?.audit) {
combined.audit = premiumActive.enterpriseFeatures.audit;
}
// Only add html if it exists (don't create defaults)
if (systemActive.html) {
combined.html = systemActive.html;
}
// Merge all _pending blocks
const mergedPending: any = {};
if (securityPending) {
@@ -115,11 +118,25 @@ export default function AdminSecuritySection() {
const { audit, html, ...securitySettings } = settings;
const deltaSettings: Record<string, any> = {
// Security settings
'security.enableLogin': securitySettings.enableLogin,
'security.csrfDisabled': securitySettings.csrfDisabled,
'security.loginMethod': securitySettings.loginMethod,
'security.loginAttemptCount': securitySettings.loginAttemptCount,
'security.loginResetTimeMinutes': securitySettings.loginResetTimeMinutes,
// JWT settings
'security.jwt.persistence': securitySettings.jwt?.persistence,
'security.jwt.enableKeyRotation': securitySettings.jwt?.enableKeyRotation,
'security.jwt.enableKeyCleanup': securitySettings.jwt?.enableKeyCleanup,
'security.jwt.keyRetentionDays': securitySettings.jwt?.keyRetentionDays,
'security.jwt.secureCookie': securitySettings.jwt?.secureCookie,
// Premium audit settings
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
'premium.enterpriseFeatures.audit.level': audit?.level,
'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays
};
// System HTML settings
if (html?.urlSecurity) {
deltaSettings['system.html.urlSecurity.enabled'] = html.urlSecurity.enabled;
deltaSettings['system.html.urlSecurity.level'] = html.urlSecurity.level;
@@ -133,7 +150,7 @@ export default function AdminSecuritySection() {
}
return {
sectionData: securitySettings,
sectionData: {},
deltaSettings
};
}
@@ -217,7 +234,12 @@ export default function AdminSecuritySection() {
<div>
<NumberInput
label={t('admin.settings.security.loginAttemptCount', 'Login Attempt Limit')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.loginAttemptCount', 'Login Attempt Limit')}</span>
<PendingBadge show={isFieldPending('loginAttemptCount')} />
</Group>
}
description={t('admin.settings.security.loginAttemptCount.description', 'Maximum number of failed login attempts before account lockout')}
value={settings.loginAttemptCount || 0}
onChange={(value) => setSettings({ ...settings, loginAttemptCount: Number(value) })}
@@ -228,7 +250,12 @@ export default function AdminSecuritySection() {
<div>
<NumberInput
label={t('admin.settings.security.loginResetTimeMinutes', 'Login Reset Time (minutes)')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.loginResetTimeMinutes', 'Login Reset Time (minutes)')}</span>
<PendingBadge show={isFieldPending('loginResetTimeMinutes')} />
</Group>
}
description={t('admin.settings.security.loginResetTimeMinutes.description', 'Time before failed login attempts are reset')}
value={settings.loginResetTimeMinutes || 0}
onChange={(value) => setSettings({ ...settings, loginResetTimeMinutes: Number(value) })}
@@ -295,10 +322,13 @@ export default function AdminSecuritySection() {
{t('admin.settings.security.jwt.enableKeyRotation.description', 'Automatically rotate JWT signing keys for improved security')}
</Text>
</div>
<Switch
checked={settings.jwt?.enableKeyRotation || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyRotation: e.target.checked } })}
/>
<Group gap="xs">
<Switch
checked={settings.jwt?.enableKeyRotation || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyRotation: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('jwt.enableKeyRotation')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -308,15 +338,23 @@ export default function AdminSecuritySection() {
{t('admin.settings.security.jwt.enableKeyCleanup.description', 'Automatically remove old JWT keys after retention period')}
</Text>
</div>
<Switch
checked={settings.jwt?.enableKeyCleanup || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyCleanup: e.target.checked } })}
/>
<Group gap="xs">
<Switch
checked={settings.jwt?.enableKeyCleanup || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyCleanup: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('jwt.enableKeyCleanup')} />
</Group>
</div>
<div>
<NumberInput
label={t('admin.settings.security.jwt.keyRetentionDays', 'Key Retention Days')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.jwt.keyRetentionDays', 'Key Retention Days')}</span>
<PendingBadge show={isFieldPending('jwt.keyRetentionDays')} />
</Group>
}
description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')}
value={settings.jwt?.keyRetentionDays || 7}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings.jwt, keyRetentionDays: Number(value) } })}
@@ -369,7 +407,12 @@ export default function AdminSecuritySection() {
<div>
<NumberInput
label={t('admin.settings.security.audit.level', 'Audit Level')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.audit.level', 'Audit Level')}</span>
<PendingBadge show={isFieldPending('audit.level')} />
</Group>
}
description={t('admin.settings.security.audit.level.description', '0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE')}
value={settings.audit?.level || 2}
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, level: Number(value) } })}
@@ -380,7 +423,12 @@ export default function AdminSecuritySection() {
<div>
<NumberInput
label={t('admin.settings.security.audit.retentionDays', 'Audit Retention (days)')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.audit.retentionDays', 'Audit Retention (days)')}</span>
<PendingBadge show={isFieldPending('audit.retentionDays')} />
</Group>
}
description={t('admin.settings.security.audit.retentionDays.description', 'Number of days to retain audit logs')}
value={settings.audit?.retentionDays || 90}
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, retentionDays: Number(value) } })}
@@ -425,7 +473,12 @@ export default function AdminSecuritySection() {
<div>
<Select
label={t('admin.settings.security.htmlUrlSecurity.level', 'Security Level')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.level', 'Security Level')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.level')} />
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.level.description', 'MAX: whitelist only, MEDIUM: block internal networks, OFF: no restrictions')}
value={settings.html?.urlSecurity?.level || 'MEDIUM'}
onChange={(value) => setSettings({
@@ -452,7 +505,12 @@ export default function AdminSecuritySection() {
{/* Allowed Domains */}
<div>
<Textarea
label={t('admin.settings.security.htmlUrlSecurity.allowedDomains', 'Allowed Domains (Whitelist)')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.allowedDomains', 'Allowed Domains (Whitelist)')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.allowedDomains')} />
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.allowedDomains.description', 'One domain per line (e.g., cdn.example.com). Only these domains allowed when level is MAX')}
value={settings.html?.urlSecurity?.allowedDomains?.join('\n') || ''}
onChange={(e) => setSettings({
@@ -474,7 +532,12 @@ export default function AdminSecuritySection() {
{/* Blocked Domains */}
<div>
<Textarea
label={t('admin.settings.security.htmlUrlSecurity.blockedDomains', 'Blocked Domains (Blacklist)')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.blockedDomains', 'Blocked Domains (Blacklist)')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.blockedDomains')} />
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.blockedDomains.description', 'One domain per line (e.g., malicious.com). Additional domains to block')}
value={settings.html?.urlSecurity?.blockedDomains?.join('\n') || ''}
onChange={(e) => setSettings({
@@ -496,7 +559,12 @@ export default function AdminSecuritySection() {
{/* Internal TLDs */}
<div>
<Textarea
label={t('admin.settings.security.htmlUrlSecurity.internalTlds', 'Internal TLDs')}
label={
<Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.internalTlds', 'Internal TLDs')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.internalTlds')} />
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.internalTlds.description', 'One TLD per line (e.g., .local, .internal). Block domains with these TLD patterns')}
value={settings.html?.urlSecurity?.internalTlds?.join('\n') || ''}
onChange={(e) => setSettings({
@@ -525,16 +593,19 @@ export default function AdminSecuritySection() {
{t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.description', 'Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)')}
</Text>
</div>
<Switch
checked={settings.html?.urlSecurity?.blockPrivateNetworks || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
}
})}
/>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockPrivateNetworks || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
}
})}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockPrivateNetworks')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -544,16 +615,19 @@ export default function AdminSecuritySection() {
{t('admin.settings.security.htmlUrlSecurity.blockLocalhost.description', 'Block localhost and loopback addresses (127.x.x.x, ::1)')}
</Text>
</div>
<Switch
checked={settings.html?.urlSecurity?.blockLocalhost || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLocalhost: e.target.checked }
}
})}
/>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockLocalhost || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLocalhost: e.target.checked }
}
})}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockLocalhost')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -563,16 +637,19 @@ export default function AdminSecuritySection() {
{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal.description', 'Block link-local addresses (169.254.x.x, fe80::/10)')}
</Text>
</div>
<Switch
checked={settings.html?.urlSecurity?.blockLinkLocal || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLinkLocal: e.target.checked }
}
})}
/>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockLinkLocal || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLinkLocal: e.target.checked }
}
})}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockLinkLocal')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -582,16 +659,19 @@ export default function AdminSecuritySection() {
{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata.description', 'Block cloud provider metadata endpoints (169.254.169.254)')}
</Text>
</div>
<Switch
checked={settings.html?.urlSecurity?.blockCloudMetadata || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockCloudMetadata: e.target.checked }
}
})}
/>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockCloudMetadata || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockCloudMetadata: e.target.checked }
}
})}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockCloudMetadata')} />
</Group>
</div>
</Stack>
</Accordion.Panel>
@@ -0,0 +1,201 @@
import React, { useState, useEffect } from 'react';
import {
Stack,
Group,
Text,
Button,
SegmentedControl,
Loader,
Alert,
Card,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services/usageAnalyticsService';
import UsageAnalyticsChart from '@app/components/shared/config/configSections/usage/UsageAnalyticsChart';
import UsageAnalyticsTable from '@app/components/shared/config/configSections/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 [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all');
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const limit = displayMode === 'all' ? undefined : displayMode === 'top10' ? 10 : 20;
const response = await usageAnalyticsService.getEndpointStatistics(limit, dataType);
setData(response);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load usage statistics');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [displayMode, dataType]);
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>
<Text size="sm" fw={500}>
{t('usage.controls.dataTypeLabel', 'Data Type:')}
</Text>
<SegmentedControl
value={dataType}
onChange={(value) => setDataType(value as 'all' | 'api' | 'ui')}
data={[
{
value: 'all',
label: t('usage.controls.dataType.all', 'All'),
},
{
value: 'api',
label: t('usage.controls.dataType.api', 'API'),
},
{
value: 'ui',
label: t('usage.controls.dataType.ui', 'UI'),
},
]}
/>
</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} />
<UsageAnalyticsTable data={data.endpoints} />
</Stack>
);
};
export default AdminUsageSection;
@@ -1,29 +1,98 @@
import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl } from '@mantine/core';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import type { ToolPanelMode } from '@app/constants/toolPanel';
import LocalIcon from '@app/components/shared/LocalIcon';
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
const BANNER_DISMISSED_KEY = 'stirlingpdf_features_banner_dismissed';
const GeneralSection: React.FC = () => {
interface GeneralSectionProps {
hideTitle?: boolean;
}
const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) => {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
const { config } = useAppConfig();
const [fileLimitInput, setFileLimitInput] = useState<number | string>(preferences.autoUnzipFileLimit);
const [bannerDismissed, setBannerDismissed] = useState(() => {
// Check localStorage on mount
return localStorage.getItem(BANNER_DISMISSED_KEY) === 'true';
});
// Sync local state with preference changes
useEffect(() => {
setFileLimitInput(preferences.autoUnzipFileLimit);
}, [preferences.autoUnzipFileLimit]);
// Check if login is disabled
const loginDisabled = !config?.enableLogin;
const handleDismissBanner = () => {
setBannerDismissed(true);
localStorage.setItem(BANNER_DISMISSED_KEY, 'true');
};
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('settings.general.description', 'Configure general application preferences.')}
</Text>
</div>
{!hideTitle && (
<div>
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('settings.general.description', 'Configure general application preferences.')}
</Text>
</div>
)}
{loginDisabled && !bannerDismissed && (
<Paper withBorder p="md" radius="md" style={{ background: 'var(--mantine-color-blue-0)', position: 'relative' }}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
onClick={handleDismissBanner}
aria-label={t('settings.general.enableFeatures.dismiss', 'Dismiss')}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
<Stack gap="sm">
<Group gap="xs">
<LocalIcon icon="admin-panel-settings-rounded" width="1.2rem" height="1.2rem" style={{ color: 'var(--mantine-color-blue-6)' }} />
<Text fw={600} size="sm" style={{ color: 'var(--mantine-color-blue-9)' }}>
{t('settings.general.enableFeatures.title', 'For System Administrators')}
</Text>
</Group>
<Text size="sm" c="dimmed">
{t('settings.general.enableFeatures.intro', 'Enable user authentication, team management, and workspace features for your organization.')}
</Text>
<Group gap="xs" wrap="wrap">
<Text size="sm" c="dimmed">
{t('settings.general.enableFeatures.action', 'Configure')}
</Text>
<Code>SECURITY_ENABLELOGIN=true</Code>
<Text size="sm" c="dimmed">
{t('settings.general.enableFeatures.and', 'and')}
</Text>
<Code>DISABLE_ADDITIONAL_FEATURES=false</Code>
</Group>
<Text size="xs" c="dimmed" fs="italic">
{t('settings.general.enableFeatures.benefit', 'Enables user roles, team collaboration, admin controls, and enterprise features.')}
</Text>
<Anchor
href="https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security"
target="_blank"
size="sm"
style={{ color: 'var(--mantine-color-blue-6)' }}
>
{t('settings.general.enableFeatures.learnMore', 'Learn more in documentation')}
</Anchor>
</Stack>
</Paper>
)}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
@@ -0,0 +1,157 @@
import React, { useState, useEffect } from 'react';
import { Card, Text, Group, Stack, SegmentedControl, Loader, Alert, Box, SimpleGrid } 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>
<Stack gap="sm">
{data.map((item, index) => (
<Box 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>
<Box
style={{
width: '100%',
height: '0.5rem',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-sm)',
overflow: 'hidden',
}}
>
<Box
style={{
width: `${(item.value / maxValue) * 100}%`,
height: '100%',
backgroundColor: `var(--mantine-color-${color}-6)`,
transition: 'width 0.3s ease',
}}
/>
</Box>
</Box>
))}
</Stack>
</Stack>
);
};
const AuditChartsSection: React.FC = () => {
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>
<Group justify="center">
<Loader size="lg" my="xl" />
</Group>
</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>
<SimpleGrid cols={3} spacing="xl">
<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"
/>
</SimpleGrid>
</Stack>
</Card>
);
};
export default AuditChartsSection;
@@ -0,0 +1,229 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Text,
Group,
Stack,
Button,
Pagination,
Modal,
Code,
Loader,
Alert,
Table,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import auditService, { AuditEvent } from '@app/services/auditService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAuditFilters } from '@app/hooks/useAuditFilters';
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
const AuditEventsTable: React.FC = () => {
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);
// Use shared filters hook
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({
page: 0,
pageSize: 20,
});
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]);
// Wrap filter handlers to reset pagination
const handleFilterChangeWithReset = (key: keyof typeof filters, value: any) => {
handleFilterChange(key, value);
setCurrentPage(1);
};
const handleClearFiltersWithReset = () => {
handleClearFilters();
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 */}
<AuditFiltersForm
filters={filters}
eventTypes={eventTypes}
users={users}
onFilterChange={handleFilterChangeWithReset}
onClearFilters={handleClearFiltersWithReset}
/>
{/* 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>
) : (
<>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
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('audit.events.timestamp', 'Timestamp')}
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('audit.events.type', 'Type')}
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('audit.events.user', 'User')}
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('audit.events.ipAddress', 'IP Address')}
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" ta="center">
{t('audit.events.actions', 'Actions')}
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{events.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={5}>
<Text ta="center" c="dimmed" py="xl">
{t('audit.events.noEvents', 'No events found')}
</Text>
</Table.Td>
</Table.Tr>
) : (
events.map((event) => (
<Table.Tr key={event.id}>
<Table.Td>
<Text size="sm">{formatDate(event.timestamp)}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{event.eventType}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{event.username}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{event.ipAddress}</Text>
</Table.Td>
<Table.Td ta="center">
<Button
variant="subtle"
size="xs"
onClick={() => setSelectedEvent(event)}
>
{t('audit.events.viewDetails', 'View Details')}
</Button>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
{/* 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"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{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,106 @@
import React, { useState } from 'react';
import {
Card,
Text,
Group,
Stack,
Button,
SegmentedControl,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import auditService from '@app/services/auditService';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useAuditFilters } from '@app/hooks/useAuditFilters';
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
const AuditExportSection: React.FC = () => {
const { t } = useTranslation();
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
const [exporting, setExporting] = useState(false);
// Use shared filters hook
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters();
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);
}
};
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>
<AuditFiltersForm
filters={filters}
eventTypes={eventTypes}
users={users}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
/>
</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,76 @@
import React from 'react';
import { Group, Select, Button } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useTranslation } from 'react-i18next';
import { AuditFilters } from '@app/services/auditService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface AuditFiltersFormProps {
filters: AuditFilters;
eventTypes: string[];
users: string[];
onFilterChange: (key: keyof AuditFilters, value: any) => void;
onClearFilters: () => void;
}
/**
* Shared filter form for audit components
*/
const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
filters,
eventTypes,
users,
onFilterChange,
onClearFilters,
}) => {
const { t } = useTranslation();
return (
<Group>
<Select
placeholder={t('audit.events.filterByType', 'Filter by type')}
data={eventTypes.map((type) => ({ value: type, label: type }))}
value={filters.eventType}
onChange={(value) => onFilterChange('eventType', value || undefined)}
clearable
style={{ flex: 1, minWidth: 200 }}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
placeholder={t('audit.events.filterByUser', 'Filter by user')}
data={users.map((user) => ({ value: user, label: user }))}
value={filters.username}
onChange={(value) => onFilterChange('username', value || undefined)}
clearable
searchable
style={{ flex: 1, minWidth: 200 }}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<DateInput
placeholder={t('audit.events.startDate', 'Start date')}
value={filters.startDate ? new Date(filters.startDate) : null}
onChange={(value: string | null) =>
onFilterChange('startDate', value ?? undefined)
}
clearable
style={{ flex: 1, minWidth: 150 }}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<DateInput
placeholder={t('audit.events.endDate', 'End date')}
value={filters.endDate ? new Date(filters.endDate) : null}
onChange={(value: string | null) =>
onFilterChange('endDate', value ?? undefined)
}
clearable
style={{ flex: 1, minWidth: 150 }}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button variant="outline" onClick={onClearFilters}>
{t('audit.events.clearFilters', 'Clear')}
</Button>
</Group>
);
};
export default AuditFiltersForm;
@@ -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,88 @@
import React from 'react';
import { Card, Text, Stack, Group, Box } 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 (
<Stack gap="sm">
{data.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t('usage.noData', 'No data available')}
</Text>
) : (
data.map((item, index) => (
<Box key={index}>
<Group justify="space-between" mb={4}>
<Text
size="xs"
c="dimmed"
maw="60%"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.label}
</Text>
<Group gap="xs">
<Text size="xs" fw={600}>
{item.value}
</Text>
<Text size="xs" c="dimmed">
({((item.value / maxValue) * 100).toFixed(1)}%)
</Text>
</Group>
</Group>
<Box
style={{
width: '100%',
height: '0.5rem',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-sm)',
overflow: 'hidden',
}}
>
<Box
style={{
width: `${(item.value / maxValue) * 100}%`,
height: '100%',
backgroundColor: 'var(--mantine-color-blue-6)',
transition: 'width 0.3s ease',
}}
/>
</Box>
</Box>
))
)}
</Stack>
);
};
interface UsageAnalyticsChartProps {
data: { label: string; value: number }[];
}
const UsageAnalyticsChart: React.FC<UsageAnalyticsChartProps> = ({ data }) => {
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,87 @@
import React from 'react';
import { Card, Text, Stack, Table } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { EndpointStatistic } from '@app/services/usageAnalyticsService';
interface UsageAnalyticsTableProps {
data: EndpointStatistic[];
}
const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data }) => {
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>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
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" w="5%">
#
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="55%">
{t('usage.table.endpoint', 'Endpoint')}
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="20%" ta="right">
{t('usage.table.visits', 'Visits')}
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="20%" ta="right">
{t('usage.table.percentage', 'Percentage')}
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={4}>
<Text ta="center" c="dimmed" py="xl">
{t('usage.table.noData', 'No data available')}
</Text>
</Table.Td>
</Table.Tr>
) : (
data.map((stat, index) => (
<Table.Tr key={index}>
<Table.Td>
<Text size="sm" c="dimmed">
{index + 1}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" truncate>
{stat.endpoint}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={600}>
{stat.visits.toLocaleString()}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" c="dimmed">
{stat.percentage.toFixed(2)}%
</Text>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Stack>
</Card>
);
};
export default UsageAnalyticsTable;
@@ -1,29 +1,35 @@
export type NavKey =
| 'overview'
| 'preferences'
| 'notifications'
| 'connections'
| 'general'
| 'people'
| 'teams'
| 'security'
| 'identity'
| 'plan'
| 'payments'
| 'requests'
| 'developer'
| 'api-keys'
| 'hotkeys'
| 'adminGeneral'
| 'adminSecurity'
| 'adminConnections'
| 'adminPrivacy'
| 'adminDatabase'
| 'adminAdvanced'
| 'adminLegal'
| 'adminPremium'
| 'adminFeatures'
| 'adminEndpoints';
// Single source of truth for all valid nav keys
export const VALID_NAV_KEYS = [
'preferences',
'notifications',
'connections',
'general',
'people',
'teams',
'security',
'identity',
'plan',
'payments',
'requests',
'developer',
'api-keys',
'hotkeys',
'adminGeneral',
'adminSecurity',
'adminConnections',
'adminPrivacy',
'adminDatabase',
'adminAdvanced',
'adminLegal',
'adminPremium',
'adminFeatures',
'adminPlan',
'adminAudit',
'adminUsage',
'adminEndpoints',
] as const;
// Derive the type from the array
export type NavKey = typeof VALID_NAV_KEYS[number];
// some of these are not used yet, but appear in figma designs
// some of these are not used yet, but appear in figma designs
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { alert } from '@app/components/toast';
import apiClient from '@app/services/apiClient';
export function useRestartServer() {
const { t } = useTranslation();
@@ -27,18 +28,12 @@ export function useRestartServer() {
),
});
const response = await fetch('/api/v1/admin/settings/restart', {
method: 'POST',
});
await apiClient.post('/api/v1/admin/settings/restart');
if (response.ok) {
// Wait a moment then reload the page
setTimeout(() => {
window.location.reload();
}, 3000);
} else {
throw new Error('Failed to restart');
}
// Wait a moment then reload the page
setTimeout(() => {
window.location.reload();
}, 3000);
} catch (_error) {
alert({
alertType: 'error',
+40 -9
View File
@@ -71,15 +71,15 @@ export function useAdminSettings<T = any>(
// Store raw settings (includes _pending if present)
setRawSettings(rawData);
// Extract active settings (without _pending) for delta comparison
const { _pending, ...activeOnly } = rawData;
setOriginalSettings(activeOnly as T);
console.log(`[useAdminSettings:${sectionName}] Original active settings:`, JSON.stringify(activeOnly, null, 2));
// Merge pending changes into settings for display
const mergedSettings = mergePendingSettings(rawData);
console.log(`[useAdminSettings:${sectionName}] Merged settings:`, JSON.stringify(mergedSettings, null, 2));
// Store merged settings as original for delta comparison
// This ensures we compare against what the user SAW (with pending), not raw active values
setOriginalSettings(mergedSettings as T);
console.log(`[useAdminSettings:${sectionName}] Original settings (for comparison):`, JSON.stringify(mergedSettings, null, 2));
setSettings(mergedSettings as T);
} catch (error) {
console.error(`[useAdminSettings:${sectionName}] Failed to fetch:`, error);
@@ -106,15 +106,46 @@ export function useAdminSettings<T = any>(
// Use custom save logic for complex sections
const { sectionData, deltaSettings } = saveTransformer(settings);
// Save section data (with delta applied)
const sectionDelta = computeDelta(originalSettings, sectionData);
// Get original sectionData using same transformer for fair comparison
const { sectionData: originalSectionData } = saveTransformer(originalSettings);
// Save section data (with delta applied) - compare transformed vs transformed
const sectionDelta = computeDelta(originalSectionData, sectionData);
if (Object.keys(sectionDelta).length > 0) {
await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, sectionDelta);
}
// Save delta settings if provided
// Save delta settings if provided (filter to only changed values)
if (deltaSettings && Object.keys(deltaSettings).length > 0) {
await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
// Build deltaSettings from original using same transformer to get correct structure
const { deltaSettings: originalDeltaSettings } = saveTransformer(originalSettings);
console.log(`[useAdminSettings:${sectionName}] Comparing deltaSettings:`, {
original: originalDeltaSettings,
current: deltaSettings
});
// Compare current vs original deltaSettings (both have same backend paths)
const changedDeltaSettings: Record<string, any> = {};
for (const [key, value] of Object.entries(deltaSettings)) {
const originalValue = originalDeltaSettings?.[key];
// Only include if value actually changed
if (JSON.stringify(value) !== JSON.stringify(originalValue)) {
changedDeltaSettings[key] = value;
console.log(`[useAdminSettings:${sectionName}] Delta field changed: ${key}`, {
original: originalValue,
new: value
});
}
}
if (Object.keys(changedDeltaSettings).length > 0) {
console.log(`[useAdminSettings:${sectionName}] Sending delta settings:`, changedDeltaSettings);
await apiClient.put('/api/v1/admin/settings', { settings: changedDeltaSettings });
} else {
console.log(`[useAdminSettings:${sectionName}] No delta settings changed, skipping`);
}
}
} else {
// Simple single-endpoint save with delta
@@ -0,0 +1,59 @@
import { useState, useEffect } from 'react';
import auditService, { AuditFilters } from '@app/services/auditService';
/**
* Shared hook for managing audit filters across components
*/
export function useAuditFilters(initialFilters: Partial<AuditFilters> = {}) {
const [eventTypes, setEventTypes] = useState<string[]>([]);
const [users, setUsers] = useState<string[]>([]);
const [filters, setFilters] = useState<AuditFilters>({
eventType: undefined,
username: undefined,
startDate: undefined,
endDate: undefined,
...initialFilters,
});
// Fetch metadata on mount
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 audit metadata:', err);
}
};
fetchMetadata();
}, []);
const handleFilterChange = (key: keyof AuditFilters, value: any) => {
setFilters((prev) => ({ ...prev, [key]: value }));
};
const handleClearFilters = () => {
setFilters({
eventType: undefined,
username: undefined,
startDate: undefined,
endDate: undefined,
page: initialFilters.page,
pageSize: initialFilters.pageSize,
});
};
return {
filters,
setFilters,
eventTypes,
users,
handleFilterChange,
handleClearFilters,
};
}
+115
View 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;
@@ -0,0 +1,61 @@
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,
dataType: 'all' | 'api' | 'ui' = 'all'
): Promise<EndpointStatisticsResponse> {
const params: Record<string, any> = {};
if (limit !== undefined) {
params.limit = limit;
}
if (dataType !== 'all') {
params.dataType = dataType;
}
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,
dataType: 'all' | 'api' | 'ui' = 'all'
): Promise<UsageChartData> {
const stats = await this.getEndpointStatistics(limit, dataType);
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 {
@@ -62,6 +68,35 @@ export interface InviteUsersResponse {
error?: string;
}
export interface InviteLinkRequest {
email: string;
role: string;
teamId?: number;
expiryHours?: number;
sendEmail?: boolean;
}
export interface InviteLinkResponse {
token: string;
inviteUrl: string;
email: string;
expiresAt: string;
expiryHours: number;
emailSent?: boolean;
emailError?: string;
error?: string;
}
export interface InviteToken {
id: number;
email: string;
role: string;
teamId?: number;
createdBy: string;
createdAt: string;
expiresAt: string;
}
/**
* User Management Service
* Provides functions to interact with user management backend APIs
@@ -163,4 +198,60 @@ export const userManagementService = {
return response.data;
},
/**
* Generate an invite link (admin only)
*/
async generateInviteLink(data: InviteLinkRequest): Promise<InviteLinkResponse> {
const formData = new FormData();
// Only append email if it's provided and not empty
if (data.email && data.email.trim()) {
formData.append('email', data.email);
}
formData.append('role', data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
}
if (data.expiryHours) {
formData.append('expiryHours', data.expiryHours.toString());
}
if (data.sendEmail !== undefined) {
formData.append('sendEmail', data.sendEmail.toString());
}
const response = await apiClient.post<InviteLinkResponse>(
'/api/v1/invite/generate',
formData,
{
suppressErrorToast: true,
} as any
);
return response.data;
},
/**
* Get list of active invite links (admin only)
*/
async getInviteLinks(): Promise<InviteToken[]> {
const response = await apiClient.get<{ invites: InviteToken[] }>('/api/v1/invite/list');
return response.data.invites;
},
/**
* Revoke an invite link (admin only)
*/
async revokeInviteLink(inviteId: number): Promise<void> {
await apiClient.delete(`/api/v1/invite/revoke/${inviteId}`, {
suppressErrorToast: true,
} as any);
},
/**
* Clean up expired invite links (admin only)
*/
async cleanupExpiredInvites(): Promise<{ deletedCount: number }> {
const response = await apiClient.post<{ deletedCount: number }>('/api/v1/invite/cleanup');
return response.data;
},
};
@@ -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}`;
}
@@ -50,12 +50,19 @@ export function isFieldPending<T extends SettingsWithPending>(
fieldPath: string
): boolean {
if (!settings?._pending) {
console.log(`[isFieldPending] No _pending block found for field: ${fieldPath}`);
return false;
}
// Navigate the pending object using dot notation
const value = getNestedValue(settings._pending, fieldPath);
return value !== undefined;
const isPending = value !== undefined;
if (isPending) {
console.log(`[isFieldPending] Field ${fieldPath} IS pending with value:`, value);
}
return isPending;
}
/**
+2
View File
@@ -6,6 +6,7 @@ import Landing from "@app/routes/Landing";
import Login from "@app/routes/Login";
import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@app/routes/InviteAccept";
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
// Import global styles
@@ -26,6 +27,7 @@ export default function App() {
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
@@ -0,0 +1,44 @@
import React from 'react';
import { createConfigNavSections as createCoreConfigNavSections, ConfigNavSection } from '@core/components/shared/config/configNavSections';
import PeopleSection from '@app/components/shared/config/configSections/PeopleSection';
import TeamsSection from '@app/components/shared/config/configSections/TeamsSection';
/**
* Proprietary extension of createConfigNavSections that adds workspace sections
*/
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false
): ConfigNavSection[] => {
// Get the core sections
const sections = createCoreConfigNavSections(isAdmin, runningEE);
// Add Workspace section if user is admin
if (isAdmin) {
const workspaceSection: ConfigNavSection = {
title: 'Workspace',
items: [
{
key: 'people',
label: 'People',
icon: 'group-rounded',
component: <PeopleSection />
},
{
key: 'teams',
label: 'Teams',
icon: 'groups-rounded',
component: <TeamsSection />
},
],
};
// Insert workspace section after Preferences (at index 1)
sections.splice(1, 0, workspaceSection);
}
return sections;
};
// Re-export types for convenience
export type { ConfigNavSection, ConfigNavItem, ConfigColors } from '@core/components/shared/config/configNavSections';
@@ -0,0 +1,53 @@
import React from 'react';
import { Stack, Text, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { useNavigate } from 'react-router-dom';
import CoreGeneralSection from '@core/components/shared/config/configSections/GeneralSection';
/**
* Proprietary extension of GeneralSection that adds account management
*/
const GeneralSection: React.FC = () => {
const { t } = useTranslation();
const { signOut, user } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
try {
await signOut();
navigate('/login');
} catch (error) {
console.error('Logout error:', error);
}
};
return (
<Stack gap="lg">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('settings.general.description', 'Configure general application preferences.')}
</Text>
</div>
{user && (
<Stack gap="xs" align="flex-end">
<Text size="sm" c="dimmed">
{t('settings.general.user', 'User')}: <strong>{user.email || user.username}</strong>
</Text>
<Button color="red" variant="outline" size="xs" onClick={handleLogout}>
{t('settings.general.logout', 'Log out')}
</Button>
</Stack>
)}
</div>
{/* Render core general section preferences (without title since we show it above) */}
<CoreGeneralSection hideTitle />
</Stack>
);
};
export default GeneralSection;
@@ -19,6 +19,8 @@ import {
SegmentedControl,
Tooltip,
CloseButton,
Avatar,
Box,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
@@ -38,7 +40,18 @@ export default function PeopleSection() {
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<'email' | 'direct'>('direct');
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(null);
// 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({
@@ -56,6 +69,15 @@ export default function PeopleSection() {
teamId: undefined as number | undefined,
});
// Form state for invite link
const [inviteLinkForm, setInviteLinkForm] = useState({
email: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
expiryHours: 72,
sendEmail: false,
});
// Form state for edit user modal
const [editForm, setEditForm] = useState({
role: 'ROLE_USER',
@@ -89,6 +111,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' });
@@ -189,6 +221,51 @@ export default function PeopleSection() {
}
};
const handleGenerateInviteLink = async () => {
try {
setProcessing(true);
const response = await userManagementService.generateInviteLink({
email: inviteLinkForm.email,
role: inviteLinkForm.role,
teamId: inviteLinkForm.teamId,
expiryHours: inviteLinkForm.expiryHours,
sendEmail: inviteLinkForm.sendEmail,
});
// Construct full frontend URL
const frontendUrl = `${window.location.origin}/invite/${response.token}`;
setGeneratedInviteLink(frontendUrl);
if (response.emailSent) {
alert({
alertType: 'success',
title: t('workspace.people.inviteLink.successWithEmail', 'Invite link generated and email sent!')
});
} else if (inviteLinkForm.sendEmail && response.emailError) {
// Email was requested but failed
alert({
alertType: 'warning',
title: t('workspace.people.inviteLink.emailFailed', 'Invite link generated, but email failed'),
body: t('workspace.people.inviteLink.emailFailedDetails', 'Error: {0}. Please share the invite link manually.').replace('{0}', response.emailError)
});
} else {
alert({
alertType: 'success',
title: t('workspace.people.inviteLink.success', 'Invite link generated successfully!')
});
}
} catch (error: any) {
console.error('Failed to generate invite link:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.inviteLink.error', 'Failed to generate invite link');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleUpdateUserRole = async () => {
if (!selectedUser) return;
@@ -267,6 +344,18 @@ export default function PeopleSection() {
});
};
const closeInviteModal = () => {
setInviteModalOpened(false);
setGeneratedInviteLink(null);
setInviteLinkForm({
email: '',
role: 'ROLE_USER',
teamId: undefined,
expiryHours: 72,
sendEmail: false,
});
};
const filteredUsers = users.filter((user) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase())
);
@@ -289,12 +378,12 @@ export default function PeopleSection() {
const renderRoleOption = ({ option }: { option: any }) => (
<Group gap="sm" wrap="nowrap">
<LocalIcon icon={option.icon} width="1.25rem" height="1.25rem" style={{ flexShrink: 0 }} />
<div style={{ flex: 1 }}>
<Box style={{ flex: 1 }}>
<Text size="sm" fw={500}>{option.label}</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
{option.description}
</Text>
</div>
</Box>
</Group>
);
@@ -325,6 +414,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 +456,138 @@ 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 : false}
>
{t('workspace.people.addMembers')}
</Button>
</Tooltip>
</Group>
{/* Members Table */}
<Paper withBorder p="md">
<Table striped highlightOnHover>
<Table.Thead>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
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}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Tooltip
label={
!user.enabled
? t('workspace.people.disabled', 'Disabled')
: user.isActive
? t('workspace.people.activeSession', 'Active session')
: t('workspace.people.active', 'Active')
}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Avatar
size={32}
color={user.enabled ? 'blue' : 'gray'}
styles={{
root: {
border: user.isActive ? '2px solid var(--mantine-color-green-6)' : 'none',
opacity: user.enabled ? 1 : 0.5,
}
}}
>
{user.username.charAt(0).toUpperCase()}
</Avatar>
</Tooltip>
<Box 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>
</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>
</Tooltip>
{user.email && (
<Text size="xs" c="dimmed" truncate style={{ lineHeight: 1.3 }}>
{user.email}
</Text>
)}
</Box>
</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,28 +639,27 @@ export default function PeopleSection() {
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Paper>
</Table.Tbody>
</Table>
{/* Add Member Modal */}
<Modal
opened={inviteModalOpened}
onClose={() => setInviteModalOpened(false)}
onClose={closeInviteModal}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<Box pos="relative">
<CloseButton
onClick={() => setInviteModalOpened(false)}
onClick={closeInviteModal}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
top: -8,
right: -8,
zIndex: 1
}}
/>
@@ -495,6 +677,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')}
@@ -506,12 +714,19 @@ export default function PeopleSection() {
<div>
<SegmentedControl
value={inviteMode}
onChange={(value) => setInviteMode(value as 'email' | 'direct')}
onChange={(value) => {
setInviteMode(value as 'email' | 'direct' | 'link');
setGeneratedInviteLink(null);
}}
data={[
{
label: t('workspace.people.inviteMode.username', 'Username'),
value: 'direct',
},
{
label: t('workspace.people.inviteMode.link', 'Link'),
value: 'link',
},
{
label: t('workspace.people.inviteMode.email', 'Email'),
value: 'email',
@@ -523,6 +738,90 @@ export default function PeopleSection() {
</div>
</Tooltip>
{/* Link Mode */}
{inviteMode === 'link' && (
<>
<TextInput
label={t('workspace.people.inviteLink.email', 'Email (optional)')}
placeholder={t('workspace.people.inviteLink.emailPlaceholder', '[email protected]')}
value={inviteLinkForm.email}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, email: e.currentTarget.value })}
description={t('workspace.people.inviteLink.emailDescription', 'If provided, the link will be tied to this email address')}
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteLinkForm.role}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteLinkForm.teamId?.toString()}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<TextInput
label={t('workspace.people.inviteLink.expiryHours', 'Link expires in (hours)')}
type="number"
value={inviteLinkForm.expiryHours}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })}
min={1}
max={720}
/>
{inviteLinkForm.email && (
<Checkbox
label={t('workspace.people.inviteLink.sendEmail', 'Send invite link via email')}
description={t('workspace.people.inviteLink.sendEmailDescription', 'Also send the link to the provided email address')}
checked={inviteLinkForm.sendEmail}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })}
/>
)}
{/* Display generated link */}
{generatedInviteLink && (
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
<Stack gap="sm">
<Text size="sm" fw={500}>{t('workspace.people.inviteLink.generated', 'Invite Link Generated')}</Text>
<Group gap="xs">
<TextInput
value={generatedInviteLink}
readOnly
style={{ flex: 1 }}
/>
<Button
variant="light"
onClick={async () => {
try {
await navigator.clipboard.writeText(generatedInviteLink);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
} catch {
// Fallback for browsers without clipboard API
const textArea = document.createElement('textarea');
textArea.value = generatedInviteLink;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
}
}}
>
<LocalIcon icon="content-copy" width="1rem" height="1rem" />
</Button>
</Group>
</Stack>
</Paper>
)}
</>
)}
{/* Email Mode */}
{inviteMode === 'email' && config?.enableEmailInvites && (
<>
@@ -599,7 +898,7 @@ export default function PeopleSection() {
{/* Action Button */}
<Button
onClick={inviteMode === 'email' ? handleEmailInvite : handleInviteUser}
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
loading={processing}
fullWidth
size="md"
@@ -607,10 +906,12 @@ export default function PeopleSection() {
>
{inviteMode === 'email'
? t('workspace.people.emailInvite.submit', 'Send Invites')
: t('workspace.people.addMember.submit')}
: inviteMode === 'link'
? t('workspace.people.inviteLink.submit', 'Generate Link')
: t('workspace.people.addMember.submit')}
</Button>
</Stack>
</div>
</Box>
</Modal>
{/* Edit User Modal */}
@@ -623,14 +924,14 @@ export default function PeopleSection() {
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<Box pos="relative">
<CloseButton
onClick={closeEditModal}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
top: -8,
right: -8,
zIndex: 1
}}
/>
@@ -666,7 +967,7 @@ export default function PeopleSection() {
{t('workspace.people.editMember.submit')}
</Button>
</Stack>
</div>
</Box>
</Modal>
</Stack>
);
@@ -11,10 +11,11 @@ import {
Group,
Modal,
Select,
Paper,
CloseButton,
Tooltip,
Menu,
Avatar,
Box,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
@@ -42,6 +43,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 +56,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 +241,120 @@ 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 : false)}
>
{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"
withRowBorders
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}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Tooltip
label={
!user.enabled
? t('workspace.people.disabled', 'Disabled')
: isActive
? t('workspace.people.activeSession', 'Active session')
: t('workspace.people.active', 'Active')
}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Avatar
size={32}
color={user.enabled ? 'blue' : 'gray'}
styles={{
root: {
border: isActive ? '2px solid var(--mantine-color-green-6)' : 'none',
opacity: user.enabled ? 1 : 0.5,
}
}}
>
{user.username.charAt(0).toUpperCase()}
</Avatar>
</Tooltip>
<Box 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>
)}
</Box>
</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 +421,11 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
</Group>
</Table.Td>
</Table.Tr>
))
);
})
)}
</Table.Tbody>
</Table>
</Paper>
</Table>
{/* Add Member Modal */}
<Modal
@@ -374,8 +443,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 +502,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
top: -8,
right: -8,
zIndex: 1,
}}
/>
@@ -12,9 +12,9 @@ import {
Loader,
Group,
Modal,
Paper,
Select,
CloseButton,
Tooltip,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
@@ -224,15 +224,26 @@ 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"
withRowBorders
highlightOnHover
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>
@@ -251,18 +262,30 @@ export default function TeamsSection() {
>
<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 +320,7 @@ export default function TeamsSection() {
))
)}
</Table.Tbody>
</Table>
</Paper>
</Table>
{/* Create Team Modal */}
<Modal
@@ -316,8 +338,8 @@ export default function TeamsSection() {
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
top: -8,
right: -8,
zIndex: 1
}}
/>
@@ -361,8 +383,8 @@ export default function TeamsSection() {
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
top: -8,
right: -8,
zIndex: 1
}}
/>
@@ -409,8 +431,8 @@ export default function TeamsSection() {
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
top: -8,
right: -8,
zIndex: 1
}}
/>
@@ -0,0 +1,237 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Stack, Text, Paper, Center, Loader, TextInput, PasswordInput, Anchor } from '@mantine/core';
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
import AuthLayout from '@app/routes/authShared/AuthLayout';
import LoginHeader from '@app/routes/login/LoginHeader';
import ErrorMessage from '@app/routes/login/ErrorMessage';
import { BASE_PATH } from '@app/constants/app';
import apiClient from '@app/services/apiClient';
interface InviteData {
email: string | null;
role: string;
expiresAt: string;
emailRequired: boolean;
}
export default function InviteAccept() {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [inviteData, setInviteData] = useState<InviteData | null>(null);
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const baseUrl = window.location.origin + BASE_PATH;
// Set document meta
useDocumentMeta({
title: `${t('invite.welcome', 'Welcome to Stirling PDF')} - Stirling PDF`,
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogTitle: `${t('invite.welcome', 'Welcome to Stirling PDF')} - Stirling PDF`,
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`
});
useEffect(() => {
if (!token) {
setError(t('invite.invalidToken', 'Invalid invitation link'));
setLoading(false);
return;
}
validateToken();
}, [token]);
const validateToken = async () => {
try {
setLoading(true);
const response = await apiClient.get<InviteData>(`/api/v1/invite/validate/${token}`, {
suppressErrorToast: true,
} as any);
setInviteData(response.data);
setError(null);
} catch (err: any) {
const errorMessage =
err.response?.data?.error ||
err.message ||
t('invite.validationError', 'Failed to validate invitation link');
setError(errorMessage);
} finally {
setLoading(false);
}
};
const handleAccept = async (e: React.FormEvent) => {
e.preventDefault();
// Validate email if required
if (inviteData?.emailRequired) {
if (!email || email.trim().length === 0) {
setError(t('invite.emailRequired', 'Email address is required'));
return;
}
if (!email.includes('@')) {
setError(t('invite.invalidEmail', 'Invalid email address'));
return;
}
}
// Validate passwords
if (!password) {
setError(t('invite.passwordRequired', 'Password is required'));
return;
}
if (password !== confirmPassword) {
setError(t('invite.passwordMismatch', 'Passwords do not match'));
return;
}
try {
setSubmitting(true);
setError(null);
const formData = new FormData();
if (inviteData?.emailRequired) {
formData.append('email', email.trim().toLowerCase());
}
formData.append('password', password);
await apiClient.post(`/api/v1/invite/accept/${token}`, formData, {
suppressErrorToast: true,
} as any);
// Success - redirect to login
navigate('/login?messageType=accountCreated');
} catch (err: any) {
const errorMessage =
err.response?.data?.error ||
err.message ||
t('invite.acceptError', 'Failed to create account');
setError(errorMessage);
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<AuthLayout>
<LoginHeader title={t('invite.validating', 'Validating invitation...')} />
<Center py="xl">
<Loader size="md" />
</Center>
</AuthLayout>
);
}
if (error && !inviteData) {
return (
<AuthLayout>
<LoginHeader title={t('invite.invalidInvitation', 'Invalid Invitation')} />
<ErrorMessage error={error} />
<div className="auth-section">
<button
type="button"
onClick={() => navigate('/login')}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 auth-cta-button"
>
{t('invite.goToLogin', 'Go to Login')}
</button>
</div>
</AuthLayout>
);
}
return (
<AuthLayout>
<LoginHeader
title={t('invite.welcomeTitle', "You've been invited!")}
subtitle={t('invite.welcomeSubtitle', 'Complete your account setup to get started')}
/>
{inviteData && !inviteData.emailRequired && (
<Paper withBorder p="md" mb="lg" bg="blue.0" style={{ borderColor: 'var(--mantine-color-blue-3)' }}>
<Stack gap="xs" align="center">
<Text size="xs" tt="uppercase" c="dimmed" fw={500} style={{ letterSpacing: '0.05em' }}>
{t('invite.accountFor', 'Creating account for')}
</Text>
<Text size="lg" fw={600}>
{inviteData.email}
</Text>
<Text size="xs" c="dimmed">
{t('invite.linkExpires', 'Link expires')}: {new Date(inviteData.expiresAt).toLocaleDateString()} at {new Date(inviteData.expiresAt).toLocaleTimeString()}
</Text>
</Stack>
</Paper>
)}
<ErrorMessage error={error} />
<form onSubmit={handleAccept}>
<Stack gap="md">
{inviteData?.emailRequired && (
<TextInput
label={t('invite.email', 'Email address')}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('invite.emailPlaceholder', 'Enter your email address')}
disabled={submitting}
required
autoComplete="email"
/>
)}
<PasswordInput
label={t('invite.choosePassword', 'Choose a password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('invite.passwordPlaceholder', 'Enter your password')}
disabled={submitting}
required
autoComplete="new-password"
/>
<PasswordInput
label={t('invite.confirmPassword', 'Confirm password')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t('invite.confirmPasswordPlaceholder', 'Re-enter your password')}
disabled={submitting}
required
autoComplete="new-password"
/>
<div className="auth-section">
<button
type="submit"
disabled={submitting}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{submitting ? t('invite.creating', 'Creating Account...') : t('invite.createAccount', 'Create Account')}
</button>
</div>
</Stack>
</form>
<Center mt="md">
<Text size="sm" c="dimmed">
{t('invite.alreadyHaveAccount', 'Already have an account?')}{' '}
<Anchor component="button" type="button" onClick={() => navigate('/login')} c="dark">
{t('invite.signIn', 'Sign in')}
</Anchor>
</Text>
</Center>
</AuthLayout>
);
}
+37 -5
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { springAuth } from '@app/auth/springAuthClient';
import { useAuth } from '@app/auth/UseSession';
import { useTranslation } from 'react-i18next';
@@ -17,26 +17,42 @@ import { BASE_PATH } from '@app/constants/app';
export default function Login() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { session, loading } = useAuth();
const { t } = useTranslation();
const [isSigningIn, setIsSigningIn] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// Prefill email from query param (e.g. after password reset)
// Handle query params (email prefill and success messages)
useEffect(() => {
try {
const url = new URL(window.location.href);
const emailFromQuery = url.searchParams.get('email');
const emailFromQuery = searchParams.get('email');
if (emailFromQuery) {
setEmail(emailFromQuery);
}
const messageType = searchParams.get('messageType')
if (messageType) {
switch (messageType) {
case 'accountCreated':
setSuccessMessage(t('login.accountCreatedSuccess', 'Account created successfully! You can now sign in.'))
break
case 'passwordChanged':
setSuccessMessage(t('login.passwordChangedSuccess', 'Password changed successfully! Please sign in with your new password.'))
break
case 'credsUpdated':
setSuccessMessage(t('login.credentialsUpdated', 'Your credentials have been updated. Please sign in again.'))
break
}
}
} catch (_) {
// ignore
}
}, []);
}, [searchParams, t]);
const baseUrl = window.location.origin + BASE_PATH;
@@ -121,6 +137,22 @@ export default function Login() {
<AuthLayout>
<LoginHeader title={t('login.login') || 'Sign in'} />
{/* Success message */}
{successMessage && (
<div style={{
padding: '1rem',
marginBottom: '1rem',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
border: '1px solid rgba(34, 197, 94, 0.3)',
borderRadius: '0.5rem',
color: '#16a34a'
}}>
<p style={{ margin: 0, fontSize: '0.875rem', textAlign: 'center' }}>
{successMessage}
</p>
</div>
)}
<ErrorMessage error={error} />
{/* OAuth first */}