settingsPage Init selfhost (#4734)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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 <jbrunton96@gmail.com>
This commit is contained in:
Anthony Stirling
2025-10-28 14:47:41 +00:00
committed by GitHub
parent d2b38ef4b8
commit d0c5d74471
68 changed files with 9133 additions and 282 deletions

View File

@@ -190,7 +190,14 @@ export default function Workbench() {
{renderMainContent()}
</Box>
<Footer analyticsEnabled={config?.enableAnalytics === true} />
<Footer
analyticsEnabled={config?.enableAnalytics === true}
termsAndConditions={config?.termsAndConditions}
privacyPolicy={config?.privacyPolicy}
cookiePolicy={config?.cookiePolicy}
impressum={config?.impressum}
accessibilityStatement={config?.accessibilityStatement}
/>
</Box>
);
}

View File

@@ -0,0 +1,24 @@
import { useEffect } from 'react';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { updateSupportedLanguages } from '@app/i18n';
/**
* Component that loads app configuration and applies it to the application.
* This includes:
* - Filtering available languages based on config.languages
*
* Place this component high in the component tree, after i18n has initialized.
*/
export default function AppConfigLoader() {
const { config, loading } = useAppConfig();
useEffect(() => {
if (!loading && config) {
// Update supported languages if config specifies a language filter
updateSupportedLanguages(config.languages);
}
}, [config, loading]);
// This component doesn't render anything
return null;
}

View File

@@ -5,6 +5,7 @@ import LocalIcon from '@app/components/shared/LocalIcon';
import Overview from '@app/components/shared/config/configSections/Overview';
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
import { NavKey } from '@app/components/shared/config/types';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import '@app/components/shared/AppConfigModal.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
@@ -16,6 +17,7 @@ interface AppConfigModalProps {
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const [active, setActive] = useState<NavKey>('overview');
const isMobile = useMediaQuery("(max-width: 1024px)");
const { config } = useAppConfig();
useEffect(() => {
const handler = (ev: Event) => {
@@ -44,13 +46,17 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
console.log('Logout placeholder for SaaS compatibility');
};
// Get isAdmin from app config (based on JWT role)
const isAdmin = config?.isAdmin ?? false;
// Left navigation structure and icons
const configNavSections = useMemo(() =>
createConfigNavSections(
Overview,
handleLogout
handleLogout,
isAdmin
),
[]
[isAdmin]
);
const activeLabel = useMemo(() => {

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useFileState } from '@app/contexts/FileContext';
import { useFileActions } from '@app/contexts/file/fileHooks';
import CloseIcon from '@mui/icons-material/Close';
import { Z_INDEX_TOAST } from '@app/styles/zIndex';
interface DismissAllErrorsButtonProps {
className?: string;
@@ -38,7 +39,7 @@ const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ classNa
position: 'absolute',
top: '1rem',
right: '1rem',
zIndex: 1000,
zIndex: Z_INDEX_TOAST,
pointerEvents: 'auto'
}}
>

View File

@@ -0,0 +1,159 @@
import { useState } from 'react';
import { Modal, Stack, Text, PasswordInput, Button, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { accountService } from '@app/services/accountService';
import { alert } from '@app/components/toast';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
interface FirstLoginModalProps {
opened: boolean;
onPasswordChanged: () => void;
username: string;
}
/**
* FirstLoginModal
*
* Forces first-time users to change their password.
* Cannot be dismissed until password is successfully changed.
*/
export default function FirstLoginModal({ opened, onPasswordChanged, username }: FirstLoginModalProps) {
const { t } = useTranslation();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async () => {
// Validation
if (!currentPassword || !newPassword || !confirmPassword) {
setError(t('firstLogin.allFieldsRequired', 'All fields are required'));
return;
}
if (newPassword !== confirmPassword) {
setError(t('firstLogin.passwordsDoNotMatch', 'New passwords do not match'));
return;
}
if (newPassword.length < 8) {
setError(t('firstLogin.passwordTooShort', 'Password must be at least 8 characters'));
return;
}
if (newPassword === currentPassword) {
setError(t('firstLogin.passwordMustBeDifferent', 'New password must be different from current password'));
return;
}
try {
setLoading(true);
setError('');
await accountService.changePassword(currentPassword, newPassword);
alert({
alertType: 'success',
title: t('firstLogin.passwordChangedSuccess', 'Password changed successfully! Please log in again.')
});
// Clear form
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
// Wait a moment for the user to see the success message
// Then the backend will have logged them out, and onPasswordChanged will handle redirect
setTimeout(() => {
onPasswordChanged();
}, 1500);
} catch (err: any) {
console.error('Failed to change password:', err);
setError(
err.response?.data?.message ||
t('firstLogin.passwordChangeFailed', 'Failed to change password. Please check your current password.')
);
} finally {
setLoading(false);
}
};
return (
<Modal
opened={opened}
onClose={() => {}} // Cannot close
title={t('firstLogin.title', 'First Time Login')}
closeOnClickOutside={false}
closeOnEscape={false}
withCloseButton={false}
centered
size="md"
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
>
<Stack gap="md">
<Alert
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
title={t('firstLogin.welcomeTitle', 'Welcome!')}
color="blue"
>
<Text size="sm">
{t(
'firstLogin.welcomeMessage',
'For security reasons, you must change your password on your first login.'
)}
</Text>
</Alert>
<Text size="sm" fw={500}>
{t('firstLogin.loggedInAs', 'Logged in as')}: <strong>{username}</strong>
</Text>
{error && (
<Alert
icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />}
title={t('firstLogin.error', 'Error')}
color="red"
>
{error}
</Alert>
)}
<PasswordInput
label={t('firstLogin.currentPassword', 'Current Password')}
placeholder={t('firstLogin.enterCurrentPassword', 'Enter your current password')}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required
/>
<PasswordInput
label={t('firstLogin.newPassword', 'New Password')}
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
required
/>
<PasswordInput
label={t('firstLogin.confirmPassword', 'Confirm New Password')}
placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required
/>
<Button
fullWidth
onClick={handleSubmit}
loading={loading}
disabled={!currentPassword || !newPassword || !confirmPassword}
mt="md"
>
{t('firstLogin.changePassword', 'Change Password')}
</Button>
</Stack>
</Modal>
);
}

View File

@@ -12,14 +12,19 @@ interface FooterProps {
}
export default function Footer({
privacyPolicy = 'https://www.stirling.com/legal/privacy-policy',
termsAndConditions = 'https://www.stirling.com/legal/terms-of-service',
accessibilityStatement = 'accessibility',
privacyPolicy,
termsAndConditions,
accessibilityStatement,
cookiePolicy,
impressum,
analyticsEnabled = false
}: FooterProps) {
const { t } = useTranslation();
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
// Helper to check if a value is valid (not null/undefined/empty string)
const isValidLink = (link?: string) => link && link.trim().length > 0;
return (
<div style={{
height: 'var(--footer-height)',
@@ -43,7 +48,7 @@ export default function Footer({
>
{t('survey.nav', 'Survey')}
</a>
{privacyPolicy && (
{isValidLink(privacyPolicy) && (
<a
className="footer-link px-3"
target="_blank"
@@ -53,7 +58,7 @@ export default function Footer({
{t('legal.privacy', 'Privacy Policy')}
</a>
)}
{termsAndConditions && (
{isValidLink(termsAndConditions) && (
<a
className="footer-link px-3"
target="_blank"
@@ -63,7 +68,7 @@ export default function Footer({
{t('legal.terms', 'Terms and Conditions')}
</a>
)}
{accessibilityStatement && (
{isValidLink(accessibilityStatement) && (
<a
className="footer-link px-3"
target="_blank"
@@ -73,6 +78,26 @@ export default function Footer({
{t('legal.accessibility', 'Accessibility')}
</a>
)}
{isValidLink(cookiePolicy) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={cookiePolicy}
>
{t('legal.cookie', 'Cookie Policy')}
</a>
)}
{isValidLink(impressum) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={impressum}
>
{t('legal.impressum', 'Impressum')}
</a>
)}
{analyticsEnabled && (
<button
className="footer-link px-3"

View File

@@ -0,0 +1,22 @@
import { Badge } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface PendingBadgeProps {
show: boolean;
size?: 'xs' | 'sm' | 'md' | 'lg';
}
/**
* Badge to show when a setting has been saved but requires restart to take effect.
*/
export default function PendingBadge({ show, size = 'xs' }: PendingBadgeProps) {
const { t } = useTranslation();
if (!show) return null;
return (
<Badge color="orange" size={size} variant="light">
{t('admin.settings.restartRequired', 'Restart Required')}
</Badge>
);
}

View File

@@ -0,0 +1,68 @@
import { Modal, Text, Group, Button, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import RefreshIcon from '@mui/icons-material/Refresh';
import ScheduleIcon from '@mui/icons-material/Schedule';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface RestartConfirmationModalProps {
opened: boolean;
onClose: () => void;
onRestart: () => void;
}
export default function RestartConfirmationModal({
opened,
onClose,
onRestart,
}: RestartConfirmationModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Text fw={600} size="lg">
{t('admin.settings.restart.title', 'Restart Required')}
</Text>
}
centered
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
withinPortal
>
<Stack gap="lg">
<Text size="sm">
{t(
'admin.settings.restart.message',
'Settings have been saved successfully. A server restart is required for the changes to take effect.'
)}
</Text>
<Text size="sm" c="dimmed">
{t(
'admin.settings.restart.question',
'Would you like to restart the server now or later?'
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
leftSection={<ScheduleIcon style={{ fontSize: 16 }} />}
onClick={onClose}
>
{t('admin.settings.restart.later', 'Restart Later')}
</Button>
<Button
color="blue"
leftSection={<RefreshIcon style={{ fontSize: 16 }} />}
onClick={onRestart}
>
{t('admin.settings.restart.now', 'Restart Now')}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -2,6 +2,18 @@ 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';
import AdminPrivacySection from '@app/components/shared/config/configSections/AdminPrivacySection';
import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
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';
export interface ConfigNavItem {
key: NavKey;
@@ -28,6 +40,7 @@ export interface ConfigColors {
export const createConfigNavSections = (
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
onLogoutClick: () => void,
isAdmin: boolean = false
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
{
@@ -41,6 +54,23 @@ export const createConfigNavSections = (
},
],
},
{
title: 'Workspace',
items: [
{
key: 'people',
label: 'People',
icon: 'group-rounded',
component: <PeopleSection />
},
{
key: 'teams',
label: 'Teams',
icon: 'groups-rounded',
component: <TeamsSection />
},
],
},
{
title: 'Preferences',
items: [
@@ -60,5 +90,74 @@ export const createConfigNavSections = (
},
];
// Add Admin Settings section if user is admin
if (isAdmin) {
sections.push({
title: 'Admin Settings',
items: [
{
key: 'adminGeneral',
label: 'General',
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',
icon: 'extension-rounded',
component: <AdminFeaturesSection />
},
{
key: 'adminEndpoints',
label: 'Endpoints',
icon: 'api-rounded',
component: <AdminEndpointsSection />
},
{
key: 'adminAdvanced',
label: 'Advanced',
icon: 'tune-rounded',
component: <AdminAdvancedSection />
},
],
});
}
return sections;
};

View File

@@ -0,0 +1,821 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Accordion, TextInput } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
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 AdvancedSettingsData {
enableAlphaFunctionality?: boolean;
maxDPI?: number;
enableUrlToPDF?: boolean;
tessdataDir?: string;
disableSanitize?: boolean;
tempFileManagement?: {
baseTmpDir?: string;
libreofficeDir?: string;
systemTempDir?: string;
prefix?: string;
maxAgeHours?: number;
cleanupIntervalMinutes?: number;
startupCleanup?: boolean;
cleanupSystemTemp?: boolean;
};
processExecutor?: {
sessionLimit?: {
libreOfficeSessionLimit?: number;
pdfToHtmlSessionLimit?: number;
qpdfSessionLimit?: number;
tesseractSessionLimit?: number;
pythonOpenCvSessionLimit?: number;
weasyPrintSessionLimit?: number;
installAppSessionLimit?: number;
calibreSessionLimit?: number;
ghostscriptSessionLimit?: number;
ocrMyPdfSessionLimit?: number;
};
timeoutMinutes?: {
libreOfficetimeoutMinutes?: number;
pdfToHtmltimeoutMinutes?: number;
pythonOpenCvtimeoutMinutes?: number;
weasyPrinttimeoutMinutes?: number;
installApptimeoutMinutes?: number;
calibretimeoutMinutes?: number;
tesseractTimeoutMinutes?: number;
qpdfTimeoutMinutes?: number;
ghostscriptTimeoutMinutes?: number;
ocrMyPdfTimeoutMinutes?: number;
};
};
}
export default function AdminAdvancedSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<AdvancedSettingsData>({
sectionName: 'advanced',
fetchTransformer: async () => {
const [systemResponse, processExecutorResponse] = await Promise.all([
apiClient.get('/api/v1/admin/settings/section/system'),
apiClient.get('/api/v1/admin/settings/section/processExecutor')
]);
const systemData = systemResponse.data || {};
const processExecutorData = processExecutorResponse.data || {};
const result: any = {
enableAlphaFunctionality: systemData.enableAlphaFunctionality || false,
maxDPI: systemData.maxDPI || 0,
enableUrlToPDF: systemData.enableUrlToPDF || false,
tessdataDir: systemData.tessdataDir || '',
disableSanitize: systemData.disableSanitize || false,
tempFileManagement: systemData.tempFileManagement || {
baseTmpDir: '',
libreofficeDir: '',
systemTempDir: '',
prefix: 'stirling-pdf-',
maxAgeHours: 24,
cleanupIntervalMinutes: 30,
startupCleanup: true,
cleanupSystemTemp: false
},
processExecutor: processExecutorData || {}
};
// Merge pending blocks from both endpoints
const pendingBlock: any = {};
if (systemData._pending?.enableAlphaFunctionality !== undefined) {
pendingBlock.enableAlphaFunctionality = systemData._pending.enableAlphaFunctionality;
}
if (systemData._pending?.maxDPI !== undefined) {
pendingBlock.maxDPI = systemData._pending.maxDPI;
}
if (systemData._pending?.enableUrlToPDF !== undefined) {
pendingBlock.enableUrlToPDF = systemData._pending.enableUrlToPDF;
}
if (systemData._pending?.tessdataDir !== undefined) {
pendingBlock.tessdataDir = systemData._pending.tessdataDir;
}
if (systemData._pending?.disableSanitize !== undefined) {
pendingBlock.disableSanitize = systemData._pending.disableSanitize;
}
if (systemData._pending?.tempFileManagement) {
pendingBlock.tempFileManagement = systemData._pending.tempFileManagement;
}
if (processExecutorData._pending) {
pendingBlock.processExecutor = processExecutorData._pending;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
}
return result;
},
saveTransformer: (settings) => {
const deltaSettings: Record<string, any> = {
'system.enableAlphaFunctionality': settings.enableAlphaFunctionality,
'system.maxDPI': settings.maxDPI,
'system.enableUrlToPDF': settings.enableUrlToPDF,
'system.tessdataDir': settings.tessdataDir,
'system.disableSanitize': settings.disableSanitize
};
// Add temp file management settings
if (settings.tempFileManagement) {
deltaSettings['system.tempFileManagement.baseTmpDir'] = settings.tempFileManagement.baseTmpDir;
deltaSettings['system.tempFileManagement.libreofficeDir'] = settings.tempFileManagement.libreofficeDir;
deltaSettings['system.tempFileManagement.systemTempDir'] = settings.tempFileManagement.systemTempDir;
deltaSettings['system.tempFileManagement.prefix'] = settings.tempFileManagement.prefix;
deltaSettings['system.tempFileManagement.maxAgeHours'] = settings.tempFileManagement.maxAgeHours;
deltaSettings['system.tempFileManagement.cleanupIntervalMinutes'] = settings.tempFileManagement.cleanupIntervalMinutes;
deltaSettings['system.tempFileManagement.startupCleanup'] = settings.tempFileManagement.startupCleanup;
deltaSettings['system.tempFileManagement.cleanupSystemTemp'] = settings.tempFileManagement.cleanupSystemTemp;
}
// Add process executor settings
if (settings.processExecutor?.sessionLimit) {
Object.entries(settings.processExecutor.sessionLimit).forEach(([key, value]) => {
deltaSettings[`processExecutor.sessionLimit.${key}`] = value;
});
}
if (settings.processExecutor?.timeoutMinutes) {
Object.entries(settings.processExecutor.timeoutMinutes).forEach(([key, value]) => {
deltaSettings[`processExecutor.timeoutMinutes.${key}`] = value;
});
}
return {
sectionData: {},
deltaSettings
};
}
});
useEffect(() => {
fetchSettings();
}, []);
const handleSave = async () => {
try {
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.advanced.title', 'Advanced')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.advanced.description', 'Configure advanced features and experimental functionality.')}
</Text>
</div>
{/* Feature Flags */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.features', 'Feature Flags')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.enableAlphaFunctionality', 'Enable Alpha Features')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.enableAlphaFunctionality.description', 'Enable experimental and alpha-stage features (may be unstable)')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enableAlphaFunctionality || false}
onChange={(e) => setSettings({ ...settings, enableAlphaFunctionality: e.target.checked })}
/>
<PendingBadge show={isFieldPending('enableAlphaFunctionality')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.enableUrlToPDF', 'Enable URL to PDF')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.enableUrlToPDF.description', 'Allow conversion of web pages to PDF documents (internal use only)')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enableUrlToPDF || false}
onChange={(e) => setSettings({ ...settings, enableUrlToPDF: e.target.checked })}
/>
<PendingBadge show={isFieldPending('enableUrlToPDF')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.disableSanitize', 'Disable HTML Sanitization')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.disableSanitize.description', 'Disable HTML sanitization (WARNING: Security risk - can lead to XSS injections)')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.disableSanitize || false}
onChange={(e) => setSettings({ ...settings, disableSanitize: e.target.checked })}
/>
<PendingBadge show={isFieldPending('disableSanitize')} />
</Group>
</div>
</Stack>
</Paper>
{/* Processing Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.processing', 'Processing')}</Text>
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.advanced.maxDPI', 'Maximum DPI')}</span>
<PendingBadge show={isFieldPending('maxDPI')} />
</Group>
}
description={t('admin.settings.advanced.maxDPI.description', 'Maximum DPI for image processing (0 = unlimited)')}
value={settings.maxDPI || 0}
onChange={(value) => setSettings({ ...settings, maxDPI: Number(value) })}
min={0}
max={3000}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.advanced.tessdataDir', 'Tessdata Directory')}</span>
<PendingBadge show={isFieldPending('tessdataDir')} />
</Group>
}
description={t('admin.settings.advanced.tessdataDir.description', 'Path to the directory containing Tessdata files for OCR')}
value={settings.tessdataDir || ''}
onChange={(e) => setSettings({ ...settings, tessdataDir: e.target.value })}
placeholder="/usr/share/tessdata"
/>
</div>
</Stack>
</Paper>
{/* Temp File Management */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.tempFileManagement', 'Temp File Management')}</Text>
<Text size="xs" c="dimmed">
{t('admin.settings.advanced.tempFileManagement.description', 'Configure temporary file storage and cleanup behavior')}
</Text>
</div>
<div>
<TextInput
label={t('admin.settings.advanced.tempFileManagement.baseTmpDir', 'Base Temp Directory')}
description={t('admin.settings.advanced.tempFileManagement.baseTmpDir.description', 'Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)')}
value={settings.tempFileManagement?.baseTmpDir || ''}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, baseTmpDir: e.target.value }
})}
placeholder="Default: java.io.tmpdir/stirling-pdf"
/>
</div>
<div>
<TextInput
label={t('admin.settings.advanced.tempFileManagement.libreofficeDir', 'LibreOffice Temp Directory')}
description={t('admin.settings.advanced.tempFileManagement.libreofficeDir.description', 'Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)')}
value={settings.tempFileManagement?.libreofficeDir || ''}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, libreofficeDir: e.target.value }
})}
placeholder="Default: baseTmpDir/libreoffice"
/>
</div>
<div>
<TextInput
label={t('admin.settings.advanced.tempFileManagement.systemTempDir', 'System Temp Directory')}
description={t('admin.settings.advanced.tempFileManagement.systemTempDir.description', 'System temp directory to clean (only used if cleanupSystemTemp is enabled)')}
value={settings.tempFileManagement?.systemTempDir || ''}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, systemTempDir: e.target.value }
})}
placeholder="System temp directory path"
/>
</div>
<div>
<TextInput
label={t('admin.settings.advanced.tempFileManagement.prefix', 'Temp File Prefix')}
description={t('admin.settings.advanced.tempFileManagement.prefix.description', 'Prefix for temp file names')}
value={settings.tempFileManagement?.prefix || 'stirling-pdf-'}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, prefix: e.target.value }
})}
placeholder="stirling-pdf-"
/>
</div>
<div>
<NumberInput
label={t('admin.settings.advanced.tempFileManagement.maxAgeHours', 'Max Age (hours)')}
description={t('admin.settings.advanced.tempFileManagement.maxAgeHours.description', 'Maximum age in hours before temp files are cleaned up')}
value={settings.tempFileManagement?.maxAgeHours ?? 24}
onChange={(value) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, maxAgeHours: Number(value) }
})}
min={1}
max={720}
/>
</div>
<div>
<NumberInput
label={t('admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes', 'Cleanup Interval (minutes)')}
description={t('admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes.description', 'How often to run cleanup (in minutes)')}
value={settings.tempFileManagement?.cleanupIntervalMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, cleanupIntervalMinutes: Number(value) }
})}
min={1}
max={1440}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.startupCleanup', 'Startup Cleanup')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.tempFileManagement.startupCleanup.description', 'Clean up old temp files on application startup')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.tempFileManagement?.startupCleanup ?? true}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked }
})}
/>
<PendingBadge show={isFieldPending('tempFileManagement.startupCleanup')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp', 'Cleanup System Temp')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp.description', 'Whether to clean broader system temp directory (use with caution)')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.tempFileManagement?.cleanupSystemTemp ?? false}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked }
})}
/>
<PendingBadge show={isFieldPending('tempFileManagement.cleanupSystemTemp')} />
</Group>
</div>
</Stack>
</Paper>
{/* Process Executor Limits */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm">{t('admin.settings.advanced.processExecutor', 'Process Executor Limits')}</Text>
<Text size="xs" c="dimmed">
{t('admin.settings.advanced.processExecutor.description', 'Configure session limits and timeouts for each process executor')}
</Text>
<Accordion variant="separated">
{/* LibreOffice */}
<Accordion.Item value="libreOffice">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.libreOffice', 'LibreOffice')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.libreOfficeSessionLimit ?? 1}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, libreOfficeSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.libreOfficetimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, libreOfficetimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* PDF to HTML */}
<Accordion.Item value="pdfToHtml">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.pdfToHtml', 'PDF to HTML')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.pdfToHtmlSessionLimit ?? 1}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, pdfToHtmlSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.pdfToHtmltimeoutMinutes ?? 20}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, pdfToHtmltimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* QPDF */}
<Accordion.Item value="qpdf">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.qpdf', 'QPDF')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.qpdfSessionLimit ?? 4}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, qpdfSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.qpdfTimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, qpdfTimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* Tesseract OCR */}
<Accordion.Item value="tesseract">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.tesseract', 'Tesseract OCR')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.tesseractSessionLimit ?? 1}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, tesseractSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.tesseractTimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, tesseractTimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* Python OpenCV */}
<Accordion.Item value="pythonOpenCv">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.pythonOpenCv', 'Python OpenCV')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.pythonOpenCvSessionLimit ?? 8}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, pythonOpenCvSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.pythonOpenCvtimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, pythonOpenCvtimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* WeasyPrint */}
<Accordion.Item value="weasyPrint">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.weasyPrint', 'WeasyPrint')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.weasyPrintSessionLimit ?? 16}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, weasyPrintSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.weasyPrinttimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, weasyPrinttimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* Install App */}
<Accordion.Item value="installApp">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.installApp', 'Install App')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.installAppSessionLimit ?? 1}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, installAppSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.installApptimeoutMinutes ?? 60}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, installApptimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* Calibre */}
<Accordion.Item value="calibre">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.calibre', 'Calibre')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.calibreSessionLimit ?? 1}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, calibreSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.calibretimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, calibretimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* Ghostscript */}
<Accordion.Item value="ghostscript">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.ghostscript', 'Ghostscript')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.ghostscriptSessionLimit ?? 8}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, ghostscriptSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.ghostscriptTimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, ghostscriptTimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
{/* OCRmyPDF */}
<Accordion.Item value="ocrMyPdf">
<Accordion.Control>{t('admin.settings.advanced.processExecutor.ocrMyPdf', 'OCRmyPDF')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.ocrMyPdfSessionLimit ?? 2}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
sessionLimit: { ...settings.processExecutor?.sessionLimit, ocrMyPdfSessionLimit: Number(value) }
}
})}
min={1}
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.ocrMyPdfTimeoutMinutes ?? 30}
onChange={(value) => setSettings({
...settings,
processExecutor: {
...settings.processExecutor,
timeoutMinutes: { ...settings.processExecutor?.timeoutMinutes, ocrMyPdfTimeoutMinutes: Number(value) }
}
})}
min={1}
max={240}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}

View File

@@ -0,0 +1,409 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import ProviderCard from '@app/components/shared/config/configSections/ProviderCard';
import {
ALL_PROVIDERS,
Provider,
} from '@app/components/shared/config/configSections/providerDefinitions';
import apiClient from '@app/services/apiClient';
interface ConnectionsSettingsData {
oauth2?: {
enabled?: boolean;
issuer?: string;
clientId?: string;
clientSecret?: string;
provider?: string;
autoCreateUser?: boolean;
blockRegistration?: boolean;
useAsUsername?: string;
scopes?: string;
client?: {
[key: string]: any;
};
};
saml2?: {
[key: string]: any;
};
mail?: {
enabled?: boolean;
enableInvites?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
from?: string;
};
ssoAutoLogin?: boolean;
}
export default function AdminConnectionsSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
fetchSettings,
isFieldPending,
} = useAdminSettings<ConnectionsSettingsData>({
sectionName: 'connections',
fetchTransformer: async () => {
// Fetch security settings (oauth2, saml2)
const securityResponse = await apiClient.get('/api/v1/admin/settings/section/security');
const securityData = securityResponse.data || {};
// Fetch mail settings
const mailResponse = await apiClient.get('/api/v1/admin/settings/section/mail');
const mailData = mailResponse.data || {};
// Fetch premium settings for SSO Auto Login
const premiumResponse = await apiClient.get('/api/v1/admin/settings/section/premium');
const premiumData = premiumResponse.data || {};
const result: any = {
oauth2: securityData.oauth2 || {},
saml2: securityData.saml2 || {},
mail: mailData || {},
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false
};
// Merge pending blocks from all three endpoints
const pendingBlock: any = {};
if (securityData._pending?.oauth2) {
pendingBlock.oauth2 = securityData._pending.oauth2;
}
if (securityData._pending?.saml2) {
pendingBlock.saml2 = securityData._pending.saml2;
}
if (mailData._pending) {
pendingBlock.mail = mailData._pending;
}
if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) {
pendingBlock.ssoAutoLogin = premiumData._pending.proFeatures.ssoAutoLogin;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
}
return result;
},
saveTransformer: () => {
// This section doesn't have a global save button
// Individual providers save through their own handlers
return {
sectionData: {},
deltaSettings: {}
};
}
});
useEffect(() => {
fetchSettings();
}, []);
const isProviderConfigured = (provider: Provider): boolean => {
if (provider.id === 'saml2') {
return settings.saml2?.enabled === true;
}
if (provider.id === 'smtp') {
return settings.mail?.enabled === true;
}
if (provider.id === 'oauth2-generic') {
return settings.oauth2?.enabled === true;
}
// Check if specific OAuth2 provider is configured (has clientId)
const providerSettings = settings.oauth2?.client?.[provider.id];
return !!(providerSettings?.clientId);
};
const getProviderSettings = (provider: Provider): Record<string, any> => {
if (provider.id === 'saml2') {
return settings.saml2 || {};
}
if (provider.id === 'smtp') {
return settings.mail || {};
}
if (provider.id === 'oauth2-generic') {
// Generic OAuth2 settings are at the root oauth2 level
return {
enabled: settings.oauth2?.enabled,
provider: settings.oauth2?.provider,
issuer: settings.oauth2?.issuer,
clientId: settings.oauth2?.clientId,
clientSecret: settings.oauth2?.clientSecret,
scopes: settings.oauth2?.scopes,
useAsUsername: settings.oauth2?.useAsUsername,
autoCreateUser: settings.oauth2?.autoCreateUser,
blockRegistration: settings.oauth2?.blockRegistration,
};
}
// Specific OAuth2 provider settings
return settings.oauth2?.client?.[provider.id] || {};
};
const handleProviderSave = async (provider: Provider, providerSettings: Record<string, any>) => {
try {
if (provider.id === 'smtp') {
// Mail settings use a different endpoint
const response = await apiClient.put('/api/v1/admin/settings/section/mail', providerSettings);
if (response.status === 200) {
await fetchSettings(); // Refresh settings
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saveSuccess', 'Settings saved successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to save');
}
} else {
// OAuth2/SAML2 use delta settings
const deltaSettings: Record<string, any> = {};
if (provider.id === 'saml2') {
// SAML2 settings
Object.keys(providerSettings).forEach((key) => {
deltaSettings[`security.saml2.${key}`] = providerSettings[key];
});
} else if (provider.id === 'oauth2-generic') {
// Generic OAuth2 settings at root level
Object.keys(providerSettings).forEach((key) => {
deltaSettings[`security.oauth2.${key}`] = providerSettings[key];
});
} else {
// Specific OAuth2 provider (google, github, keycloak)
Object.keys(providerSettings).forEach((key) => {
deltaSettings[`security.oauth2.client.${provider.id}.${key}`] = providerSettings[key];
});
}
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
if (response.status === 200) {
await fetchSettings(); // Refresh settings
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saveSuccess', 'Settings saved successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to save');
}
}
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
const handleProviderDisconnect = async (provider: Provider) => {
try {
if (provider.id === 'smtp') {
// Mail settings use a different endpoint
const response = await apiClient.put('/api/v1/admin/settings/section/mail', { enabled: false });
if (response.status === 200) {
await fetchSettings();
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.connections.disconnected', 'Provider disconnected successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to disconnect');
}
} else {
const deltaSettings: Record<string, any> = {};
if (provider.id === 'saml2') {
deltaSettings['security.saml2.enabled'] = false;
} else if (provider.id === 'oauth2-generic') {
deltaSettings['security.oauth2.enabled'] = false;
} else {
// Clear all fields for specific OAuth2 provider
provider.fields.forEach((field) => {
deltaSettings[`security.oauth2.client.${provider.id}.${field.key}`] = '';
});
}
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
if (response.status === 200) {
await fetchSettings();
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.connections.disconnected', 'Provider disconnected successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to disconnect');
}
}
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.connections.disconnectError', 'Failed to disconnect provider'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
const handleSSOAutoLoginSave = async () => {
try {
const deltaSettings = {
'premium.proFeatures.ssoAutoLogin': settings.ssoAutoLogin
};
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
if (response.status === 200) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saveSuccess', 'Settings saved successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to save');
}
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
const linkedProviders = ALL_PROVIDERS.filter((p) => isProviderConfigured(p));
const availableProviders = ALL_PROVIDERS.filter((p) => !isProviderConfigured(p));
return (
<Stack gap="xl">
{/* Header */}
<div>
<Text fw={600} size="lg">
{t('admin.settings.connections.title', 'Connections')}
</Text>
<Text size="sm" c="dimmed">
{t(
'admin.settings.connections.description',
'Configure external authentication providers like OAuth2 and SAML.'
)}
</Text>
</div>
{/* SSO Auto Login - Premium Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.connections.ssoAutoLogin', 'SSO Auto Login')}</Text>
<Badge color="yellow" size="sm">PRO</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.ssoAutoLogin.enable', 'Enable SSO Auto Login')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.ssoAutoLogin.description', 'Automatically redirect to SSO login when authentication is required')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.ssoAutoLogin || false}
onChange={(e) => {
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
handleSSOAutoLoginSave();
}}
/>
<PendingBadge show={isFieldPending('ssoAutoLogin')} />
</Group>
</div>
</Stack>
</Paper>
{/* Linked Services Section - Only show if there are linked providers */}
{linkedProviders.length > 0 && (
<>
<div>
<Text fw={600} size="md" mb="md">
{t('admin.settings.connections.linkedServices', 'Linked Services')}
</Text>
<Stack gap="sm">
{linkedProviders.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isConfigured={true}
settings={getProviderSettings(provider)}
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
onDisconnect={() => handleProviderDisconnect(provider)}
/>
))}
</Stack>
</div>
{/* Divider between sections */}
{availableProviders.length > 0 && <Divider />}
</>
)}
{/* Unlinked Services Section */}
{availableProviders.length > 0 && (
<div>
<Text fw={600} size="md" mb="md">
{t('admin.settings.connections.unlinkedServices', 'Unlinked Services')}
</Text>
<Stack gap="sm">
{availableProviders.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isConfigured={false}
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
/>
))}
</Stack>
</div>
)}
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}

View File

@@ -0,0 +1,272 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, TextInput, PasswordInput, Select, Badge } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
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 DatabaseSettingsData {
enableCustomDatabase?: boolean;
customDatabaseUrl?: string;
username?: string;
password?: string;
type?: string;
hostName?: string;
port?: number;
name?: string;
}
export default function AdminDatabaseSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<DatabaseSettingsData>({
sectionName: 'database',
fetchTransformer: async () => {
const response = await apiClient.get('/api/v1/admin/settings/section/system');
const systemData = response.data || {};
// Extract datasource from system response and handle pending
const datasource = systemData.datasource || {
enableCustomDatabase: false,
customDatabaseUrl: '',
username: '',
password: '',
type: 'postgresql',
hostName: 'localhost',
port: 5432,
name: 'postgres'
};
// Map pending changes from system._pending.datasource to root level
const result: any = { ...datasource };
if (systemData._pending?.datasource) {
result._pending = systemData._pending.datasource;
}
return result;
},
saveTransformer: (settings) => {
// Convert flat settings to dot-notation for delta endpoint
const deltaSettings: Record<string, any> = {
'system.datasource.enableCustomDatabase': settings.enableCustomDatabase,
'system.datasource.customDatabaseUrl': settings.customDatabaseUrl,
'system.datasource.username': settings.username,
'system.datasource.password': settings.password,
'system.datasource.type': settings.type,
'system.datasource.hostName': settings.hostName,
'system.datasource.port': settings.port,
'system.datasource.name': settings.name
};
return {
sectionData: {},
deltaSettings
};
}
});
useEffect(() => {
fetchSettings();
}, []);
const handleSave = async () => {
try {
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="lg">{t('admin.settings.database.title', 'Database')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.database.description', 'Configure custom database connection settings for enterprise deployments.')}
</Text>
</div>
<Badge color="grape" size="lg">ENTERPRISE</Badge>
</Group>
</div>
{/* Database Configuration */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.database.configuration', 'Database Configuration')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.database.enableCustom', 'Enable Custom Database')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.database.enableCustom.description', 'Use your own custom database configuration instead of the default embedded database')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enableCustomDatabase || false}
onChange={(e) => setSettings({ ...settings, enableCustomDatabase: e.target.checked })}
/>
<PendingBadge show={isFieldPending('enableCustomDatabase')} />
</Group>
</div>
{settings.enableCustomDatabase && (
<>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.customUrl', 'Custom Database URL')}</span>
<PendingBadge show={isFieldPending('customDatabaseUrl')} />
</Group>
}
description={t('admin.settings.database.customUrl.description', 'Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.')}
value={settings.customDatabaseUrl || ''}
onChange={(e) => setSettings({ ...settings, customDatabaseUrl: e.target.value })}
placeholder="jdbc:postgresql://localhost:5432/postgres"
/>
</div>
<div>
<Select
label={
<Group gap="xs">
<span>{t('admin.settings.database.type', 'Database Type')}</span>
<PendingBadge show={isFieldPending('type')} />
</Group>
}
description={t('admin.settings.database.type.description', 'Type of database (not used if custom URL is provided)')}
value={settings.type || 'postgresql'}
onChange={(value) => setSettings({ ...settings, type: value || 'postgresql' })}
data={[
{ value: 'postgresql', label: 'PostgreSQL' },
{ value: 'h2', label: 'H2' },
{ value: 'mysql', label: 'MySQL' },
{ value: 'mariadb', label: 'MariaDB' }
]}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.hostName', 'Host Name')}</span>
<PendingBadge show={isFieldPending('hostName')} />
</Group>
}
description={t('admin.settings.database.hostName.description', 'Database server hostname (not used if custom URL is provided)')}
value={settings.hostName || ''}
onChange={(e) => setSettings({ ...settings, hostName: e.target.value })}
placeholder="localhost"
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.port', 'Port')}</span>
<PendingBadge show={isFieldPending('port')} />
</Group>
}
description={t('admin.settings.database.port.description', 'Database server port (not used if custom URL is provided)')}
value={settings.port || 5432}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
max={65535}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.name', 'Database Name')}</span>
<PendingBadge show={isFieldPending('name')} />
</Group>
}
description={t('admin.settings.database.name.description', 'Name of the database (not used if custom URL is provided)')}
value={settings.name || ''}
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
placeholder="postgres"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.username', 'Username')}</span>
<PendingBadge show={isFieldPending('username')} />
</Group>
}
description={t('admin.settings.database.username.description', 'Database authentication username')}
value={settings.username || ''}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
placeholder="postgres"
/>
</div>
<div>
<PasswordInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.password', 'Password')}</span>
<PendingBadge show={isFieldPending('password')} />
</Group>
}
description={t('admin.settings.database.password.description', 'Database authentication password')}
value={settings.password || ''}
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
placeholder="••••••••"
/>
</div>
</>
)}
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}

View File

@@ -0,0 +1,176 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Stack, Paper, Text, Loader, Group, MultiSelect } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
interface EndpointsSettingsData {
toRemove?: string[];
groupsToRemove?: string[];
}
export default function AdminEndpointsSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<EndpointsSettingsData>({
sectionName: 'endpoints',
});
useEffect(() => {
fetchSettings();
}, []);
const handleSave = async () => {
try {
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
// Common endpoint examples
const commonEndpoints = [
'img-to-pdf',
'pdf-to-img',
'merge-pdfs',
'split-pdf',
'rotate-pdf',
'compress-pdf',
'extract-images',
'extract-image-scans',
'add-watermark',
'remove-watermark',
'add-password',
'remove-password',
'change-permissions',
'ocr-pdf',
'pdf-to-pdfa',
'html-to-pdf',
'url-to-pdf',
'markdown-to-pdf',
'get-info-on-pdf',
'extract-pdf-metadata',
'pdf-to-single-page',
'crop',
'auto-split-pdf',
'sanitize-pdf',
'add-page-numbers',
'auto-rename',
'scale-pages',
'repair',
'flatten',
'remove-blanks',
'compare-pdfs'
];
// Common endpoint groups
const commonGroups = [
'Conversion',
'Security',
'Other',
'Organize',
'LibreOffice',
'CLI',
'Python',
'OpenCV'
];
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.endpoints.title', 'API Endpoints')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.endpoints.description', 'Control which API endpoints and endpoint groups are available.')}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.endpoints.management', 'Endpoint Management')}</Text>
<div>
<MultiSelect
label={
<Group gap="xs">
<span>{t('admin.settings.endpoints.toRemove', 'Disabled Endpoints')}</span>
<PendingBadge show={isFieldPending('toRemove')} />
</Group>
}
description={t('admin.settings.endpoints.toRemove.description', 'Select individual endpoints to disable')}
value={settings.toRemove || []}
onChange={(value) => setSettings({ ...settings, toRemove: value })}
data={commonEndpoints.map(endpoint => ({ value: endpoint, label: endpoint }))}
searchable
clearable
placeholder="Select endpoints to disable"
comboboxProps={{ zIndex: 1400 }}
/>
</div>
<div>
<MultiSelect
label={
<Group gap="xs">
<span>{t('admin.settings.endpoints.groupsToRemove', 'Disabled Endpoint Groups')}</span>
<PendingBadge show={isFieldPending('groupsToRemove')} />
</Group>
}
description={t('admin.settings.endpoints.groupsToRemove.description', 'Select endpoint groups to disable')}
value={settings.groupsToRemove || []}
onChange={(value) => setSettings({ ...settings, groupsToRemove: value })}
data={commonGroups.map(group => ({ value: group, label: group }))}
searchable
clearable
placeholder="Select groups to disable"
comboboxProps={{ zIndex: 1400 }}
/>
</div>
<Paper bg="var(--mantine-color-blue-light)" p="sm" radius="sm">
<Text size="xs" c="dimmed">
{t('admin.settings.endpoints.note', 'Note: Disabling endpoints restricts API access but does not remove UI components. Restart required for changes to take effect.')}
</Text>
</Paper>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}

View File

@@ -0,0 +1,209 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Badge } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
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 FeaturesSettingsData {
serverCertificate?: {
enabled?: boolean;
organizationName?: string;
validity?: number;
regenerateOnStartup?: boolean;
};
}
export default function AdminFeaturesSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<FeaturesSettingsData>({
sectionName: 'features',
fetchTransformer: async () => {
const systemResponse = await apiClient.get('/api/v1/admin/settings/section/system');
const systemData = systemResponse.data || {};
const result: any = {
serverCertificate: systemData.serverCertificate || {
enabled: true,
organizationName: 'Stirling-PDF',
validity: 365,
regenerateOnStartup: false
}
};
// Map pending changes from system._pending.serverCertificate
if (systemData._pending?.serverCertificate) {
result._pending = { serverCertificate: systemData._pending.serverCertificate };
}
return result;
},
saveTransformer: (settings) => {
const deltaSettings: Record<string, any> = {};
if (settings.serverCertificate) {
deltaSettings['system.serverCertificate.enabled'] = settings.serverCertificate.enabled;
deltaSettings['system.serverCertificate.organizationName'] = settings.serverCertificate.organizationName;
deltaSettings['system.serverCertificate.validity'] = settings.serverCertificate.validity;
deltaSettings['system.serverCertificate.regenerateOnStartup'] = settings.serverCertificate.regenerateOnStartup;
}
return {
sectionData: {},
deltaSettings
};
}
});
useEffect(() => {
fetchSettings();
}, []);
const handleSave = async () => {
try {
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.features.title', 'Features')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.features.description', 'Configure optional features and functionality.')}
</Text>
</div>
{/* Server Certificate - Pro Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.features.serverCertificate', 'Server Certificate')}</Text>
<Badge color="blue" size="sm">PRO</Badge>
</Group>
<Text size="xs" c="dimmed">
{t('admin.settings.features.serverCertificate.description', 'Configure server-side certificate generation for "Sign with Stirling-PDF" functionality')}
</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.enabled', 'Enable Server Certificate')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.features.serverCertificate.enabled.description', 'Enable server-side certificate for "Sign with Stirling-PDF" option')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.enabled ?? true}
onChange={(e) => setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked }
})}
/>
<PendingBadge show={isFieldPending('serverCertificate.enabled')} />
</Group>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.features.serverCertificate.organizationName', 'Organization Name')}</span>
<PendingBadge show={isFieldPending('serverCertificate.organizationName')} />
</Group>
}
description={t('admin.settings.features.serverCertificate.organizationName.description', 'Organization name for generated certificates')}
value={settings.serverCertificate?.organizationName || 'Stirling-PDF'}
onChange={(e) => setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value }
})}
placeholder="Stirling-PDF"
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.features.serverCertificate.validity', 'Certificate Validity (days)')}</span>
<PendingBadge show={isFieldPending('serverCertificate.validity')} />
</Group>
}
description={t('admin.settings.features.serverCertificate.validity.description', 'Number of days the certificate will be valid')}
value={settings.serverCertificate?.validity ?? 365}
onChange={(value) => setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, validity: Number(value) }
})}
min={1}
max={3650}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.regenerateOnStartup', 'Regenerate on Startup')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.features.serverCertificate.regenerateOnStartup.description', 'Generate new certificate on each application startup')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.regenerateOnStartup ?? false}
onChange={(e) => setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked }
})}
/>
<PendingBadge show={isFieldPending('serverCertificate.regenerateOnStartup')} />
</Group>
</div>
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}

View File

@@ -0,0 +1,526 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
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 GeneralSettingsData {
ui: {
appNameNavbar?: string;
languages?: string[];
};
system: {
defaultLocale?: string;
showUpdate?: boolean;
showUpdateOnlyAdmin?: boolean;
customHTMLFiles?: boolean;
fileUploadLimit?: string;
};
customPaths?: {
pipeline?: {
watchedFoldersDir?: string;
finishedFoldersDir?: string;
};
operations?: {
weasyprint?: string;
unoconvert?: string;
};
};
customMetadata?: {
autoUpdateMetadata?: boolean;
author?: string;
creator?: string;
producer?: string;
};
}
export default function AdminGeneralSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<GeneralSettingsData>({
sectionName: 'general',
fetchTransformer: async () => {
const [uiResponse, systemResponse, premiumResponse] = await Promise.all([
apiClient.get('/api/v1/admin/settings/section/ui'),
apiClient.get('/api/v1/admin/settings/section/system'),
apiClient.get('/api/v1/admin/settings/section/premium')
]);
const ui = uiResponse.data || {};
const system = systemResponse.data || {};
const premium = premiumResponse.data || {};
const result: any = {
ui,
system,
customPaths: system.customPaths || {
pipeline: {
watchedFoldersDir: '',
finishedFoldersDir: ''
},
operations: {
weasyprint: '',
unoconvert: ''
}
},
customMetadata: premium.proFeatures?.customMetadata || {
autoUpdateMetadata: false,
author: '',
creator: '',
producer: ''
}
};
// Merge pending blocks from all three endpoints
const pendingBlock: any = {};
if (ui._pending) {
pendingBlock.ui = ui._pending;
}
if (system._pending) {
pendingBlock.system = system._pending;
}
if (system._pending?.customPaths) {
pendingBlock.customPaths = system._pending.customPaths;
}
if (premium._pending?.proFeatures?.customMetadata) {
pendingBlock.customMetadata = premium._pending.proFeatures.customMetadata;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
}
return result;
},
saveTransformer: (settings) => {
const deltaSettings: Record<string, any> = {
// UI settings
'ui.appNameNavbar': settings.ui.appNameNavbar,
'ui.languages': settings.ui.languages,
// System settings
'system.defaultLocale': settings.system.defaultLocale,
'system.showUpdate': settings.system.showUpdate,
'system.showUpdateOnlyAdmin': settings.system.showUpdateOnlyAdmin,
'system.customHTMLFiles': settings.system.customHTMLFiles,
'system.fileUploadLimit': settings.system.fileUploadLimit,
// Premium custom metadata
'premium.proFeatures.customMetadata.autoUpdateMetadata': settings.customMetadata?.autoUpdateMetadata,
'premium.proFeatures.customMetadata.author': settings.customMetadata?.author,
'premium.proFeatures.customMetadata.creator': settings.customMetadata?.creator,
'premium.proFeatures.customMetadata.producer': settings.customMetadata?.producer
};
if (settings.customPaths) {
deltaSettings['system.customPaths.pipeline.watchedFoldersDir'] = settings.customPaths.pipeline?.watchedFoldersDir;
deltaSettings['system.customPaths.pipeline.finishedFoldersDir'] = settings.customPaths.pipeline?.finishedFoldersDir;
deltaSettings['system.customPaths.operations.weasyprint'] = settings.customPaths.operations?.weasyprint;
deltaSettings['system.customPaths.operations.unoconvert'] = settings.customPaths.operations?.unoconvert;
}
return {
sectionData: {},
deltaSettings
};
}
});
useEffect(() => {
fetchSettings();
}, []);
const handleSave = async () => {
try {
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.general.description', 'Configure general application settings including branding and default behaviour.')}
</Text>
</div>
{/* System Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.general.system', 'System')}</Text>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.appNameNavbar', 'Navbar Brand')}</span>
<PendingBadge show={isFieldPending('ui.appNameNavbar')} />
</Group>
}
description={t('admin.settings.general.appNameNavbar.description', 'The name displayed in the navigation bar')}
value={settings.ui.appNameNavbar || ''}
onChange={(e) => setSettings({ ...settings, ui: { ...settings.ui, appNameNavbar: e.target.value } })}
placeholder="Stirling PDF"
/>
</div>
<div>
<MultiSelect
label={
<Group gap="xs">
<span>{t('admin.settings.general.languages', 'Available Languages')}</span>
<PendingBadge show={isFieldPending('ui.languages')} />
</Group>
}
description={t('admin.settings.general.languages.description', 'Limit which languages are available (empty = all languages)')}
value={settings.ui.languages || []}
onChange={(value) => setSettings({ ...settings, ui: { ...settings.ui, languages: value } })}
data={[
{ value: 'de_DE', label: 'Deutsch' },
{ value: 'es_ES', label: 'Español' },
{ value: 'fr_FR', label: 'Français' },
{ value: 'it_IT', label: 'Italiano' },
{ value: 'pl_PL', label: 'Polski' },
{ value: 'pt_BR', label: 'Português (Brasil)' },
{ value: 'ru_RU', label: 'Русский' },
{ value: 'zh_CN', label: '简体中文' },
{ value: 'ja_JP', label: '日本語' },
{ value: 'ko_KR', label: '한국어' },
]}
searchable
clearable
placeholder="Select languages"
comboboxProps={{ zIndex: 1400 }}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.defaultLocale', 'Default Locale')}</span>
<PendingBadge show={isFieldPending('system.defaultLocale')} />
</Group>
}
description={t('admin.settings.general.defaultLocale.description', 'The default language for new users (e.g., en_US, es_ES)')}
value={settings.system.defaultLocale || ''}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, defaultLocale: e.target.value } })}
placeholder="en_US"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.fileUploadLimit', 'File Upload Limit')}</span>
<PendingBadge show={isFieldPending('system.fileUploadLimit')} />
</Group>
}
description={t('admin.settings.general.fileUploadLimit.description', 'Maximum file upload size (e.g., 100MB, 1GB)')}
value={settings.system.fileUploadLimit || ''}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, fileUploadLimit: e.target.value } })}
placeholder="100MB"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdate', 'Show Update Notifications')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.showUpdate.description', 'Display notifications when a new version is available')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.system.showUpdate || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdate: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('system.showUpdate')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdateOnlyAdmin', 'Show Updates to Admins Only')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.showUpdateOnlyAdmin.description', 'Restrict update notifications to admin users only')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.system.showUpdateOnlyAdmin || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdateOnlyAdmin: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('system.showUpdateOnlyAdmin')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.customHTMLFiles', 'Custom HTML Files')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.customHTMLFiles.description', 'Allow serving custom HTML files from the customFiles directory')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.system.customHTMLFiles || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, customHTMLFiles: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('system.customHTMLFiles')} />
</Group>
</div>
</Stack>
</Paper>
{/* Custom Metadata - Premium Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.general.customMetadata', 'Custom Metadata')}</Text>
<Badge color="yellow" size="sm">PRO</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.customMetadata.autoUpdate', 'Auto Update Metadata')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.customMetadata.autoUpdate.description', 'Automatically update PDF metadata on all processed documents')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.customMetadata?.autoUpdateMetadata || false}
onChange={(e) => setSettings({
...settings,
customMetadata: {
...settings.customMetadata,
autoUpdateMetadata: e.target.checked
}
})}
/>
<PendingBadge show={isFieldPending('customMetadata.autoUpdateMetadata')} />
</Group>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customMetadata.author', 'Default Author')}</span>
<PendingBadge show={isFieldPending('customMetadata.author')} />
</Group>
}
description={t('admin.settings.general.customMetadata.author.description', 'Default author for PDF metadata (e.g., username)')}
value={settings.customMetadata?.author || ''}
onChange={(e) => setSettings({
...settings,
customMetadata: {
...settings.customMetadata,
author: e.target.value
}
})}
placeholder="username"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customMetadata.creator', 'Default Creator')}</span>
<PendingBadge show={isFieldPending('customMetadata.creator')} />
</Group>
}
description={t('admin.settings.general.customMetadata.creator.description', 'Default creator for PDF metadata')}
value={settings.customMetadata?.creator || ''}
onChange={(e) => setSettings({
...settings,
customMetadata: {
...settings.customMetadata,
creator: e.target.value
}
})}
placeholder="Stirling-PDF"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customMetadata.producer', 'Default Producer')}</span>
<PendingBadge show={isFieldPending('customMetadata.producer')} />
</Group>
}
description={t('admin.settings.general.customMetadata.producer.description', 'Default producer for PDF metadata')}
value={settings.customMetadata?.producer || ''}
onChange={(e) => setSettings({
...settings,
customMetadata: {
...settings.customMetadata,
producer: e.target.value
}
})}
placeholder="Stirling-PDF"
/>
</div>
</Stack>
</Paper>
{/* Custom Paths */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.general.customPaths', 'Custom Paths')}</Text>
<Text size="xs" c="dimmed">
{t('admin.settings.general.customPaths.description', 'Configure custom file system paths for pipeline processing and external tools')}
</Text>
</div>
<Text fw={500} size="sm" mt="xs">{t('admin.settings.general.customPaths.pipeline', 'Pipeline Directories')}</Text>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customPaths.pipeline.watchedFoldersDir', 'Watched Folders Directory')}</span>
<PendingBadge show={isFieldPending('customPaths.pipeline.watchedFoldersDir')} />
</Group>
}
description={t('admin.settings.general.customPaths.pipeline.watchedFoldersDir.description', 'Directory where pipeline monitors for incoming PDFs (leave empty for default: /pipeline/watchedFolders)')}
value={settings.customPaths?.pipeline?.watchedFoldersDir || ''}
onChange={(e) => setSettings({
...settings,
customPaths: {
...settings.customPaths,
pipeline: {
...settings.customPaths?.pipeline,
watchedFoldersDir: e.target.value
}
}
})}
placeholder="/pipeline/watchedFolders"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customPaths.pipeline.finishedFoldersDir', 'Finished Folders Directory')}</span>
<PendingBadge show={isFieldPending('customPaths.pipeline.finishedFoldersDir')} />
</Group>
}
description={t('admin.settings.general.customPaths.pipeline.finishedFoldersDir.description', 'Directory where processed PDFs are outputted (leave empty for default: /pipeline/finishedFolders)')}
value={settings.customPaths?.pipeline?.finishedFoldersDir || ''}
onChange={(e) => setSettings({
...settings,
customPaths: {
...settings.customPaths,
pipeline: {
...settings.customPaths?.pipeline,
finishedFoldersDir: e.target.value
}
}
})}
placeholder="/pipeline/finishedFolders"
/>
</div>
<Text fw={500} size="sm" mt="md">{t('admin.settings.general.customPaths.operations', 'External Tool Paths')}</Text>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customPaths.operations.weasyprint', 'WeasyPrint Executable')}</span>
<PendingBadge show={isFieldPending('customPaths.operations.weasyprint')} />
</Group>
}
description={t('admin.settings.general.customPaths.operations.weasyprint.description', 'Path to WeasyPrint executable for HTML to PDF conversion (leave empty for default: /opt/venv/bin/weasyprint)')}
value={settings.customPaths?.operations?.weasyprint || ''}
onChange={(e) => setSettings({
...settings,
customPaths: {
...settings.customPaths,
operations: {
...settings.customPaths?.operations,
weasyprint: e.target.value
}
}
})}
placeholder="/opt/venv/bin/weasyprint"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customPaths.operations.unoconvert', 'Unoconvert Executable')}</span>
<PendingBadge show={isFieldPending('customPaths.operations.unoconvert')} />
</Group>
}
description={t('admin.settings.general.customPaths.operations.unoconvert.description', 'Path to LibreOffice unoconvert for document conversions (leave empty for default: /opt/venv/bin/unoconvert)')}
value={settings.customPaths?.operations?.unoconvert || ''}
onChange={(e) => setSettings({
...settings,
customPaths: {
...settings.customPaths,
operations: {
...settings.customPaths?.operations,
unoconvert: e.target.value
}
}
})}
placeholder="/opt/venv/bin/unoconvert"
/>
</div>
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}

View File

@@ -0,0 +1,177 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, Button, Stack, Paper, Text, Loader, Group, Alert } from '@mantine/core';
import WarningIcon from '@mui/icons-material/Warning';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
interface LegalSettingsData {
termsAndConditions?: string;
privacyPolicy?: string;
accessibilityStatement?: string;
cookiePolicy?: string;
impressum?: string;
}
export default function AdminLegalSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<LegalSettingsData>({
sectionName: 'legal',
});
useEffect(() => {
fetchSettings();
}, []);
const handleSave = async () => {
try {
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.legal.title', 'Legal Documents')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.legal.description', 'Configure links to legal documents and policies.')}
</Text>
</div>
{/* Legal Disclaimer */}
<Alert
icon={<WarningIcon style={{ fontSize: 18 }} />}
title={t('admin.settings.legal.disclaimer.title', 'Legal Responsibility Warning')}
color="yellow"
variant="light"
>
<Text size="sm">
{t(
'admin.settings.legal.disclaimer.message',
'By customizing these legal documents, you assume full responsibility for ensuring compliance with all applicable laws and regulations, including but not limited to GDPR and other EU data protection requirements. Only modify these settings if: (1) you are operating a personal/private instance, (2) you are outside EU jurisdiction and understand your local legal obligations, or (3) you have obtained proper legal counsel and accept sole responsibility for all user data and legal compliance. Stirling-PDF and its developers assume no liability for your legal obligations.'
)}
</Text>
</Alert>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.termsAndConditions', 'Terms and Conditions')}</span>
<PendingBadge show={isFieldPending('termsAndConditions')} />
</Group>
}
description={t('admin.settings.legal.termsAndConditions.description', 'URL or filename to terms and conditions')}
value={settings.termsAndConditions || ''}
onChange={(e) => setSettings({ ...settings, termsAndConditions: e.target.value })}
placeholder="https://example.com/terms"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.privacyPolicy', 'Privacy Policy')}</span>
<PendingBadge show={isFieldPending('privacyPolicy')} />
</Group>
}
description={t('admin.settings.legal.privacyPolicy.description', 'URL or filename to privacy policy')}
value={settings.privacyPolicy || ''}
onChange={(e) => setSettings({ ...settings, privacyPolicy: e.target.value })}
placeholder="https://example.com/privacy"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.accessibilityStatement', 'Accessibility Statement')}</span>
<PendingBadge show={isFieldPending('accessibilityStatement')} />
</Group>
}
description={t('admin.settings.legal.accessibilityStatement.description', 'URL or filename to accessibility statement')}
value={settings.accessibilityStatement || ''}
onChange={(e) => setSettings({ ...settings, accessibilityStatement: e.target.value })}
placeholder="https://example.com/accessibility"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.cookiePolicy', 'Cookie Policy')}</span>
<PendingBadge show={isFieldPending('cookiePolicy')} />
</Group>
}
description={t('admin.settings.legal.cookiePolicy.description', 'URL or filename to cookie policy')}
value={settings.cookiePolicy || ''}
onChange={(e) => setSettings({ ...settings, cookiePolicy: e.target.value })}
placeholder="https://example.com/cookies"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.impressum', 'Impressum')}</span>
<PendingBadge show={isFieldPending('impressum')} />
</Group>
}
description={t('admin.settings.legal.impressum.description', 'URL or filename to impressum (required in some jurisdictions)')}
value={settings.impressum || ''}
onChange={(e) => setSettings({ ...settings, impressum: e.target.value })}
placeholder="https://example.com/impressum"
/>
</div>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}

View File

@@ -0,0 +1,195 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, PasswordInput } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
interface MailSettingsData {
enabled?: boolean;
enableInvites?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
from?: string;
}
export default function AdminMailSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<MailSettingsData>({
sectionName: 'mail',
});
useEffect(() => {
fetchSettings();
}, []);
const handleSave = async () => {
try {
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.mail.title', 'Mail Configuration')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.mail.description', 'Configure SMTP settings for email notifications.')}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">{t('admin.settings.mail.enabled', 'Enable Mail')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.mail.enabled.description', 'Enable email notifications and SMTP functionality')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
/>
<PendingBadge show={isFieldPending('enabled')} />
</Group>
</Group>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">{t('admin.settings.mail.enableInvites', 'Enable Email Invites')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.mail.enableInvites.description', 'Allow admins to invite users via email with auto-generated passwords')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enableInvites || false}
onChange={(e) => setSettings({ ...settings, enableInvites: e.target.checked })}
disabled={!settings.enabled}
/>
<PendingBadge show={isFieldPending('enableInvites')} />
</Group>
</Group>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.host', 'SMTP Host')}</span>
<PendingBadge show={isFieldPending('host')} />
</Group>
}
description={t('admin.settings.mail.host.description', 'SMTP server hostname')}
value={settings.host || ''}
onChange={(e) => setSettings({ ...settings, host: e.target.value })}
placeholder="smtp.example.com"
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.port', 'SMTP Port')}</span>
<PendingBadge show={isFieldPending('port')} />
</Group>
}
description={t('admin.settings.mail.port.description', 'SMTP server port (typically 587 for TLS, 465 for SSL)')}
value={settings.port || 587}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
max={65535}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.username', 'SMTP Username')}</span>
<PendingBadge show={isFieldPending('username')} />
</Group>
}
description={t('admin.settings.mail.username.description', 'SMTP authentication username')}
value={settings.username || ''}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
/>
</div>
<div>
<PasswordInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.password', 'SMTP Password')}</span>
<PendingBadge show={isFieldPending('password')} />
</Group>
}
description={t('admin.settings.mail.password.description', 'SMTP authentication password')}
value={settings.password || ''}
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.from', 'From Address')}</span>
<PendingBadge show={isFieldPending('from')} />
</Group>
}
description={t('admin.settings.mail.from.description', 'Email address to use as sender')}
value={settings.from || ''}
onChange={(e) => setSettings({ ...settings, from: e.target.value })}
placeholder="noreply@example.com"
/>
</div>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}

View File

@@ -0,0 +1,136 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, Alert } from '@mantine/core';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
interface PremiumSettingsData {
key?: string;
enabled?: boolean;
}
export default function AdminPremiumSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<PremiumSettingsData>({
sectionName: 'premium',
});
useEffect(() => {
fetchSettings();
}, []);
const handleSave = async () => {
try {
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.premium.title', 'Premium & Enterprise')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.premium.description', 'Configure your premium or enterprise license key.')}
</Text>
</div>
{/* Notice about moved features */}
<Alert
variant="light"
color="blue"
title={t('admin.settings.premium.movedFeatures.title', 'Premium Features Distributed')}
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<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>
</Alert>
{/* License Configuration */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.premium.license', 'License Configuration')}</Text>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.premium.key', 'License Key')}</span>
<PendingBadge show={isFieldPending('key')} />
</Group>
}
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key')}
value={settings.key || ''}
onChange={(e) => setSettings({ ...settings, key: e.target.value })}
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.premium.enabled', 'Enable Premium Features')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.premium.enabled.description', 'Enable license key checks for pro/enterprise features')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
/>
<PendingBadge show={isFieldPending('enabled')} />
</Group>
</div>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}

View File

@@ -0,0 +1,189 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Switch, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
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 PrivacySettingsData {
enableAnalytics?: boolean;
googleVisibility?: boolean;
metricsEnabled?: boolean;
}
export default function AdminPrivacySection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<PrivacySettingsData>({
sectionName: 'privacy',
fetchTransformer: async () => {
const [metricsResponse, systemResponse] = await Promise.all([
apiClient.get('/api/v1/admin/settings/section/metrics'),
apiClient.get('/api/v1/admin/settings/section/system')
]);
const metrics = metricsResponse.data;
const system = systemResponse.data;
const result: any = {
enableAnalytics: system.enableAnalytics || false,
googleVisibility: system.googlevisibility || false,
metricsEnabled: metrics.enabled || false
};
// Merge pending blocks from both endpoints
const pendingBlock: any = {};
if (system._pending?.enableAnalytics !== undefined) {
pendingBlock.enableAnalytics = system._pending.enableAnalytics;
}
if (system._pending?.googlevisibility !== undefined) {
pendingBlock.googleVisibility = system._pending.googlevisibility;
}
if (metrics._pending?.enabled !== undefined) {
pendingBlock.metricsEnabled = metrics._pending.enabled;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
}
return result;
},
saveTransformer: (settings) => {
const deltaSettings = {
'system.enableAnalytics': settings.enableAnalytics,
'system.googlevisibility': settings.googleVisibility,
'metrics.enabled': settings.metricsEnabled
};
return {
sectionData: {},
deltaSettings
};
}
});
useEffect(() => {
fetchSettings();
}, []);
const handleSave = async () => {
try {
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.privacy.title', 'Privacy')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.privacy.description', 'Configure privacy and data collection settings.')}
</Text>
</div>
{/* Analytics & Tracking */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.privacy.analytics', 'Analytics & Tracking')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.enableAnalytics', 'Enable Analytics')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.enableAnalytics.description', 'Collect anonymous usage analytics to help improve the application')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enableAnalytics || false}
onChange={(e) => setSettings({ ...settings, enableAnalytics: e.target.checked })}
/>
<PendingBadge show={isFieldPending('enableAnalytics')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.metricsEnabled', 'Enable Metrics')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.metricsEnabled.description', 'Enable collection of performance and usage metrics')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.metricsEnabled || false}
onChange={(e) => setSettings({ ...settings, metricsEnabled: e.target.checked })}
/>
<PendingBadge show={isFieldPending('metricsEnabled')} />
</Group>
</div>
</Stack>
</Paper>
{/* Search Engine Visibility */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.privacy.searchEngine', 'Search Engine Visibility')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.googleVisibility', 'Google Visibility')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.googleVisibility.description', 'Allow search engines to index this application')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.googleVisibility || false}
onChange={(e) => setSettings({ ...settings, googleVisibility: e.target.checked })}
/>
<PendingBadge show={isFieldPending('googleVisibility')} />
</Group>
</div>
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}

View File

@@ -0,0 +1,618 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Select, Alert, Badge, Accordion, Textarea } from '@mantine/core';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
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 SecuritySettingsData {
enableLogin?: boolean;
csrfDisabled?: boolean;
loginMethod?: string;
loginAttemptCount?: number;
loginResetTimeMinutes?: number;
jwt?: {
persistence?: boolean;
enableKeyRotation?: boolean;
enableKeyCleanup?: boolean;
keyRetentionDays?: number;
secureCookie?: boolean;
};
audit?: {
enabled?: boolean;
level?: number;
retentionDays?: number;
};
html?: {
urlSecurity?: {
enabled?: boolean;
level?: string;
allowedDomains?: string[];
blockedDomains?: string[];
internalTlds?: string[];
blockPrivateNetworks?: boolean;
blockLocalhost?: boolean;
blockLinkLocal?: boolean;
blockCloudMetadata?: boolean;
};
};
}
export default function AdminSecuritySection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<SecuritySettingsData>({
sectionName: 'security',
fetchTransformer: async () => {
const [securityResponse, premiumResponse, systemResponse] = await Promise.all([
apiClient.get('/api/v1/admin/settings/section/security'),
apiClient.get('/api/v1/admin/settings/section/premium'),
apiClient.get('/api/v1/admin/settings/section/system')
]);
const securityData = securityResponse.data || {};
const premiumData = premiumResponse.data || {};
const systemData = systemResponse.data || {};
const { _pending: securityPending, ...securityActive } = securityData;
const { _pending: premiumPending, ...premiumActive } = premiumData;
const { _pending: systemPending, ...systemActive } = systemData;
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
}
}
};
// Merge all _pending blocks
const mergedPending: any = {};
if (securityPending) {
Object.assign(mergedPending, securityPending);
}
if (premiumPending?.enterpriseFeatures?.audit) {
mergedPending.audit = premiumPending.enterpriseFeatures.audit;
}
if (systemPending?.html) {
mergedPending.html = systemPending.html;
}
if (Object.keys(mergedPending).length > 0) {
combined._pending = mergedPending;
}
return combined;
},
saveTransformer: (settings) => {
const { audit, html, ...securitySettings } = settings;
const deltaSettings: Record<string, any> = {
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
'premium.enterpriseFeatures.audit.level': audit?.level,
'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays
};
if (html?.urlSecurity) {
deltaSettings['system.html.urlSecurity.enabled'] = html.urlSecurity.enabled;
deltaSettings['system.html.urlSecurity.level'] = html.urlSecurity.level;
deltaSettings['system.html.urlSecurity.allowedDomains'] = html.urlSecurity.allowedDomains;
deltaSettings['system.html.urlSecurity.blockedDomains'] = html.urlSecurity.blockedDomains;
deltaSettings['system.html.urlSecurity.internalTlds'] = html.urlSecurity.internalTlds;
deltaSettings['system.html.urlSecurity.blockPrivateNetworks'] = html.urlSecurity.blockPrivateNetworks;
deltaSettings['system.html.urlSecurity.blockLocalhost'] = html.urlSecurity.blockLocalhost;
deltaSettings['system.html.urlSecurity.blockLinkLocal'] = html.urlSecurity.blockLinkLocal;
deltaSettings['system.html.urlSecurity.blockCloudMetadata'] = html.urlSecurity.blockCloudMetadata;
}
return {
sectionData: securitySettings,
deltaSettings
};
}
});
useEffect(() => {
fetchSettings();
}, []);
const handleSave = async () => {
try {
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.security.title', 'Security')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.security.description', 'Configure authentication, login behaviour, and security policies.')}
</Text>
</div>
{/* Authentication Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.authentication', 'Authentication')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.enableLogin', 'Enable Login')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.enableLogin.description', 'Require users to log in before accessing the application')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enableLogin || false}
onChange={(e) => setSettings({ ...settings, enableLogin: e.target.checked })}
/>
<PendingBadge show={isFieldPending('enableLogin')} />
</Group>
</div>
<div>
<Select
label={t('admin.settings.security.loginMethod', 'Login Method')}
description={t('admin.settings.security.loginMethod.description', 'The authentication method to use for user login')}
value={settings.loginMethod || 'all'}
onChange={(value) => setSettings({ ...settings, loginMethod: value || 'all' })}
data={[
{ value: 'all', label: t('admin.settings.security.loginMethod.all', 'All Methods') },
{ value: 'normal', label: t('admin.settings.security.loginMethod.normal', 'Username/Password Only') },
{ value: 'oauth2', label: t('admin.settings.security.loginMethod.oauth2', 'OAuth2 Only') },
{ value: 'saml2', label: t('admin.settings.security.loginMethod.saml2', 'SAML2 Only') },
]}
comboboxProps={{ zIndex: 1400 }}
/>
{isFieldPending('loginMethod') && (
<Group mt="xs">
<PendingBadge show={true} />
</Group>
)}
</div>
<div>
<NumberInput
label={t('admin.settings.security.loginAttemptCount', 'Login Attempt Limit')}
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) })}
min={0}
max={100}
/>
</div>
<div>
<NumberInput
label={t('admin.settings.security.loginResetTimeMinutes', 'Login Reset Time (minutes)')}
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) })}
min={0}
max={1440}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.csrfDisabled', 'Disable CSRF Protection')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.csrfDisabled.description', 'Disable Cross-Site Request Forgery protection (not recommended)')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.csrfDisabled || false}
onChange={(e) => setSettings({ ...settings, csrfDisabled: e.target.checked })}
/>
<PendingBadge show={isFieldPending('csrfDisabled')} />
</Group>
</div>
</Stack>
</Paper>
{/* SSO/SAML Notice */}
<Alert
variant="light"
color="blue"
title={t('admin.settings.security.ssoNotice.title', 'Looking for SSO/SAML settings?')}
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.security.ssoNotice.message', 'OAuth2 and SAML2 authentication providers have been moved to the Connections menu for easier management.')}
</Text>
</Alert>
{/* JWT Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.jwt', 'JWT Configuration')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.persistence', 'Enable Key Persistence')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.persistence.description', 'Store JWT keys persistently (required for multi-instance deployments)')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.persistence || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, persistence: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('jwt.persistence')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyRotation', 'Enable Key Rotation')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{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 } })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyCleanup', 'Enable Key Cleanup')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{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 } })}
/>
</div>
<div>
<NumberInput
label={t('admin.settings.security.jwt.keyRetentionDays', 'Key Retention Days')}
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) } })}
min={1}
max={365}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.secureCookie', 'Secure Cookie')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.secureCookie.description', 'Require HTTPS for JWT cookies (recommended for production)')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.secureCookie || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, secureCookie: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('jwt.secureCookie')} />
</Group>
</div>
</Stack>
</Paper>
{/* Audit Logging - Enterprise Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.security.audit', 'Audit Logging')}</Text>
<Badge color="grape" size="sm">ENTERPRISE</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.audit.enabled', 'Enable Audit Logging')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.audit.enabled.description', 'Track user actions and system events for compliance and security monitoring')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.audit?.enabled || false}
onChange={(e) => setSettings({ ...settings, audit: { ...settings.audit, enabled: e.target.checked } })}
/>
<PendingBadge show={isFieldPending('audit.enabled')} />
</Group>
</div>
<div>
<NumberInput
label={t('admin.settings.security.audit.level', 'Audit Level')}
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) } })}
min={0}
max={3}
/>
</div>
<div>
<NumberInput
label={t('admin.settings.security.audit.retentionDays', 'Audit Retention (days)')}
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) } })}
min={1}
max={3650}
/>
</div>
</Stack>
</Paper>
{/* HTML URL Security */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.htmlUrlSecurity', 'HTML URL Security')}</Text>
<Text size="xs" c="dimmed">
{t('admin.settings.security.htmlUrlSecurity.description', 'Configure URL access restrictions for HTML processing to prevent SSRF attacks')}
</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.enabled', 'Enable URL Security')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.enabled.description', 'Enable URL security restrictions for HTML to PDF conversions')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.enabled || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, enabled: e.target.checked }
}
})}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.enabled')} />
</Group>
</div>
<div>
<Select
label={t('admin.settings.security.htmlUrlSecurity.level', 'Security Level')}
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({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, level: value || 'MEDIUM' }
}
})}
data={[
{ value: 'MAX', label: t('admin.settings.security.htmlUrlSecurity.level.max', 'Maximum (Whitelist Only)') },
{ value: 'MEDIUM', label: t('admin.settings.security.htmlUrlSecurity.level.medium', 'Medium (Block Internal)') },
{ value: 'OFF', label: t('admin.settings.security.htmlUrlSecurity.level.off', 'Off (No Restrictions)') },
]}
comboboxProps={{ zIndex: 1400 }}
/>
</div>
<Accordion variant="separated">
<Accordion.Item value="advanced">
<Accordion.Control>{t('admin.settings.security.htmlUrlSecurity.advanced', 'Advanced Settings')}</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
{/* Allowed Domains */}
<div>
<Textarea
label={t('admin.settings.security.htmlUrlSecurity.allowedDomains', 'Allowed Domains (Whitelist)')}
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({
...settings,
html: {
...settings.html,
urlSecurity: {
...settings.html?.urlSecurity,
allowedDomains: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
})}
placeholder="cdn.example.com&#10;images.google.com"
minRows={3}
autosize
/>
</div>
{/* Blocked Domains */}
<div>
<Textarea
label={t('admin.settings.security.htmlUrlSecurity.blockedDomains', 'Blocked Domains (Blacklist)')}
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({
...settings,
html: {
...settings.html,
urlSecurity: {
...settings.html?.urlSecurity,
blockedDomains: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
})}
placeholder="malicious.com&#10;evil.org"
minRows={3}
autosize
/>
</div>
{/* Internal TLDs */}
<div>
<Textarea
label={t('admin.settings.security.htmlUrlSecurity.internalTlds', 'Internal TLDs')}
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({
...settings,
html: {
...settings.html,
urlSecurity: {
...settings.html?.urlSecurity,
internalTlds: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
})}
placeholder=".local&#10;.internal&#10;.corp&#10;.home"
minRows={3}
autosize
/>
</div>
{/* Network Blocking Options */}
<Text fw={600} size="sm" mt="md">{t('admin.settings.security.htmlUrlSecurity.networkBlocking', 'Network Blocking')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks', 'Block Private Networks')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{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 }
}
})}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLocalhost', 'Block Localhost')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{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 }
}
})}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal', 'Block Link-Local Addresses')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{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 }
}
})}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata', 'Block Cloud Metadata Endpoints')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{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 }
}
})}
/>
</div>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
);
}

View File

@@ -35,7 +35,6 @@ const Overview: React.FC = () => {
};
const basicConfig = config ? {
appName: config.appName,
appNameNavbar: config.appNameNavbar,
baseUrl: config.baseUrl,
contextPath: config.contextPath,

View File

@@ -0,0 +1,673 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Text,
Button,
TextInput,
Table,
ActionIcon,
Menu,
Badge,
Loader,
Group,
Modal,
Select,
Paper,
Checkbox,
Textarea,
SegmentedControl,
Tooltip,
CloseButton,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { userManagementService, User } from '@app/services/userManagementService';
import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
export default function PeopleSection() {
const { t } = useTranslation();
const { config } = useAppConfig();
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [inviteModalOpened, setInviteModalOpened] = useState(false);
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<'email' | 'direct'>('direct');
// Form state for direct invite
const [inviteForm, setInviteForm] = useState({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
forceChange: false,
});
// Form state for email invite
const [emailInviteForm, setEmailInviteForm] = useState({
emails: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
});
// Form state for edit user modal
const [editForm, setEditForm] = useState({
role: 'ROLE_USER',
teamId: undefined as number | undefined,
});
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
if (config) {
console.log('[PeopleSection] Email invites enabled:', config.enableEmailInvites);
}
}, [config]);
const fetchData = async () => {
try {
setLoading(true);
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
// Enrich users with session data
const enrichedUsers = adminData.users.map(user => ({
...user,
isActive: adminData.userSessions[user.username] || false,
lastRequest: adminData.userLastRequest[user.username] || undefined,
}));
setUsers(enrichedUsers);
setTeams(teamsData);
} catch (error) {
console.error('Failed to fetch people data:', error);
alert({ alertType: 'error', title: 'Failed to load people data' });
} finally {
setLoading(false);
}
};
const handleInviteUser = async () => {
if (!inviteForm.username || !inviteForm.password) {
alert({ alertType: 'error', title: t('workspace.people.addMember.usernameRequired') });
return;
}
try {
setProcessing(true);
await userManagementService.createUser({
username: inviteForm.username,
password: inviteForm.password,
role: inviteForm.role,
teamId: inviteForm.teamId,
authType: 'password',
forceChange: inviteForm.forceChange,
});
alert({ alertType: 'success', title: t('workspace.people.addMember.success') });
setInviteModalOpened(false);
setInviteForm({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined,
forceChange: false,
});
fetchData();
} catch (error: any) {
console.error('Failed to create user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.addMember.error');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleEmailInvite = async () => {
if (!emailInviteForm.emails.trim()) {
alert({ alertType: 'error', title: t('workspace.people.emailInvite.emailsRequired', 'At least one email address is required') });
return;
}
try {
setProcessing(true);
const response = await userManagementService.inviteUsers({
emails: emailInviteForm.emails,
role: emailInviteForm.role,
teamId: emailInviteForm.teamId,
});
if (response.successCount > 0) {
alert({
alertType: 'success',
title: t('workspace.people.emailInvite.success', `${response.successCount} user(s) invited successfully`)
});
if (response.failureCount > 0 && response.errors) {
alert({
alertType: 'warning',
title: t('workspace.people.emailInvite.partialSuccess', 'Some invites failed'),
body: response.errors
});
}
setInviteModalOpened(false);
setEmailInviteForm({
emails: '',
role: 'ROLE_USER',
teamId: undefined,
});
fetchData();
} else {
alert({
alertType: 'error',
title: t('workspace.people.emailInvite.allFailed', 'Failed to invite users'),
body: response.errors || response.error
});
}
} catch (error: any) {
console.error('Failed to invite users:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.emailInvite.error', 'Failed to send invites');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleUpdateUserRole = async () => {
if (!selectedUser) return;
try {
setProcessing(true);
await userManagementService.updateUserRole({
username: selectedUser.username,
role: editForm.role,
teamId: editForm.teamId,
});
alert({ alertType: 'success', title: t('workspace.people.editMember.success') });
closeEditModal();
fetchData();
} catch (error: any) {
console.error('Failed to update user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.editMember.error');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleToggleEnabled = async (user: User) => {
try {
await userManagementService.toggleUserEnabled(user.username, !user.enabled);
alert({ alertType: 'success', title: t('workspace.people.toggleEnabled.success') });
fetchData();
} catch (error: any) {
console.error('Failed to toggle user status:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.toggleEnabled.error');
alert({ alertType: 'error', title: errorMessage });
}
};
const handleDeleteUser = async (user: User) => {
const confirmMessage = t('workspace.people.confirmDelete', 'Are you sure you want to delete this user? This action cannot be undone.');
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
try {
await userManagementService.deleteUser(user.username);
alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') });
fetchData();
} catch (error: any) {
console.error('Failed to delete user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.deleteUserError', 'Failed to delete user');
alert({ alertType: 'error', title: errorMessage });
}
};
const openEditModal = (user: User) => {
setSelectedUser(user);
setEditForm({
role: user.roleName,
teamId: user.team?.id,
});
setEditUserModalOpened(true);
};
const closeEditModal = () => {
setEditUserModalOpened(false);
setSelectedUser(null);
setEditForm({
role: 'ROLE_USER',
teamId: undefined,
});
};
const filteredUsers = users.filter((user) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase())
);
const roleOptions = [
{
value: 'ROLE_ADMIN',
label: t('workspace.people.admin'),
description: t('workspace.people.roleDescriptions.admin', 'Can manage settings and invite members, with full administrative access.'),
icon: 'admin-panel-settings'
},
{
value: 'ROLE_USER',
label: t('workspace.people.member'),
description: t('workspace.people.roleDescriptions.member', 'Can view and edit shared files, but cannot manage workspace settings or members.'),
icon: 'person'
},
];
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 }}>
<Text size="sm" fw={500}>{option.label}</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
{option.description}
</Text>
</div>
</Group>
);
const teamOptions = teams.map((team) => ({
value: team.id.toString(),
label: team.name,
}));
if (loading) {
return (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('workspace.people.loading', 'Loading people...')}
</Text>
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">
{t('workspace.people.title')}
</Text>
<Text size="sm" c="dimmed">
{t('workspace.people.description')}
</Text>
</div>
{/* Header Actions */}
<Group justify="space-between">
<TextInput
placeholder={t('workspace.people.searchMembers')}
leftSection={<LocalIcon icon="search" width="1rem" height="1rem" />}
value={searchQuery}
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>
</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.team')}</Table.Th>
<Table.Th>{t('workspace.people.status')}</Table.Th>
<Table.Th style={{ width: 50 }}></Table.Th>
</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
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'var(--mantine-color-green-6)',
flexShrink: 0,
}}
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>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{/* Info icon with tooltip */}
<Tooltip
label={
<div>
<Text size="xs" fw={500}>Authentication: {user.authenticationType || 'Unknown'}</Text>
<Text size="xs">
Last Activity: {user.lastRequest
? new Date(user.lastRequest).toLocaleString()
: 'Never'}
</Text>
</div>
}
multiline
w={220}
position="left"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
>
<ActionIcon variant="subtle" color="gray" size="sm">
<LocalIcon icon="info" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
{/* Actions menu */}
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Item onClick={() => openEditModal(user)}>{t('workspace.people.editRole')}</Menu.Item>
<Menu.Item
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />}
onClick={() => handleToggleEnabled(user)}
>
{user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')}
</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />} onClick={() => handleDeleteUser(user)}>
{t('workspace.people.deleteUser')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Paper>
{/* Add Member Modal */}
<Modal
opened={inviteModalOpened}
onClose={() => setInviteModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setInviteModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.people.inviteMembers', 'Invite Members')}
</Text>
{inviteMode === 'email' && (
<Text size="sm" c="dimmed" ta="center" px="md">
{t('workspace.people.inviteMembers.subtitle', 'Type or paste in emails below, separated by commas. Your workspace will be billed by members.')}
</Text>
)}
</Stack>
{/* Mode Toggle */}
<Tooltip
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
disabled={!!config?.enableEmailInvites}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
>
<div>
<SegmentedControl
value={inviteMode}
onChange={(value) => setInviteMode(value as 'email' | 'direct')}
data={[
{
label: t('workspace.people.inviteMode.username', 'Username'),
value: 'direct',
},
{
label: t('workspace.people.inviteMode.email', 'Email'),
value: 'email',
disabled: !config?.enableEmailInvites,
},
]}
fullWidth
/>
</div>
</Tooltip>
{/* Email Mode */}
{inviteMode === 'email' && config?.enableEmailInvites && (
<>
<Textarea
label={t('workspace.people.emailInvite.emails', 'Email Addresses')}
placeholder={t('workspace.people.emailInvite.emailsPlaceholder', 'user1@example.com, user2@example.com')}
value={emailInviteForm.emails}
onChange={(e) => setEmailInviteForm({ ...emailInviteForm, emails: e.currentTarget.value })}
minRows={3}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={emailInviteForm.role}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, 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={emailInviteForm.teamId?.toString()}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
</>
)}
{/* Direct/Username Mode */}
{inviteMode === 'direct' && (
<>
<TextInput
label={t('workspace.people.addMember.username')}
placeholder={t('workspace.people.addMember.usernamePlaceholder')}
value={inviteForm.username}
onChange={(e) => setInviteForm({ ...inviteForm, username: e.currentTarget.value })}
required
/>
<TextInput
label={t('workspace.people.addMember.password')}
type="password"
placeholder={t('workspace.people.addMember.passwordPlaceholder')}
value={inviteForm.password}
onChange={(e) => setInviteForm({ ...inviteForm, password: e.currentTarget.value })}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteForm.role}
onChange={(value) => setInviteForm({ ...inviteForm, 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={inviteForm.teamId?.toString()}
onChange={(value) => setInviteForm({ ...inviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Checkbox
label={t('workspace.people.addMember.forcePasswordChange', 'Force password change on first login')}
checked={inviteForm.forceChange}
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
/>
</>
)}
{/* Action Button */}
<Button
onClick={inviteMode === 'email' ? handleEmailInvite : handleInviteUser}
loading={processing}
fullWidth
size="md"
mt="md"
>
{inviteMode === 'email'
? t('workspace.people.emailInvite.submit', 'Send Invites')
: t('workspace.people.addMember.submit')}
</Button>
</Stack>
</div>
</Modal>
{/* Edit User Modal */}
<Modal
opened={editUserModalOpened}
onClose={closeEditModal}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={closeEditModal}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="edit" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.people.editMember.title')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.people.editMember.editing')} <strong>{selectedUser?.username}</strong>
</Text>
</Stack>
<Select
label={t('workspace.people.editMember.role')}
data={roleOptions}
value={editForm.role}
onChange={(value) => setEditForm({ ...editForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.editMember.team')}
placeholder={t('workspace.people.editMember.teamPlaceholder')}
data={teamOptions}
value={editForm.teamId?.toString()}
onChange={(value) => setEditForm({ ...editForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button onClick={handleUpdateUserRole} loading={processing} fullWidth size="md" mt="md">
{t('workspace.people.editMember.submit')}
</Button>
</Stack>
</div>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,190 @@
import { useState } from 'react';
import { Paper, Group, Text, Button, Collapse, Stack, TextInput, Textarea, Switch, PasswordInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Provider, ProviderField } from '@app/components/shared/config/configSections/providerDefinitions';
interface ProviderCardProps {
provider: Provider;
isConfigured: boolean;
settings?: Record<string, any>;
onSave?: (settings: Record<string, any>) => void;
onDisconnect?: () => void;
}
export default function ProviderCard({
provider,
isConfigured,
settings = {},
onSave,
onDisconnect,
}: ProviderCardProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const [localSettings, setLocalSettings] = useState<Record<string, any>>(settings);
// Initialize local settings with defaults when opening an unconfigured provider
const handleConnectToggle = () => {
if (!isConfigured && !expanded) {
// First time opening an unconfigured provider - initialize with defaults
const defaultSettings: Record<string, any> = {};
provider.fields.forEach((field) => {
if (field.defaultValue !== undefined) {
defaultSettings[field.key] = field.defaultValue;
}
});
setLocalSettings(defaultSettings);
}
setExpanded(!expanded);
};
const handleFieldChange = (key: string, value: any) => {
setLocalSettings((prev) => ({ ...prev, [key]: value }));
};
const handleSave = () => {
if (onSave) {
onSave(localSettings);
}
setExpanded(false);
};
const renderField = (field: ProviderField) => {
const value = localSettings[field.key] ?? field.defaultValue ?? '';
switch (field.type) {
case 'switch':
return (
<div key={field.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{field.label}</Text>
<Text size="xs" c="dimmed" mt={4}>{field.description}</Text>
</div>
<Switch
checked={value || false}
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
/>
</div>
);
case 'password':
return (
<PasswordInput
key={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
);
case 'textarea':
return (
<Textarea
key={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
);
default:
return (
<TextInput
key={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
);
}
};
const renderProviderIcon = () => {
// If icon starts with '/', it's a path to an SVG file
if (provider.icon.startsWith('/')) {
return (
<img
src={provider.icon}
alt={provider.name}
style={{ width: '1.5rem', height: '1.5rem' }}
/>
);
}
// Otherwise use LocalIcon for iconify icons
return <LocalIcon icon={provider.icon} width="1.5rem" height="1.5rem" />;
};
return (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{/* Provider Header */}
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
{renderProviderIcon()}
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={600} size="sm">{provider.name}</Text>
<Text size="xs" c="dimmed" truncate>{provider.scope}</Text>
</div>
</Group>
<Group gap="xs" wrap="nowrap">
<Button
variant={isConfigured ? "subtle" : "filled"}
size="xs"
onClick={isConfigured ? () => setExpanded(!expanded) : handleConnectToggle}
rightSection={
expanded ? (
<LocalIcon
icon="close-rounded"
width="1rem"
height="1rem"
/>
) : (isConfigured ? (
<LocalIcon
icon="expand-more-rounded"
width="1rem"
height="1rem"
/>
) : undefined)
}
>
{isConfigured
? (expanded ? t('admin.close', 'Close') : t('admin.expand', 'Expand'))
: (expanded ? t('admin.close', 'Close') : t('admin.settings.connections.connect', 'Connect'))
}
</Button>
</Group>
</Group>
{/* Expandable Settings */}
<Collapse in={expanded}>
<Stack gap="md" mt="xs">
{provider.fields.map((field) => renderField(field))}
<Group justify="flex-end" mt="sm">
{onDisconnect && (
<Button
variant="outline"
color="red"
size="sm"
onClick={onDisconnect}
>
{t('admin.settings.connections.disconnect', 'Disconnect')}
</Button>
)}
<Button size="sm" onClick={handleSave}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Collapse>
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,476 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Text,
Button,
Table,
ActionIcon,
Badge,
Loader,
Group,
Modal,
Select,
Paper,
CloseButton,
Tooltip,
Menu,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { teamService, Team } from '@app/services/teamService';
import { User, userManagementService } from '@app/services/userManagementService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface TeamDetailsSectionProps {
teamId: number;
onBack: () => void;
}
export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectionProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [team, setTeam] = useState<Team | null>(null);
const [teamUsers, setTeamUsers] = useState<User[]>([]);
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
const [allTeams, setAllTeams] = useState<Team[]>([]);
const [userLastRequest, setUserLastRequest] = useState<Record<string, number>>({});
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [changeTeamModalOpened, setChangeTeamModalOpened] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [selectedTeamId, setSelectedTeamId] = useState<string>('');
const [processing, setProcessing] = useState(false);
useEffect(() => {
fetchTeamDetails();
fetchAllTeams();
}, [teamId]);
const fetchTeamDetails = async () => {
try {
setLoading(true);
const data = await teamService.getTeamDetails(teamId);
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 || {});
} catch (error) {
console.error('Failed to fetch team details:', error);
alert({ alertType: 'error', title: 'Failed to load team details' });
onBack();
} finally {
setLoading(false);
}
};
const fetchAllTeams = async () => {
try {
const teams = await teamService.getTeams();
setAllTeams(teams);
} catch (error) {
console.error('Failed to fetch teams:', error);
}
};
const handleAddMember = async () => {
if (!selectedUserId) {
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.selectUserRequired', 'Please select a user') });
return;
}
try {
setProcessing(true);
await teamService.addUserToTeam(teamId, parseInt(selectedUserId));
alert({ alertType: 'success', title: t('workspace.teams.addMemberToTeam.success', 'User added to team successfully') });
setAddMemberModalOpened(false);
setSelectedUserId('');
fetchTeamDetails();
} catch (error: any) {
console.error('Failed to add member:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.addMemberToTeam.error', 'Failed to add user to team');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleRemoveMember = async (user: User) => {
if (!window.confirm(t('workspace.teams.confirmRemove', `Remove ${user.username} from this team?`))) {
return;
}
try {
setProcessing(true);
// Find the Default team ID
const defaultTeam = allTeams.find(t => t.name === 'Default');
if (!defaultTeam) {
throw new Error('Default team not found');
}
// Move user to Default team by updating their role with the Default team ID
await teamService.moveUserToTeam(user.username, user.rolesAsString || 'ROLE_USER', defaultTeam.id);
alert({ alertType: 'success', title: t('workspace.teams.removeMemberSuccess', 'User removed from team') });
fetchTeamDetails();
} catch (error: any) {
console.error('Failed to remove member:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.removeMemberError', 'Failed to remove user from team');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleDeleteUser = async (user: User) => {
const confirmMessage = t('workspace.people.confirmDelete', 'Are you sure you want to delete this user? This action cannot be undone.');
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
try {
setProcessing(true);
await userManagementService.deleteUser(user.username);
alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') });
fetchTeamDetails();
} catch (error: any) {
console.error('Failed to delete user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.deleteUserError', 'Failed to delete user');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const openChangeTeamModal = (user: User) => {
setSelectedUser(user);
setSelectedTeamId(user.team?.id?.toString() || '');
setChangeTeamModalOpened(true);
};
const handleChangeTeam = async () => {
if (!selectedUser || !selectedTeamId) {
alert({ alertType: 'error', title: t('workspace.teams.changeTeam.selectTeamRequired', 'Please select a team') });
return;
}
try {
setProcessing(true);
await teamService.moveUserToTeam(selectedUser.username, selectedUser.rolesAsString || 'ROLE_USER', parseInt(selectedTeamId));
alert({ alertType: 'success', title: t('workspace.teams.changeTeam.success', 'Team changed successfully') });
setChangeTeamModalOpened(false);
setSelectedUser(null);
setSelectedTeamId('');
fetchTeamDetails();
} catch (error: any) {
console.error('Failed to change team:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.changeTeam.error', 'Failed to change team');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
if (loading) {
return (
<Stack align="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('workspace.teams.loadingDetails', 'Loading team details...')}
</Text>
</Stack>
);
}
if (!team) {
return (
<Stack align="center" py="xl">
<Text size="sm" c="red">
{t('workspace.teams.teamNotFound', 'Team not found')}
</Text>
<Button variant="light" onClick={onBack}>
{t('workspace.teams.backToTeams', 'Back to Teams')}
</Button>
</Stack>
);
}
return (
<Stack gap="lg">
{/* Header with back button */}
<Group>
<ActionIcon variant="subtle" onClick={onBack}>
<LocalIcon icon="arrow-back" width="1.2rem" height="1.2rem" />
</ActionIcon>
<div style={{ flex: 1 }}>
<Text fw={600} size="lg">
{team.name}
</Text>
<Text size="sm" c="dimmed">
{t('workspace.teams.memberCount', { count: teamUsers.length })} {teamUsers.length === 1 ? 'member' : 'members'}
</Text>
</div>
</Group>
{/* Add Member Button */}
<Group justify="flex-end">
<Button
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
onClick={() => setAddMemberModalOpened(true)}
disabled={team.name === 'Internal'}
>
{t('workspace.teams.addMember')}
</Button>
</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.Tbody>
{teamUsers.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={4}>
<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>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{/* Info icon with tooltip */}
<Tooltip
label={
<div>
<Text size="xs" fw={500}>
Authentication: {user.authenticationType || 'Unknown'}
</Text>
<Text size="xs">
Last Activity:{' '}
{userLastRequest[user.username]
? new Date(userLastRequest[user.username]).toLocaleString()
: 'Never'}
</Text>
</div>
}
multiline
w={220}
position="left"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
>
<ActionIcon variant="subtle" color="gray" size="sm">
<LocalIcon icon="info" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
{/* Actions menu */}
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Item
leftSection={<LocalIcon icon="swap-horiz" width="1rem" height="1rem" />}
onClick={() => openChangeTeamModal(user)}
disabled={processing || team.name === 'Internal'}
>
{t('workspace.teams.changeTeam.label', 'Change Team')}
</Menu.Item>
{team.name !== 'Internal' && team.name !== 'Default' && (
<Menu.Item
leftSection={<LocalIcon icon="person-remove" width="1rem" height="1rem" />}
onClick={() => handleRemoveMember(user)}
disabled={processing}
>
{t('workspace.teams.removeMember', 'Remove from team')}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteUser(user)}
disabled={processing || team.name === 'Internal'}
>
{t('workspace.people.deleteUser', 'Delete User')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Paper>
{/* Add Member Modal */}
<Modal
opened={addMemberModalOpened}
onClose={() => setAddMemberModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setAddMemberModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.teams.addMemberToTeam.title')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.teams.addMemberToTeam.addingTo')} <strong>{team.name}</strong>
</Text>
</Stack>
<Select
label={t('workspace.teams.addMemberToTeam.selectUser')}
placeholder={t('workspace.teams.addMemberToTeam.selectUserPlaceholder')}
data={availableUsers.map((user) => ({
value: user.id.toString(),
label: `${user.username}${user.team ? ` (${t('workspace.teams.addMemberToTeam.currentlyIn')} ${user.team.name})` : ''}`,
}))}
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || '')}
searchable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
{selectedUserId && availableUsers.find((u) => u.id.toString() === selectedUserId)?.team && (
<Text size="xs" c="orange">
{t('workspace.teams.addMemberToTeam.willBeMoved')}
</Text>
)}
<Button onClick={handleAddMember} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.addMemberToTeam.submit')}
</Button>
</Stack>
</div>
</Modal>
{/* Change Team Modal */}
<Modal
opened={changeTeamModalOpened}
onClose={() => setChangeTeamModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setChangeTeamModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="swap-horiz" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.teams.changeTeam.title', 'Change Team')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.teams.changeTeam.changing', 'Moving')} <strong>{selectedUser?.username}</strong>
</Text>
</Stack>
<Select
label={t('workspace.teams.changeTeam.selectTeam', 'Select Team')}
placeholder={t('workspace.teams.changeTeam.selectTeamPlaceholder', 'Choose a team')}
data={allTeams
.filter((t) => t.name !== 'Internal')
.map((team) => ({
value: team.id.toString(),
label: team.name,
}))}
value={selectedTeamId}
onChange={(value) => setSelectedTeamId(value || '')}
searchable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button onClick={handleChangeTeam} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.changeTeam.submit', 'Change Team')}
</Button>
</Stack>
</div>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,456 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Text,
Button,
TextInput,
Table,
ActionIcon,
Menu,
Badge,
Loader,
Group,
Modal,
Paper,
Select,
CloseButton,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { teamService, Team } from '@app/services/teamService';
import { userManagementService, User } from '@app/services/userManagementService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import TeamDetailsSection from '@app/components/shared/config/configSections/TeamDetailsSection';
export default function TeamsSection() {
const { t } = useTranslation();
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const [createModalOpened, setCreateModalOpened] = useState(false);
const [renameModalOpened, setRenameModalOpened] = useState(false);
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
const [processing, setProcessing] = useState(false);
const [viewingTeamId, setViewingTeamId] = useState<number | null>(null);
// Form states
const [newTeamName, setNewTeamName] = useState('');
const [renameTeamName, setRenameTeamName] = useState('');
const [selectedUserId, setSelectedUserId] = useState<string>('');
useEffect(() => {
fetchTeams();
}, []);
const fetchTeams = async () => {
try {
setLoading(true);
const teamsData = await teamService.getTeams();
setTeams(teamsData);
} catch (error) {
console.error('Failed to fetch teams:', error);
alert({ alertType: 'error', title: 'Failed to load teams' });
} finally {
setLoading(false);
}
};
const handleCreateTeam = async () => {
if (!newTeamName.trim()) {
alert({ alertType: 'error', title: t('workspace.teams.createTeam.nameRequired') });
return;
}
try {
setProcessing(true);
await teamService.createTeam(newTeamName);
alert({ alertType: 'success', title: t('workspace.teams.createTeam.success') });
setCreateModalOpened(false);
setNewTeamName('');
fetchTeams();
} catch (error: any) {
console.error('Failed to create team:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.createTeam.error');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleRenameTeam = async () => {
if (!selectedTeam || !renameTeamName.trim()) {
alert({ alertType: 'error', title: t('workspace.teams.renameTeam.nameRequired') });
return;
}
try {
setProcessing(true);
await teamService.renameTeam(selectedTeam.id, renameTeamName);
alert({ alertType: 'success', title: t('workspace.teams.renameTeam.success') });
setRenameModalOpened(false);
setSelectedTeam(null);
setRenameTeamName('');
fetchTeams();
} catch (error: any) {
console.error('Failed to rename team:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.renameTeam.error');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleDeleteTeam = async (team: Team) => {
if (team.name === 'Internal') {
alert({ alertType: 'error', title: t('workspace.teams.cannotDeleteInternal') });
return;
}
if (!confirm(t('workspace.teams.confirmDelete'))) {
return;
}
try {
await teamService.deleteTeam(team.id);
alert({ alertType: 'success', title: t('workspace.teams.deleteTeam.success') });
fetchTeams();
} catch (error: any) {
console.error('Failed to delete team:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.teams.deleteTeam.error');
alert({ alertType: 'error', title: errorMessage });
}
};
const openRenameModal = (team: Team) => {
if (team.name === 'Internal') {
alert({ alertType: 'error', title: t('workspace.teams.cannotRenameInternal') });
return;
}
setSelectedTeam(team);
setRenameTeamName(team.name);
setRenameModalOpened(true);
};
const openAddMemberModal = async (team: Team) => {
if (team.name === 'Internal') {
alert({ alertType: 'error', title: t('workspace.teams.cannotAddToInternal') });
return;
}
setSelectedTeam(team);
try {
// Fetch all users to show in dropdown
const adminData = await userManagementService.getUsers();
setAvailableUsers(adminData.users);
setAddMemberModalOpened(true);
} catch (error) {
console.error('Failed to fetch users:', error);
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.error') });
}
};
const handleAddMember = async () => {
if (!selectedTeam || !selectedUserId) {
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.userRequired') });
return;
}
try {
setProcessing(true);
await teamService.addUserToTeam(selectedTeam.id, parseInt(selectedUserId));
alert({ alertType: 'success', title: t('workspace.teams.addMemberToTeam.success') });
setAddMemberModalOpened(false);
setSelectedTeam(null);
setSelectedUserId('');
fetchTeams();
} catch (error) {
console.error('Failed to add member to team:', error);
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.error') });
} finally {
setProcessing(false);
}
};
// If viewing team details, render TeamDetailsSection
if (viewingTeamId !== null) {
return (
<TeamDetailsSection
teamId={viewingTeamId}
onBack={() => {
setViewingTeamId(null);
fetchTeams(); // Refresh teams list
}}
/>
);
}
if (loading) {
return (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('workspace.teams.loading', 'Loading teams...')}
</Text>
</Stack>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">
{t('workspace.teams.title')}
</Text>
<Text size="sm" c="dimmed">
{t('workspace.teams.description')}
</Text>
</div>
{/* Header Actions */}
<Group justify="flex-end">
<Button leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />} onClick={() => setCreateModalOpened(true)}>
{t('workspace.teams.createNewTeam')}
</Button>
</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.Tbody>
{teams.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3}>
<Text ta="center" c="dimmed" py="xl">
{t('workspace.teams.noTeamsFound')}
</Text>
</Table.Td>
</Table.Tr>
) : (
teams.map((team) => (
<Table.Tr
key={team.id}
style={{ cursor: 'pointer' }}
onClick={() => setViewingTeamId(team.id)}
>
<Table.Td>
<Group gap="xs">
<Text size="sm" fw={500}>
{team.name}
</Text>
{team.name === 'Internal' && (
<Badge size="xs" color="gray">
{t('workspace.teams.system')}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm">{team.userCount || 0}</Text>
</Table.Td>
<Table.Td onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Item leftSection={<LocalIcon icon="visibility" width="1rem" height="1rem" />} onClick={() => setViewingTeamId(team.id)}>
{t('workspace.teams.viewTeam', 'View Team')}
</Menu.Item>
<Menu.Item leftSection={<LocalIcon icon="group" width="1rem" height="1rem" />} onClick={() => openAddMemberModal(team)}>
{t('workspace.teams.addMember')}
</Menu.Item>
<Menu.Item leftSection={<LocalIcon icon="edit" width="1rem" height="1rem" />} onClick={() => openRenameModal(team)}>
{t('workspace.teams.renameTeamLabel')}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteTeam(team)}
disabled={team.name === 'Internal'}
>
{t('workspace.teams.deleteTeamLabel')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Paper>
{/* Create Team Modal */}
<Modal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setCreateModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="group-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.teams.createTeam.title')}
</Text>
</Stack>
<TextInput
label={t('workspace.teams.createTeam.teamName')}
placeholder={t('workspace.teams.createTeam.teamNamePlaceholder')}
value={newTeamName}
onChange={(e) => setNewTeamName(e.currentTarget.value)}
required
/>
<Button onClick={handleCreateTeam} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.createTeam.submit')}
</Button>
</Stack>
</div>
</Modal>
{/* Rename Team Modal */}
<Modal
opened={renameModalOpened}
onClose={() => setRenameModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setRenameModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="edit" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.teams.renameTeam.title')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.teams.renameTeam.renaming')} <strong>{selectedTeam?.name}</strong>
</Text>
</Stack>
<TextInput
label={t('workspace.teams.renameTeam.newTeamName')}
placeholder={t('workspace.teams.renameTeam.newTeamNamePlaceholder')}
value={renameTeamName}
onChange={(e) => setRenameTeamName(e.currentTarget.value)}
required
/>
<Button onClick={handleRenameTeam} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.renameTeam.submit')}
</Button>
</Stack>
</div>
</Modal>
{/* Add Member Modal */}
<Modal
opened={addMemberModalOpened}
onClose={() => setAddMemberModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<CloseButton
onClick={() => setAddMemberModalOpened(false)}
size="lg"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.teams.addMemberToTeam.title')}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.teams.addMemberToTeam.addingTo')} <strong>{selectedTeam?.name}</strong>
</Text>
</Stack>
<Select
label={t('workspace.teams.addMemberToTeam.selectUser')}
placeholder={t('workspace.teams.addMemberToTeam.selectUserPlaceholder')}
data={availableUsers.map((user) => ({
value: user.id.toString(),
label: `${user.username}${user.team ? ` (${t('workspace.teams.addMemberToTeam.currentlyIn')} ${user.team.name})` : ''}`,
}))}
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || '')}
searchable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
{selectedUserId && availableUsers.find((u) => u.id.toString() === selectedUserId)?.team && (
<Text size="xs" c="orange">
{t('workspace.teams.addMemberToTeam.willBeMoved')}
</Text>
)}
<Button onClick={handleAddMember} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.addMemberToTeam.submit')}
</Button>
</Stack>
</div>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,353 @@
export type ProviderType = 'oauth2' | 'saml2';
export interface ProviderField {
key: string;
type: 'text' | 'password' | 'switch' | 'textarea';
label: string;
description: string;
placeholder?: string;
defaultValue?: any;
}
export interface Provider {
id: string;
name: string;
icon: string;
type: ProviderType;
scope: string; // Summary of what this provider does
businessTier?: boolean; // Enterprise only
fields: ProviderField[];
}
export const OAUTH2_PROVIDERS: Provider[] = [
{
id: 'google',
name: 'Google',
icon: '/Login/google.svg',
type: 'oauth2',
scope: 'Sign-in authentication',
fields: [
{
key: 'clientId',
type: 'text',
label: 'Client ID',
description: 'The OAuth2 client ID from Google Cloud Console',
placeholder: 'your-client-id.apps.googleusercontent.com',
},
{
key: 'clientSecret',
type: 'password',
label: 'Client Secret',
description: 'The OAuth2 client secret from Google Cloud Console',
},
{
key: 'scopes',
type: 'text',
label: 'Scopes',
description: 'Comma-separated OAuth2 scopes',
defaultValue: 'email, profile',
},
{
key: 'useAsUsername',
type: 'text',
label: 'Use as Username',
description: 'Field to use as username (email, name, given_name, family_name)',
defaultValue: 'email',
},
],
},
{
id: 'github',
name: 'GitHub',
icon: '/Login/github.svg',
type: 'oauth2',
scope: 'Sign-in authentication',
fields: [
{
key: 'clientId',
type: 'text',
label: 'Client ID',
description: 'The OAuth2 client ID from GitHub Developer Settings',
},
{
key: 'clientSecret',
type: 'password',
label: 'Client Secret',
description: 'The OAuth2 client secret from GitHub Developer Settings',
},
{
key: 'scopes',
type: 'text',
label: 'Scopes',
description: 'Comma-separated OAuth2 scopes',
defaultValue: 'read:user',
},
{
key: 'useAsUsername',
type: 'text',
label: 'Use as Username',
description: 'Field to use as username (email, login, name)',
defaultValue: 'login',
},
],
},
{
id: 'keycloak',
name: 'Keycloak',
icon: 'key-rounded',
type: 'oauth2',
scope: 'SSO',
fields: [
{
key: 'issuer',
type: 'text',
label: 'Issuer URL',
description: "URL of the Keycloak realm's OpenID Connect Discovery endpoint",
placeholder: 'https://keycloak.example.com/realms/myrealm',
},
{
key: 'clientId',
type: 'text',
label: 'Client ID',
description: 'The OAuth2 client ID from Keycloak',
},
{
key: 'clientSecret',
type: 'password',
label: 'Client Secret',
description: 'The OAuth2 client secret from Keycloak',
},
{
key: 'scopes',
type: 'text',
label: 'Scopes',
description: 'Comma-separated OAuth2 scopes',
defaultValue: 'openid, profile, email',
},
{
key: 'useAsUsername',
type: 'text',
label: 'Use as Username',
description: 'Field to use as username (email, name, given_name, family_name, preferred_username)',
defaultValue: 'preferred_username',
},
],
},
];
export const GENERIC_OAUTH2_PROVIDER: Provider = {
id: 'oauth2-generic',
name: 'Generic OAuth2',
icon: 'link-rounded',
type: 'oauth2',
scope: 'SSO',
fields: [
{
key: 'enabled',
type: 'switch',
label: 'Enable Generic OAuth2',
description: 'Enable authentication using a custom OAuth2 provider',
defaultValue: false,
},
{
key: 'provider',
type: 'text',
label: 'Provider Name',
description: 'The name of your OAuth2 provider (e.g., Azure AD, Okta)',
placeholder: 'azure-ad',
},
{
key: 'issuer',
type: 'text',
label: 'Issuer URL',
description: 'Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration)',
placeholder: 'https://login.microsoftonline.com/{tenant-id}/v2.0',
},
{
key: 'clientId',
type: 'text',
label: 'Client ID',
description: 'The OAuth2 client ID from your provider',
},
{
key: 'clientSecret',
type: 'password',
label: 'Client Secret',
description: 'The OAuth2 client secret from your provider',
},
{
key: 'scopes',
type: 'text',
label: 'Scopes',
description: 'Comma-separated OAuth2 scopes',
defaultValue: 'openid, profile, email',
},
{
key: 'useAsUsername',
type: 'text',
label: 'Use as Username',
description: 'Field to use as username',
defaultValue: 'email',
},
{
key: 'autoCreateUser',
type: 'switch',
label: 'Auto Create Users',
description: 'Automatically create user accounts on first OAuth2 login',
defaultValue: true,
},
{
key: 'blockRegistration',
type: 'switch',
label: 'Block Registration',
description: 'Prevent new user registration via OAuth2',
defaultValue: false,
},
],
};
export const SMTP_PROVIDER: Provider = {
id: 'smtp',
name: 'SMTP Mail',
icon: 'mail-rounded',
type: 'oauth2', // Using oauth2 as the base type, but it's really just a generic provider
scope: 'Email Notifications',
fields: [
{
key: 'enabled',
type: 'switch',
label: 'Enable Mail',
description: 'Enable email notifications and SMTP functionality',
defaultValue: false,
},
{
key: 'host',
type: 'text',
label: 'SMTP Host',
description: 'The hostname or IP address of your SMTP server',
placeholder: 'smtp.example.com',
},
{
key: 'port',
type: 'text',
label: 'SMTP Port',
description: 'The port number for SMTP connection (typically 25, 465, or 587)',
placeholder: '587',
defaultValue: '587',
},
{
key: 'username',
type: 'text',
label: 'SMTP Username',
description: 'Username for SMTP authentication',
},
{
key: 'password',
type: 'password',
label: 'SMTP Password',
description: 'Password for SMTP authentication',
},
{
key: 'from',
type: 'text',
label: 'From Address',
description: 'The email address to use as the sender',
placeholder: 'noreply@example.com',
},
],
};
export const SAML2_PROVIDER: Provider = {
id: 'saml2',
name: 'SAML2',
icon: 'verified-user-rounded',
type: 'saml2',
scope: 'SSO',
businessTier: true,
fields: [
{
key: 'enabled',
type: 'switch',
label: 'Enable SAML2',
description: 'Enable SAML2 authentication (Enterprise only)',
defaultValue: false,
},
{
key: 'provider',
type: 'text',
label: 'Provider Name',
description: 'The name of your SAML2 provider',
},
{
key: 'registrationId',
type: 'text',
label: 'Registration ID',
description: 'The name of your Service Provider (SP) app name',
defaultValue: 'stirling',
},
{
key: 'idpMetadataUri',
type: 'text',
label: 'IDP Metadata URI',
description: 'The URI for your provider\'s metadata',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata',
},
{
key: 'idpSingleLoginUrl',
type: 'text',
label: 'IDP Single Login URL',
description: 'The URL for initiating SSO',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml',
},
{
key: 'idpSingleLogoutUrl',
type: 'text',
label: 'IDP Single Logout URL',
description: 'The URL for initiating SLO',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml',
},
{
key: 'idpIssuer',
type: 'text',
label: 'IDP Issuer',
description: 'The ID of your provider',
},
{
key: 'idpCert',
type: 'text',
label: 'IDP Certificate',
description: 'The certificate path (e.g., classpath:okta.cert)',
placeholder: 'classpath:okta.cert',
},
{
key: 'privateKey',
type: 'text',
label: 'Private Key',
description: 'Your private key path',
placeholder: 'classpath:saml-private-key.key',
},
{
key: 'spCert',
type: 'text',
label: 'SP Certificate',
description: 'Your signing certificate path',
placeholder: 'classpath:saml-public-cert.crt',
},
{
key: 'autoCreateUser',
type: 'switch',
label: 'Auto Create Users',
description: 'Automatically create user accounts on first SAML2 login',
defaultValue: true,
},
{
key: 'blockRegistration',
type: 'switch',
label: 'Block Registration',
description: 'Prevent new user registration via SAML2',
defaultValue: false,
},
],
};
export const ALL_PROVIDERS = [...OAUTH2_PROVIDERS, GENERIC_OAUTH2_PROVIDER, SAML2_PROVIDER, SMTP_PROVIDER];

View File

@@ -13,7 +13,17 @@ export type NavKey =
| 'requests'
| 'developer'
| 'api-keys'
| 'hotkeys';
| 'hotkeys'
| 'adminGeneral'
| 'adminSecurity'
| 'adminConnections'
| 'adminPrivacy'
| 'adminDatabase'
| 'adminAdvanced'
| 'adminLegal'
| 'adminPremium'
| 'adminFeatures'
| 'adminEndpoints';
// some of these are not used yet, but appear in figma designs

View File

@@ -0,0 +1,60 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { alert } from '@app/components/toast';
export function useRestartServer() {
const { t } = useTranslation();
const [restartModalOpened, setRestartModalOpened] = useState(false);
const showRestartModal = () => {
setRestartModalOpened(true);
};
const closeRestartModal = () => {
setRestartModalOpened(false);
};
const restartServer = async () => {
try {
setRestartModalOpened(false);
alert({
alertType: 'neutral',
title: t('admin.settings.restarting', 'Restarting Server'),
body: t(
'admin.settings.restartingMessage',
'The server is restarting. Please wait a moment...'
),
});
const response = await fetch('/api/v1/admin/settings/restart', {
method: 'POST',
});
if (response.ok) {
// Wait a moment then reload the page
setTimeout(() => {
window.location.reload();
}, 3000);
} else {
throw new Error('Failed to restart');
}
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t(
'admin.settings.restartError',
'Failed to restart server. Please restart manually.'
),
});
}
};
return {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
};
}

View File

@@ -1,7 +1,7 @@
/* Toast Container Styles */
.toast-container {
position: fixed;
z-index: 1200;
z-index: 10001; /* Always on top - must match Z_INDEX_TOAST in zIndex.ts */
display: flex;
gap: 12px;
pointer-events: none;

View File

@@ -5,11 +5,11 @@ export interface AppConfig {
baseUrl?: string;
contextPath?: string;
serverPort?: number;
appName?: string;
appNameNavbar?: string;
homeDescription?: string;
languages?: string[];
enableLogin?: boolean;
enableEmailInvites?: boolean;
isAdmin?: boolean;
enableAlphaFunctionality?: boolean;
enableAnalytics?: boolean | null;
enablePosthog?: boolean | null;

View File

@@ -0,0 +1,191 @@
import { useState } from 'react';
import apiClient from '@app/services/apiClient';
import { mergePendingSettings, isFieldPending, hasPendingChanges } from '@app/utils/settingsPendingHelper';
interface UseAdminSettingsOptions<T> {
sectionName: string;
/**
* Optional transformer to combine data from multiple endpoints.
* If not provided, uses the section response directly.
*/
fetchTransformer?: () => Promise<T>;
/**
* Optional transformer to split settings before saving.
* Returns an object with sectionData and optionally deltaSettings.
*/
saveTransformer?: (settings: T) => {
sectionData: any;
deltaSettings?: Record<string, any>;
};
}
interface UseAdminSettingsReturn<T> {
settings: T;
rawSettings: any;
loading: boolean;
saving: boolean;
setSettings: (settings: T) => void;
fetchSettings: () => Promise<void>;
saveSettings: () => Promise<void>;
isFieldPending: (fieldPath: string) => boolean;
hasPendingChanges: () => boolean;
}
/**
* Hook for managing admin settings with automatic pending changes support.
* Includes delta detection to only send changed fields.
*
* @example
* const { settings, setSettings, saveSettings, isFieldPending } = useAdminSettings({
* sectionName: 'legal'
* });
*/
export function useAdminSettings<T = any>(
options: UseAdminSettingsOptions<T>
): UseAdminSettingsReturn<T> {
const { sectionName, fetchTransformer, saveTransformer } = options;
const [settings, setSettings] = useState<T>({} as T);
const [rawSettings, setRawSettings] = useState<any>(null);
const [originalSettings, setOriginalSettings] = useState<T>({} as T); // Track original active values
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const fetchSettings = async () => {
try {
setLoading(true);
let rawData: any;
if (fetchTransformer) {
// Use custom fetch logic for complex sections
rawData = await fetchTransformer();
} else {
// Simple single-endpoint fetch
const response = await apiClient.get(`/api/v1/admin/settings/section/${sectionName}`);
rawData = response.data || {};
}
console.log(`[useAdminSettings:${sectionName}] Raw response:`, JSON.stringify(rawData, null, 2));
// 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));
setSettings(mergedSettings as T);
} catch (error) {
console.error(`[useAdminSettings:${sectionName}] Failed to fetch:`, error);
throw error;
} finally {
setLoading(false);
}
};
const saveSettings = async () => {
try {
setSaving(true);
// Compute delta: only include fields that changed from original
const delta = computeDelta(originalSettings, settings);
console.log(`[useAdminSettings:${sectionName}] Delta (changed fields):`, JSON.stringify(delta, null, 2));
if (Object.keys(delta).length === 0) {
console.log(`[useAdminSettings:${sectionName}] No changes detected, skipping save`);
return;
}
if (saveTransformer) {
// Use custom save logic for complex sections
const { sectionData, deltaSettings } = saveTransformer(settings);
// Save section data (with delta applied)
const sectionDelta = computeDelta(originalSettings, sectionData);
if (Object.keys(sectionDelta).length > 0) {
await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, sectionDelta);
}
// Save delta settings if provided
if (deltaSettings && Object.keys(deltaSettings).length > 0) {
await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
}
} else {
// Simple single-endpoint save with delta
await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, delta);
}
// Refetch to get updated _pending block
await fetchSettings();
} catch (error) {
console.error(`[useAdminSettings:${sectionName}] Failed to save:`, error);
throw error;
} finally {
setSaving(false);
}
};
return {
settings,
rawSettings,
loading,
saving,
setSettings,
fetchSettings,
saveSettings,
isFieldPending: (fieldPath: string) => isFieldPending(rawSettings, fieldPath),
hasPendingChanges: () => hasPendingChanges(rawSettings),
};
}
/**
* Compute delta between original and current settings.
* Returns only fields that have changed.
*/
function computeDelta(original: any, current: any): any {
const delta: any = {};
for (const key in current) {
if (!Object.prototype.hasOwnProperty.call(current, key)) continue;
const originalValue = original[key];
const currentValue = current[key];
// Handle nested objects
if (isPlainObject(currentValue) && isPlainObject(originalValue)) {
const nestedDelta = computeDelta(originalValue, currentValue);
if (Object.keys(nestedDelta).length > 0) {
delta[key] = nestedDelta;
}
}
// Handle arrays
else if (Array.isArray(currentValue) && Array.isArray(originalValue)) {
if (JSON.stringify(currentValue) !== JSON.stringify(originalValue)) {
delta[key] = currentValue;
}
}
// Handle primitives
else if (currentValue !== originalValue) {
delta[key] = currentValue;
}
}
return delta;
}
/**
* Check if value is a plain object (not array, not null, not Date, etc.)
*/
function isPlainObject(value: any): boolean {
return (
value !== null &&
typeof value === 'object' &&
value.constructor === Object
);
}

View File

@@ -104,4 +104,34 @@ i18n.on('languageChanged', (lng) => {
document.documentElement.lang = lng;
});
/**
* Updates the supported languages list dynamically based on config
* If configLanguages is null/empty, all languages remain available
* Otherwise, only specified languages plus 'en-GB' fallback are enabled
*/
export function updateSupportedLanguages(configLanguages?: string[] | null) {
if (!configLanguages || configLanguages.length === 0) {
// No filter specified - keep all languages
return;
}
// Ensure fallback language is always included
const languagesToSupport = new Set(['en-GB', ...configLanguages]);
// Filter to only valid language codes that exist in our translations
const validLanguages = Array.from(languagesToSupport).filter(
lang => lang in supportedLanguages
);
if (validLanguages.length > 0) {
i18n.options.supportedLngs = validLanguages;
// If current language is not in the new supported list, switch to fallback
const currentLang = i18n.language;
if (currentLang && !validLanguages.includes(currentLang)) {
i18n.changeLanguage('en-GB');
}
}
}
export default i18n;

View File

@@ -150,10 +150,11 @@ export default function HomePage() {
const baseUrl = useBaseUrl();
// Update document meta when tool changes
const appName = config?.appNameNavbar || 'Stirling PDF';
useDocumentMeta({
title: selectedTool ? `${selectedTool.name} - Stirling PDF` : 'Stirling PDF',
title: selectedTool ? `${selectedTool.name} - ${appName}` : appName,
description: selectedTool?.description || t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogTitle: selectedTool ? `${selectedTool.name} - Stirling PDF` : 'Stirling PDF',
ogTitle: selectedTool ? `${selectedTool.name} - ${appName}` : appName,
ogDescription: selectedTool?.description || t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogImage: selectedToolKey ? `${baseUrl}/og_images/${selectedToolKey}.png` : `${baseUrl}/og_images/home.png`,
ogUrl: selectedTool ? `${baseUrl}${window.location.pathname}` : baseUrl

View File

@@ -0,0 +1,34 @@
import apiClient from '@app/services/apiClient';
export interface AccountData {
username: string;
role: string;
settings: string; // JSON string of settings
changeCredsFlag: boolean;
oAuth2Login: boolean;
saml2Login: boolean;
}
/**
* Account Service
* Provides functions to interact with account-related backend APIs
*/
export const accountService = {
/**
* Get current user account data
*/
async getAccountData(): Promise<AccountData> {
const response = await apiClient.get<AccountData>('/api/v1/proprietary/ui-data/account');
return response.data;
},
/**
* Change user password
*/
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
const formData = new FormData();
formData.append('currentPassword', currentPassword);
formData.append('newPassword', newPassword);
await apiClient.post('/api/v1/user/change-password', formData);
},
};

View File

@@ -88,6 +88,10 @@ const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate
* Returns true if the error should be suppressed (deduplicated), false otherwise
*/
export async function handleHttpError(error: any): Promise<boolean> {
// Check if this error should skip the global toast (component will handle it)
if (error?.config?.suppressErrorToast === true) {
return false; // Don't show global toast, but continue rejection
}
// Compute title/body (friendly) from the error object
const { title, body } = extractAxiosErrorMessage(error);

View File

@@ -0,0 +1,107 @@
import apiClient from '@app/services/apiClient';
export interface Team {
id: number;
name: string;
userCount?: number;
}
export interface TeamMember {
id: number;
username: string;
email?: string;
roleName: string;
enabled: boolean;
team?: {
id: number;
name: string;
};
lastRequest?: Date | null;
}
export interface TeamDetailsResponse {
team: Team;
members: TeamMember[];
availableUsers: TeamMember[];
}
/**
* Team Management Service
* Provides functions to interact with team-related backend APIs
*/
export const teamService = {
/**
* Get all teams with user counts
*/
async getTeams(): Promise<Team[]> {
const response = await apiClient.get<{ teamsWithCounts: Team[] }>('/api/v1/proprietary/ui-data/teams');
return response.data.teamsWithCounts;
},
/**
* Get team details including members
*/
async getTeamDetails(teamId: number): Promise<any> {
const response = await apiClient.get(`/api/v1/proprietary/ui-data/teams/${teamId}`);
return response.data;
},
/**
* Create a new team
*/
async createTeam(name: string): Promise<void> {
const formData = new FormData();
formData.append('name', name);
await apiClient.post('/api/v1/team/create', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Rename an existing team
*/
async renameTeam(teamId: number, newName: string): Promise<void> {
const formData = new FormData();
formData.append('teamId', teamId.toString());
formData.append('newName', newName);
await apiClient.post('/api/v1/team/rename', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Delete a team (only if it has no members)
*/
async deleteTeam(teamId: number): Promise<void> {
const formData = new FormData();
formData.append('teamId', teamId.toString());
await apiClient.post('/api/v1/team/delete', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Add a user to a team
*/
async addUserToTeam(teamId: number, userId: number): Promise<void> {
const formData = new FormData();
formData.append('teamId', teamId.toString());
formData.append('userId', userId.toString());
await apiClient.post('/api/v1/team/addUser', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Move a user to a specific team (used when "removing" from a team - moves to Default)
*/
async moveUserToTeam(username: string, currentRole: string, teamId: number): Promise<void> {
const formData = new FormData();
formData.append('username', username);
formData.append('role', currentRole);
formData.append('teamId', teamId.toString());
await apiClient.post('/api/v1/user/admin/changeRole', formData, {
suppressErrorToast: true,
} as any);
},
};

View File

@@ -0,0 +1,166 @@
import apiClient from '@app/services/apiClient';
export interface User {
id: number;
username: string;
email?: string;
roleName: string; // Translation key like "adminUserSettings.admin"
rolesAsString?: string; // Actual role ID like "ROLE_ADMIN"
enabled: boolean;
isFirstLogin?: boolean;
authenticationType?: string;
team?: {
id: number;
name: string;
};
createdAt?: string;
updatedAt?: string;
// Enriched client-side fields
isActive?: boolean;
lastRequest?: number; // timestamp in milliseconds
}
export interface AdminSettingsData {
users: User[];
userSessions: Record<string, boolean>;
userLastRequest: Record<string, number>; // username -> timestamp in milliseconds
totalUsers: number;
activeUsers: number;
disabledUsers: number;
currentUsername?: string;
roleDetails?: Record<string, string>;
teams?: any[];
maxPaidUsers?: number;
}
export interface CreateUserRequest {
username: string;
password?: string;
role: string;
teamId?: number;
authType: 'password' | 'SSO';
forceChange?: boolean;
}
export interface UpdateUserRoleRequest {
username: string;
role: string;
teamId?: number;
}
export interface InviteUsersRequest {
emails: string; // Comma-separated email addresses
role: string;
teamId?: number;
}
export interface InviteUsersResponse {
successCount: number;
failureCount: number;
message?: string;
errors?: string;
error?: string;
}
/**
* User Management Service
* Provides functions to interact with user management backend APIs
*/
export const userManagementService = {
/**
* Get all users with session data (admin only)
*/
async getUsers(): Promise<AdminSettingsData> {
const response = await apiClient.get<AdminSettingsData>('/api/v1/proprietary/ui-data/admin-settings');
return response.data;
},
/**
* Get users without a team
*/
async getUsersWithoutTeam(): Promise<User[]> {
const response = await apiClient.get<User[]>('/api/v1/users/without-team');
return response.data;
},
/**
* Create a new user (admin only)
*/
async createUser(data: CreateUserRequest): Promise<void> {
const formData = new FormData();
formData.append('username', data.username);
if (data.password) {
formData.append('password', data.password);
}
formData.append('role', data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
}
formData.append('authType', data.authType);
if (data.forceChange !== undefined) {
formData.append('forceChange', data.forceChange.toString());
}
await apiClient.post('/api/v1/user/admin/saveUser', formData, {
suppressErrorToast: true, // Component will handle error display
} as any);
},
/**
* Update user role and/or team (admin only)
*/
async updateUserRole(data: UpdateUserRoleRequest): Promise<void> {
const formData = new FormData();
formData.append('username', data.username);
formData.append('role', data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
}
await apiClient.post('/api/v1/user/admin/changeRole', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Enable or disable a user (admin only)
*/
async toggleUserEnabled(username: string, enabled: boolean): Promise<void> {
const formData = new FormData();
formData.append('enabled', enabled.toString());
await apiClient.post(`/api/v1/user/admin/changeUserEnabled/${username}`, formData, {
suppressErrorToast: true,
} as any);
},
/**
* Delete a user (admin only)
*/
async deleteUser(username: string): Promise<void> {
await apiClient.post(`/api/v1/user/admin/deleteUser/${username}`, null, {
suppressErrorToast: true,
} as any);
},
/**
* Invite users via email (admin only)
* Sends comma-separated email addresses, creates accounts with random passwords,
* and sends invitation emails
*/
async inviteUsers(data: InviteUsersRequest): Promise<InviteUsersResponse> {
const formData = new FormData();
formData.append('emails', data.emails);
formData.append('role', data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
}
const response = await apiClient.post<InviteUsersResponse>(
'/api/v1/user/admin/inviteUsers',
formData,
{
suppressErrorToast: true, // Component will handle error display
} as any
);
return response.data;
},
};

View File

@@ -10,5 +10,11 @@ export const Z_INDEX_OVER_FILE_MANAGER_MODAL = 1300;
export const Z_INDEX_AUTOMATE_MODAL = 1100;
// Modal that appears on top of config modal (e.g., restart confirmation)
export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
// Toast notifications and error displays - Always on top (higher than rainbow theme at 10000)
export const Z_INDEX_TOAST = 10001;

View File

@@ -0,0 +1,166 @@
/**
* Helper utilities for handling settings with pending changes that require restart.
*
* Backend returns settings in this format:
* {
* "enableLogin": false, // Current active value
* "csrfDisabled": true,
* "_pending": { // Optional - only present if there are pending changes
* "enableLogin": true // Value that will be active after restart
* }
* }
*/
export interface SettingsWithPending<T = any> {
_pending?: Partial<T>;
[key: string]: any;
}
/**
* Merge pending changes into the settings object.
* Returns a new object with pending values overlaid on top of current values.
*
* @param settings Settings object from backend (may contain _pending block)
* @returns Merged settings with pending values applied
*/
export function mergePendingSettings<T extends SettingsWithPending>(settings: T): Omit<T, '_pending'> {
if (!settings || !settings._pending) {
// No pending changes, return as-is (without _pending property)
const { _pending, ...rest } = settings || {};
return rest as Omit<T, '_pending'>;
}
// Deep merge pending changes
const merged = deepMerge(settings, settings._pending);
// Remove _pending from result
const { _pending, ...result } = merged;
return result as Omit<T, '_pending'>;
}
/**
* Check if a specific field has a pending change awaiting restart.
*
* @param settings Settings object from backend
* @param fieldPath Dot-notation path to the field (e.g., "oauth2.clientSecret")
* @returns True if field has pending changes
*/
export function isFieldPending<T extends SettingsWithPending>(
settings: T | null | undefined,
fieldPath: string
): boolean {
if (!settings?._pending) {
return false;
}
// Navigate the pending object using dot notation
const value = getNestedValue(settings._pending, fieldPath);
return value !== undefined;
}
/**
* Check if there are any pending changes in the settings.
*
* @param settings Settings object from backend
* @returns True if there are any pending changes
*/
export function hasPendingChanges<T extends SettingsWithPending>(
settings: T | null | undefined
): boolean {
return settings?._pending !== undefined && Object.keys(settings._pending).length > 0;
}
/**
* Get the pending value for a specific field, or undefined if no pending change.
*
* @param settings Settings object from backend
* @param fieldPath Dot-notation path to the field
* @returns Pending value or undefined
*/
export function getPendingValue<T extends SettingsWithPending>(
settings: T | null | undefined,
fieldPath: string
): any {
if (!settings?._pending) {
return undefined;
}
return getNestedValue(settings._pending, fieldPath);
}
/**
* Get the current active value for a field (ignoring pending changes).
*
* @param settings Settings object from backend
* @param fieldPath Dot-notation path to the field
* @returns Current active value
*/
export function getCurrentValue<T extends SettingsWithPending>(
settings: T | null | undefined,
fieldPath: string
): any {
if (!settings) {
return undefined;
}
// Get from settings, ignoring _pending
const { _pending, ...activeSettings } = settings;
return getNestedValue(activeSettings, fieldPath);
}
// ========== Helper Functions ==========
/**
* Deep merge two objects. Second object takes priority.
*/
function deepMerge(target: any, source: any): any {
if (!source) return target;
if (!target) return source;
const result = { ...target };
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const sourceValue = source[key];
const targetValue = result[key];
if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
result[key] = deepMerge(targetValue, sourceValue);
} else {
result[key] = sourceValue;
}
}
}
return result;
}
/**
* Get nested value using dot notation.
*/
function getNestedValue(obj: any, path: string): any {
if (!obj || !path) return undefined;
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
current = current[part];
}
return current;
}
/**
* Check if value is a plain object (not array, not null, not Date, etc.)
*/
function isPlainObject(value: any): boolean {
return (
value !== null &&
typeof value === 'object' &&
value.constructor === Object
);
}

View File

@@ -15,6 +15,7 @@ export interface User {
role: string;
enabled?: boolean;
is_anonymous?: boolean;
isFirstLogin?: boolean;
app_metadata?: Record<string, any>;
}

View File

@@ -1,8 +1,11 @@
import { useState, useEffect } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession'
import { useAppConfig } from '@app/contexts/AppConfigContext'
import HomePage from '@app/pages/HomePage'
import Login from '@app/routes/Login'
import FirstLoginModal from '@app/components/shared/FirstLoginModal'
import { accountService } from '@app/services/accountService'
/**
* Landing component - Smart router based on authentication status
@@ -12,12 +15,45 @@ import Login from '@app/routes/Login'
* If user is not authenticated: Show Login or redirect to /login
*/
export default function Landing() {
const { session, loading: authLoading } = useAuth();
const { session, loading: authLoading, refreshSession } = useAuth();
const { config, loading: configLoading } = useAppConfig();
const location = useLocation();
const [isFirstLogin, setIsFirstLogin] = useState(false);
const [checkingFirstLogin, setCheckingFirstLogin] = useState(false);
const [username, setUsername] = useState('');
const loading = authLoading || configLoading;
// Check if user needs to change password on first login
useEffect(() => {
const checkFirstLogin = async () => {
if (session && config?.enableLogin !== false) {
try {
setCheckingFirstLogin(true)
const accountData = await accountService.getAccountData()
setUsername(accountData.username)
setIsFirstLogin(accountData.changeCredsFlag)
} catch (err) {
console.error('Failed to check first login status:', err)
// If account endpoint fails (404), user probably doesn't have security enabled
setIsFirstLogin(false)
} finally {
setCheckingFirstLogin(false)
}
}
}
checkFirstLogin()
}, [session, config])
const handlePasswordChanged = async () => {
// After password change, backend logs out the user
// Refresh session to detect logout and redirect to login
setIsFirstLogin(false) // Close modal first
await refreshSession()
// The auth system will automatically redirect to login when session is null
}
console.log('[Landing] State:', {
pathname: location.pathname,
loading,
@@ -26,7 +62,7 @@ export default function Landing() {
});
// Show loading while checking auth and config
if (loading) {
if (loading || checkingFirstLogin) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="text-center">
@@ -47,7 +83,16 @@ export default function Landing() {
// If we have a session, show the main app
if (session) {
return <HomePage />;
return (
<>
<FirstLoginModal
opened={isFirstLogin}
onPasswordChanged={handlePasswordChanged}
username={username}
/>
<HomePage />
</>
);
}
// If we're at home route ("/"), show login directly (marketing/landing page)