mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
24
frontend/src/core/components/shared/AppConfigLoader.tsx
Normal file
24
frontend/src/core/components/shared/AppConfigLoader.tsx
Normal 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;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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'
|
||||
}}
|
||||
>
|
||||
|
||||
159
frontend/src/core/components/shared/FirstLoginModal.tsx
Normal file
159
frontend/src/core/components/shared/FirstLoginModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
22
frontend/src/core/components/shared/config/PendingBadge.tsx
Normal file
22
frontend/src/core/components/shared/config/PendingBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 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 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 .internal .corp .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>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,6 @@ const Overview: React.FC = () => {
|
||||
};
|
||||
|
||||
const basicConfig = config ? {
|
||||
appName: config.appName,
|
||||
appNameNavbar: config.appNameNavbar,
|
||||
baseUrl: config.baseUrl,
|
||||
contextPath: config.contextPath,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
191
frontend/src/core/hooks/useAdminSettings.ts
Normal file
191
frontend/src/core/hooks/useAdminSettings.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
34
frontend/src/core/services/accountService.ts
Normal file
34
frontend/src/core/services/accountService.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
107
frontend/src/core/services/teamService.ts
Normal file
107
frontend/src/core/services/teamService.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
166
frontend/src/core/services/userManagementService.ts
Normal file
166
frontend/src/core/services/userManagementService.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
166
frontend/src/core/utils/settingsPendingHelper.ts
Normal file
166
frontend/src/core/utils/settingsPendingHelper.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export interface User {
|
||||
role: string;
|
||||
enabled?: boolean;
|
||||
is_anonymous?: boolean;
|
||||
isFirstLogin?: boolean;
|
||||
app_metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user