This commit is contained in:
Anthony Stirling 2025-11-12 15:05:27 +00:00
parent ebf4bab80b
commit 0da1ae06d9
25 changed files with 878 additions and 228 deletions

View File

@ -107,7 +107,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
}
}
// If we still don't have any authentication, check if it's a public endpoint. If not, deny the request
// If we still don't have any authentication, check if it's a public endpoint. If not, deny
// the request
if (authentication == null || !authentication.isAuthenticated()) {
String method = request.getMethod();
String contextPath = request.getContextPath();

View File

@ -64,19 +64,21 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
headerBorder: 'var(--modal-header-border)',
}), []);
// Get isAdmin and runningEE from app config
// Get isAdmin, runningEE, and enableLogin from app config
const isAdmin = config?.isAdmin ?? false;
const runningEE = config?.runningEE ?? false;
const loginEnabled = config?.enableLogin ?? true;
console.log('[AppConfigModal] Config:', { isAdmin, runningEE, fullConfig: config });
console.log('[AppConfigModal] Config:', { isAdmin, runningEE, loginEnabled, fullConfig: config });
// Left navigation structure and icons
const configNavSections = useMemo(() =>
createConfigNavSections(
isAdmin,
runningEE
runningEE,
loginEnabled
),
[isAdmin, runningEE]
[isAdmin, runningEE, loginEnabled]
);
const activeLabel = useMemo(() => {
@ -143,16 +145,15 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
<div
key={item.key}
onClick={() => {
if (!isDisabled) {
setActive(item.key);
navigate(`/settings/${item.key}`);
}
// Allow navigation even when disabled - the content inside will be disabled
setActive(item.key);
navigate(`/settings/${item.key}`);
}}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
opacity: isDisabled ? 0.5 : 1,
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.6 : 1,
cursor: 'pointer',
}}
>
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />

View File

@ -52,7 +52,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
setLoading(true);
setError('');
await accountService.changePassword(currentPassword, newPassword);
await accountService.changePasswordOnLogin(currentPassword, newPassword);
alert({
alertType: 'success',

View File

@ -0,0 +1,38 @@
import { Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
interface LoginRequiredBannerProps {
show: boolean;
}
/**
* Banner component that displays when login mode is required but not enabled
* Shows prominent warning that settings are read-only
*/
export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps) {
const { t } = useTranslation();
if (!show) return null;
return (
<Alert
icon={<LocalIcon icon="lock-rounded" width={20} height={20} />}
title={t('admin.settings.loginDisabled.title', 'Login Mode Required')}
color="blue"
variant="light"
styles={{
root: {
borderLeft: '4px solid var(--mantine-color-blue-6)'
}
}}
>
<Text size="sm">
{t('admin.settings.loginDisabled.message', 'Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.')}
</Text>
<Text size="sm" fw={600} mt="xs" c="dimmed">
{t('admin.settings.loginDisabled.readOnly', 'The settings below show example values for reference. Enable login mode to view and edit actual configuration.')}
</Text>
</Alert>
);
}

View File

@ -41,7 +41,8 @@ export interface ConfigColors {
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false
runningEE: boolean = false,
loginEnabled: boolean = true
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
{
@ -63,8 +64,9 @@ export const createConfigNavSections = (
},
];
// Add Admin sections if user is admin
if (isAdmin) {
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
if (isAdmin || !loginEnabled) {
const requiresLogin = !loginEnabled;
// Configuration
sections.push({
title: 'Configuration',
@ -73,31 +75,41 @@ export const createConfigNavSections = (
key: 'adminGeneral',
label: 'System Settings',
icon: 'settings-rounded',
component: <AdminGeneralSection />
component: <AdminGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminFeatures',
label: 'Features',
icon: 'extension-rounded',
component: <AdminFeaturesSection />
component: <AdminFeaturesSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminEndpoints',
label: 'Endpoints',
icon: 'api-rounded',
component: <AdminEndpointsSection />
component: <AdminEndpointsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminDatabase',
label: 'Database',
icon: 'storage-rounded',
component: <AdminDatabaseSection />
component: <AdminDatabaseSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminAdvanced',
label: 'Advanced',
icon: 'tune-rounded',
component: <AdminAdvancedSection />
component: <AdminAdvancedSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
],
});
@ -110,13 +122,17 @@ export const createConfigNavSections = (
key: 'adminSecurity',
label: 'Security',
icon: 'shield-rounded',
component: <AdminSecuritySection />
component: <AdminSecuritySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminConnections',
label: 'Connections',
icon: 'link-rounded',
component: <AdminConnectionsSection />
component: <AdminConnectionsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
],
});
@ -129,23 +145,25 @@ export const createConfigNavSections = (
key: 'adminPremium',
label: 'Premium',
icon: 'star-rounded',
component: <AdminPremiumSection />
component: <AdminPremiumSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminAudit',
label: 'Audit',
icon: 'fact-check-rounded',
component: <AdminAuditSection />,
disabled: !runningEE,
disabledTooltip: 'Requires Enterprise license'
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : 'Requires Enterprise license'
},
{
key: 'adminUsage',
label: 'Usage Analytics',
icon: 'analytics-rounded',
component: <AdminUsageSection />,
disabled: !runningEE,
disabledTooltip: 'Requires Enterprise license'
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : 'Requires Enterprise license'
},
],
});
@ -158,13 +176,17 @@ export const createConfigNavSections = (
key: 'adminLegal',
label: 'Legal',
icon: 'gavel-rounded',
component: <AdminLegalSection />
component: <AdminLegalSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminPrivacy',
label: 'Privacy',
icon: 'visibility-rounded',
component: <AdminPrivacySection />
component: <AdminPrivacySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
],
});

View File

@ -7,6 +7,8 @@ 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';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface AdvancedSettingsData {
enableAlphaFunctionality?: boolean;
@ -55,6 +57,7 @@ interface AdvancedSettingsData {
export default function AdminAdvancedSection() {
const { t } = useTranslation();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const {
settings,
@ -165,10 +168,15 @@ export default function AdminAdvancedSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -181,7 +189,9 @@ export default function AdminAdvancedSection() {
}
};
if (loading) {
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -191,6 +201,7 @@ export default function AdminAdvancedSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.advanced.title', 'Advanced')}</Text>
<Text size="sm" c="dimmed">
@ -213,7 +224,12 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.enableAlphaFunctionality || false}
onChange={(e) => setSettings({ ...settings, enableAlphaFunctionality: e.target.checked })}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableAlphaFunctionality: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableAlphaFunctionality')} />
</Group>
@ -229,7 +245,12 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.enableUrlToPDF || false}
onChange={(e) => setSettings({ ...settings, enableUrlToPDF: e.target.checked })}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableUrlToPDF: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableUrlToPDF')} />
</Group>
@ -245,7 +266,12 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.disableSanitize || false}
onChange={(e) => setSettings({ ...settings, disableSanitize: e.target.checked })}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, disableSanitize: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('disableSanitize')} />
</Group>
@ -271,6 +297,7 @@ export default function AdminAdvancedSection() {
onChange={(value) => setSettings({ ...settings, maxDPI: Number(value) })}
min={0}
max={3000}
disabled={!loginEnabled}
/>
</div>
@ -286,6 +313,7 @@ export default function AdminAdvancedSection() {
value={settings.tessdataDir || ''}
onChange={(e) => setSettings({ ...settings, tessdataDir: e.target.value })}
placeholder="/usr/share/tessdata"
disabled={!loginEnabled}
/>
</div>
</Stack>
@ -311,6 +339,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, baseTmpDir: e.target.value }
})}
placeholder="Default: java.io.tmpdir/stirling-pdf"
disabled={!loginEnabled}
/>
</div>
@ -324,6 +353,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, libreofficeDir: e.target.value }
})}
placeholder="Default: baseTmpDir/libreoffice"
disabled={!loginEnabled}
/>
</div>
@ -337,6 +367,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, systemTempDir: e.target.value }
})}
placeholder="System temp directory path"
disabled={!loginEnabled}
/>
</div>
@ -350,6 +381,7 @@ export default function AdminAdvancedSection() {
tempFileManagement: { ...settings.tempFileManagement, prefix: e.target.value }
})}
placeholder="stirling-pdf-"
disabled={!loginEnabled}
/>
</div>
@ -364,6 +396,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={720}
disabled={!loginEnabled}
/>
</div>
@ -378,6 +411,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={1440}
disabled={!loginEnabled}
/>
</div>
@ -391,10 +425,15 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.tempFileManagement?.startupCleanup ?? true}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked }
})}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked }
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('tempFileManagement.startupCleanup')} />
</Group>
@ -410,10 +449,15 @@ export default function AdminAdvancedSection() {
<Group gap="xs">
<Switch
checked={settings.tempFileManagement?.cleanupSystemTemp ?? false}
onChange={(e) => setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked }
})}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked }
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('tempFileManagement.cleanupSystemTemp')} />
</Group>
@ -448,6 +492,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -462,6 +507,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -485,6 +531,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -499,6 +546,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -522,6 +570,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -536,6 +585,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -559,6 +609,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -573,6 +624,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -596,6 +648,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -610,6 +663,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -633,6 +687,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -647,6 +702,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -670,6 +726,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -684,6 +741,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -707,6 +765,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -721,6 +780,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -744,6 +804,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -758,6 +819,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -781,6 +843,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={100}
disabled={!loginEnabled}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
@ -795,6 +858,7 @@ export default function AdminAdvancedSection() {
})}
min={1}
max={240}
disabled={!loginEnabled}
/>
</Stack>
</Accordion.Panel>
@ -805,7 +869,7 @@ export default function AdminAdvancedSection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -6,9 +6,12 @@ import AuditSystemStatus from '@app/components/shared/config/configSections/audi
import AuditChartsSection from '@app/components/shared/config/configSections/audit/AuditChartsSection';
import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable';
import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
const AdminAuditSection: React.FC = () => {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -27,10 +30,24 @@ const AdminAuditSection: React.FC = () => {
}
};
fetchSystemStatus();
}, []);
if (loginEnabled) {
fetchSystemStatus();
} else {
// Provide example audit system status when login is disabled
setSystemStatus({
enabled: true,
level: 'INFO',
retentionDays: 90,
totalEvents: 1234,
});
setLoading(false);
}
}, [loginEnabled]);
if (loading) {
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
<Loader size="lg" />
@ -56,32 +73,33 @@ const AdminAuditSection: React.FC = () => {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<AuditSystemStatus status={systemStatus} />
{systemStatus.enabled ? (
<Tabs defaultValue="dashboard">
<Tabs.List>
<Tabs.Tab value="dashboard">
<Tabs.Tab value="dashboard" disabled={!loginEnabled}>
{t('audit.tabs.dashboard', 'Dashboard')}
</Tabs.Tab>
<Tabs.Tab value="events">
<Tabs.Tab value="events" disabled={!loginEnabled}>
{t('audit.tabs.events', 'Audit Events')}
</Tabs.Tab>
<Tabs.Tab value="export">
<Tabs.Tab value="export" disabled={!loginEnabled}>
{t('audit.tabs.export', 'Export')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="dashboard" pt="md">
<AuditChartsSection />
<AuditChartsSection loginEnabled={loginEnabled} />
</Tabs.Panel>
<Tabs.Panel value="events" pt="md">
<AuditEventsTable />
<AuditEventsTable loginEnabled={loginEnabled} />
</Tabs.Panel>
<Tabs.Panel value="export" pt="md">
<AuditExportSection />
<AuditExportSection loginEnabled={loginEnabled} />
</Tabs.Panel>
</Tabs>
) : (

View File

@ -12,6 +12,8 @@ import {
Provider,
} from '@app/components/shared/config/configSections/providerDefinitions';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface ConnectionsSettingsData {
oauth2?: {
@ -45,15 +47,10 @@ interface ConnectionsSettingsData {
export default function AdminConnectionsSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
fetchSettings,
isFieldPending,
} = useAdminSettings<ConnectionsSettingsData>({
const adminSettings = useAdminSettings<ConnectionsSettingsData>({
sectionName: 'connections',
fetchTransformer: async () => {
// Fetch security settings (oauth2, saml2)
@ -106,57 +103,76 @@ export default function AdminConnectionsSection() {
}
});
const {
settings,
setSettings,
loading,
fetchSettings,
isFieldPending,
} = adminSettings;
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loginEnabled]);
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
const isProviderConfigured = (provider: Provider): boolean => {
if (provider.id === 'saml2') {
return settings.saml2?.enabled === true;
return settings?.saml2?.enabled === true;
}
if (provider.id === 'smtp') {
return settings.mail?.enabled === true;
return settings?.mail?.enabled === true;
}
if (provider.id === 'oauth2-generic') {
return settings.oauth2?.enabled === true;
return settings?.oauth2?.enabled === true;
}
// Check if specific OAuth2 provider is configured (has clientId)
const providerSettings = settings.oauth2?.client?.[provider.id];
const providerSettings = settings?.oauth2?.client?.[provider.id];
return !!(providerSettings?.clientId);
};
const getProviderSettings = (provider: Provider): Record<string, any> => {
if (provider.id === 'saml2') {
return settings.saml2 || {};
return settings?.saml2 || {};
}
if (provider.id === 'smtp') {
return settings.mail || {};
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,
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] || {};
return settings?.oauth2?.client?.[provider.id] || {};
};
const handleProviderSave = async (provider: Provider, providerSettings: Record<string, any>) => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
if (provider.id === 'smtp') {
// Mail settings use a different endpoint
@ -218,7 +234,12 @@ export default function AdminConnectionsSection() {
};
const handleProviderDisconnect = async (provider: Provider) => {
try {
// Block disconnect if login is disabled
if (!validateLoginEnabled()) {
return;
}
try{
if (provider.id === 'smtp') {
// Mail settings use a different endpoint
const response = await apiClient.put('/api/v1/admin/settings/section/mail', { enabled: false });
@ -271,7 +292,7 @@ export default function AdminConnectionsSection() {
}
};
if (loading) {
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -280,9 +301,14 @@ export default function AdminConnectionsSection() {
}
const handleSSOAutoLoginSave = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
const deltaSettings = {
'premium.proFeatures.ssoAutoLogin': settings.ssoAutoLogin
'premium.proFeatures.ssoAutoLogin': settings?.ssoAutoLogin
};
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
@ -311,6 +337,8 @@ export default function AdminConnectionsSection() {
return (
<Stack gap="xl">
<LoginRequiredBanner show={!loginEnabled} />
{/* Header */}
<div>
<Text fw={600} size="lg">
@ -341,11 +369,14 @@ export default function AdminConnectionsSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.ssoAutoLogin || false}
checked={settings?.ssoAutoLogin || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
handleSSOAutoLoginSave();
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('ssoAutoLogin')} />
</Group>
@ -369,6 +400,7 @@ export default function AdminConnectionsSection() {
settings={getProviderSettings(provider)}
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
onDisconnect={() => handleProviderDisconnect(provider)}
disabled={!loginEnabled}
/>
))}
</Stack>
@ -392,6 +424,7 @@ export default function AdminConnectionsSection() {
provider={provider}
isConfigured={false}
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
disabled={!loginEnabled}
/>
))}
</Stack>

View File

@ -6,6 +6,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import apiClient from '@app/services/apiClient';
interface DatabaseSettingsData {
@ -21,6 +23,7 @@ interface DatabaseSettingsData {
export default function AdminDatabaseSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -78,10 +81,17 @@ export default function AdminDatabaseSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -94,7 +104,10 @@ export default function AdminDatabaseSection() {
}
};
if (loading) {
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -104,6 +117,8 @@ export default function AdminDatabaseSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Group justify="space-between" align="center">
<div>
@ -130,14 +145,19 @@ export default function AdminDatabaseSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.enableCustomDatabase || false}
onChange={(e) => setSettings({ ...settings, enableCustomDatabase: e.target.checked })}
checked={settings?.enableCustomDatabase || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableCustomDatabase: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableCustomDatabase')} />
</Group>
</div>
{settings.enableCustomDatabase && (
{settings?.enableCustomDatabase && (
<>
<div>
<TextInput
@ -148,9 +168,10 @@ export default function AdminDatabaseSection() {
</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 || ''}
value={settings?.customDatabaseUrl || ''}
onChange={(e) => setSettings({ ...settings, customDatabaseUrl: e.target.value })}
placeholder="jdbc:postgresql://localhost:5432/postgres"
disabled={!loginEnabled}
/>
</div>
@ -163,7 +184,7 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.type.description', 'Type of database (not used if custom URL is provided)')}
value={settings.type || 'postgresql'}
value={settings?.type || 'postgresql'}
onChange={(value) => setSettings({ ...settings, type: value || 'postgresql' })}
data={[
{ value: 'postgresql', label: 'PostgreSQL' },
@ -171,6 +192,7 @@ export default function AdminDatabaseSection() {
{ value: 'mysql', label: 'MySQL' },
{ value: 'mariadb', label: 'MariaDB' }
]}
disabled={!loginEnabled}
/>
</div>
@ -183,9 +205,10 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.hostName.description', 'Database server hostname (not used if custom URL is provided)')}
value={settings.hostName || ''}
value={settings?.hostName || ''}
onChange={(e) => setSettings({ ...settings, hostName: e.target.value })}
placeholder="localhost"
disabled={!loginEnabled}
/>
</div>
@ -198,10 +221,11 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.port.description', 'Database server port (not used if custom URL is provided)')}
value={settings.port || 5432}
value={settings?.port || 5432}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
max={65535}
disabled={!loginEnabled}
/>
</div>
@ -214,9 +238,10 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.name.description', 'Name of the database (not used if custom URL is provided)')}
value={settings.name || ''}
value={settings?.name || ''}
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
/>
</div>
@ -229,9 +254,10 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.username.description', 'Database authentication username')}
value={settings.username || ''}
value={settings?.username || ''}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
/>
</div>
@ -244,9 +270,10 @@ export default function AdminDatabaseSection() {
</Group>
}
description={t('admin.settings.database.password.description', 'Database authentication password')}
value={settings.password || ''}
value={settings?.password || ''}
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
placeholder="••••••••"
disabled={!loginEnabled}
/>
</div>
</>
@ -256,7 +283,7 @@ export default function AdminDatabaseSection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -6,6 +6,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface EndpointsSettingsData {
toRemove?: string[];
@ -14,6 +16,7 @@ interface EndpointsSettingsData {
export default function AdminEndpointsSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -29,10 +32,17 @@ export default function AdminEndpointsSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -45,7 +55,10 @@ export default function AdminEndpointsSection() {
}
};
if (loading) {
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -102,6 +115,8 @@ export default function AdminEndpointsSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.endpoints.title', 'API Endpoints')}</Text>
<Text size="sm" c="dimmed">
@ -123,12 +138,16 @@ export default function AdminEndpointsSection() {
}
description={t('admin.settings.endpoints.toRemove.description', 'Select individual endpoints to disable')}
value={settings.toRemove || []}
onChange={(value) => setSettings({ ...settings, toRemove: value })}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, toRemove: value });
}}
data={commonEndpoints.map(endpoint => ({ value: endpoint, label: endpoint }))}
searchable
clearable
placeholder="Select endpoints to disable"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
@ -142,12 +161,16 @@ export default function AdminEndpointsSection() {
}
description={t('admin.settings.endpoints.groupsToRemove.description', 'Select endpoint groups to disable')}
value={settings.groupsToRemove || []}
onChange={(value) => setSettings({ ...settings, groupsToRemove: value })}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, groupsToRemove: value });
}}
data={commonGroups.map(group => ({ value: group, label: group }))}
searchable
clearable
placeholder="Select groups to disable"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
@ -160,7 +183,7 @@ export default function AdminEndpointsSection() {
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -7,6 +7,8 @@ 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';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface FeaturesSettingsData {
serverCertificate?: {
@ -19,6 +21,7 @@ interface FeaturesSettingsData {
export default function AdminFeaturesSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -69,10 +72,15 @@ export default function AdminFeaturesSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -85,7 +93,9 @@ export default function AdminFeaturesSection() {
}
};
if (loading) {
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -95,6 +105,7 @@ export default function AdminFeaturesSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.features.title', 'Features')}</Text>
<Text size="sm" c="dimmed">
@ -124,10 +135,15 @@ export default function AdminFeaturesSection() {
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.enabled ?? true}
onChange={(e) => setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked }
})}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked }
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('serverCertificate.enabled')} />
</Group>
@ -148,6 +164,7 @@ export default function AdminFeaturesSection() {
serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value }
})}
placeholder="Stirling-PDF"
disabled={!loginEnabled}
/>
</div>
@ -167,6 +184,7 @@ export default function AdminFeaturesSection() {
})}
min={1}
max={3650}
disabled={!loginEnabled}
/>
</div>
@ -180,10 +198,15 @@ export default function AdminFeaturesSection() {
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.regenerateOnStartup ?? false}
onChange={(e) => setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked }
})}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked }
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('serverCertificate.regenerateOnStartup')} />
</Group>
@ -193,7 +216,7 @@ export default function AdminFeaturesSection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -7,6 +7,8 @@ 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';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface GeneralSettingsData {
ui: {
@ -40,6 +42,7 @@ interface GeneralSettingsData {
export default function AdminGeneralSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -108,14 +111,14 @@ export default function AdminGeneralSection() {
saveTransformer: (settings) => {
const deltaSettings: Record<string, any> = {
// UI settings
'ui.appNameNavbar': settings.ui.appNameNavbar,
'ui.languages': settings.ui.languages,
'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,
'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,
@ -124,10 +127,10 @@ export default function AdminGeneralSection() {
};
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;
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 {
@ -138,10 +141,22 @@ export default function AdminGeneralSection() {
});
useEffect(() => {
fetchSettings();
}, []);
// Only fetch real settings if login is enabled
if (loginEnabled) {
fetchSettings();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loginEnabled]);
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
const handleSave = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -154,7 +169,7 @@ export default function AdminGeneralSection() {
}
};
if (loading) {
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -164,6 +179,8 @@ export default function AdminGeneralSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.general.title', 'System Settings')}</Text>
<Text size="sm" c="dimmed">
@ -185,9 +202,10 @@ export default function AdminGeneralSection() {
</Group>
}
description={t('admin.settings.general.appNameNavbar.description', 'The name displayed in the navigation bar')}
value={settings.ui.appNameNavbar || ''}
value={settings.ui?.appNameNavbar || ''}
onChange={(e) => setSettings({ ...settings, ui: { ...settings.ui, appNameNavbar: e.target.value } })}
placeholder="Stirling PDF"
disabled={!loginEnabled}
/>
</div>
@ -200,7 +218,7 @@ export default function AdminGeneralSection() {
</Group>
}
description={t('admin.settings.general.languages.description', 'Limit which languages are available (empty = all languages)')}
value={settings.ui.languages || []}
value={settings.ui?.languages || []}
onChange={(value) => setSettings({ ...settings, ui: { ...settings.ui, languages: value } })}
data={[
{ value: 'de_DE', label: 'Deutsch' },
@ -218,6 +236,7 @@ export default function AdminGeneralSection() {
clearable
placeholder="Select languages"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
@ -230,9 +249,10 @@ export default function AdminGeneralSection() {
</Group>
}
description={t('admin.settings.general.defaultLocale.description', 'The default language for new users (e.g., en_US, es_ES)')}
value={settings.system.defaultLocale || ''}
value={ settings.system?.defaultLocale || ''}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, defaultLocale: e.target.value } })}
placeholder="en_US"
disabled={!loginEnabled}
/>
</div>
@ -245,9 +265,10 @@ export default function AdminGeneralSection() {
</Group>
}
description={t('admin.settings.general.fileUploadLimit.description', 'Maximum file upload size (e.g., 100MB, 1GB)')}
value={settings.system.fileUploadLimit || ''}
value={ settings.system?.fileUploadLimit || ''}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, fileUploadLimit: e.target.value } })}
placeholder="100MB"
disabled={!loginEnabled}
/>
</div>
@ -260,8 +281,9 @@ export default function AdminGeneralSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.system.showUpdate || false}
checked={ settings.system?.showUpdate || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdate: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('system.showUpdate')} />
</Group>
@ -276,8 +298,9 @@ export default function AdminGeneralSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.system.showUpdateOnlyAdmin || false}
checked={ settings.system?.showUpdateOnlyAdmin || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdateOnlyAdmin: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('system.showUpdateOnlyAdmin')} />
</Group>
@ -292,8 +315,9 @@ export default function AdminGeneralSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.system.customHTMLFiles || false}
checked={settings.system?.customHTMLFiles || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, customHTMLFiles: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('system.customHTMLFiles')} />
</Group>
@ -326,6 +350,7 @@ export default function AdminGeneralSection() {
autoUpdateMetadata: e.target.checked
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('customMetadata.autoUpdateMetadata')} />
</Group>
@ -349,6 +374,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="username"
disabled={!loginEnabled}
/>
</div>
@ -370,6 +396,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="Stirling-PDF"
disabled={!loginEnabled}
/>
</div>
@ -391,6 +418,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="Stirling-PDF"
disabled={!loginEnabled}
/>
</div>
</Stack>
@ -429,6 +457,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="/pipeline/watchedFolders"
disabled={!loginEnabled}
/>
</div>
@ -453,6 +482,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="/pipeline/finishedFolders"
disabled={!loginEnabled}
/>
</div>
@ -479,6 +509,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="/opt/venv/bin/weasyprint"
disabled={!loginEnabled}
/>
</div>
@ -503,6 +534,7 @@ export default function AdminGeneralSection() {
}
})}
placeholder="/opt/venv/bin/unoconvert"
disabled={!loginEnabled}
/>
</div>
</Stack>
@ -510,7 +542,7 @@ export default function AdminGeneralSection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -7,6 +7,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface LegalSettingsData {
termsAndConditions?: string;
@ -18,6 +20,7 @@ interface LegalSettingsData {
export default function AdminLegalSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -33,10 +36,15 @@ export default function AdminLegalSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -49,7 +57,9 @@ export default function AdminLegalSection() {
}
};
if (loading) {
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -59,6 +69,7 @@ export default function AdminLegalSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.legal.title', 'Legal Documents')}</Text>
<Text size="sm" c="dimmed">
@ -95,6 +106,7 @@ export default function AdminLegalSection() {
value={settings.termsAndConditions || ''}
onChange={(e) => setSettings({ ...settings, termsAndConditions: e.target.value })}
placeholder="https://example.com/terms"
disabled={!loginEnabled}
/>
</div>
@ -110,6 +122,7 @@ export default function AdminLegalSection() {
value={settings.privacyPolicy || ''}
onChange={(e) => setSettings({ ...settings, privacyPolicy: e.target.value })}
placeholder="https://example.com/privacy"
disabled={!loginEnabled}
/>
</div>
@ -125,6 +138,7 @@ export default function AdminLegalSection() {
value={settings.accessibilityStatement || ''}
onChange={(e) => setSettings({ ...settings, accessibilityStatement: e.target.value })}
placeholder="https://example.com/accessibility"
disabled={!loginEnabled}
/>
</div>
@ -140,6 +154,7 @@ export default function AdminLegalSection() {
value={settings.cookiePolicy || ''}
onChange={(e) => setSettings({ ...settings, cookiePolicy: e.target.value })}
placeholder="https://example.com/cookies"
disabled={!loginEnabled}
/>
</div>
@ -155,13 +170,14 @@ export default function AdminLegalSection() {
value={settings.impressum || ''}
onChange={(e) => setSettings({ ...settings, impressum: e.target.value })}
placeholder="https://example.com/impressum"
disabled={!loginEnabled}
/>
</div>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -7,6 +7,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface PremiumSettingsData {
key?: string;
@ -15,6 +17,7 @@ interface PremiumSettingsData {
export default function AdminPremiumSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -30,10 +33,15 @@ export default function AdminPremiumSection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -46,7 +54,9 @@ export default function AdminPremiumSection() {
}
};
if (loading) {
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -56,6 +66,7 @@ export default function AdminPremiumSection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.premium.title', 'Premium & Enterprise')}</Text>
<Text size="sm" c="dimmed">
@ -98,6 +109,7 @@ export default function AdminPremiumSection() {
value={settings.key || ''}
onChange={(e) => setSettings({ ...settings, key: e.target.value })}
placeholder="00000000-0000-0000-0000-000000000000"
disabled={!loginEnabled}
/>
</div>
@ -111,7 +123,12 @@ export default function AdminPremiumSection() {
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enabled: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enabled')} />
</Group>
@ -120,7 +137,7 @@ export default function AdminPremiumSection() {
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -6,6 +6,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import apiClient from '@app/services/apiClient';
interface PrivacySettingsData {
@ -16,6 +18,7 @@ interface PrivacySettingsData {
export default function AdminPrivacySection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -76,10 +79,17 @@ export default function AdminPrivacySection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loginEnabled]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -92,7 +102,10 @@ export default function AdminPrivacySection() {
}
};
if (loading) {
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -102,6 +115,8 @@ export default function AdminPrivacySection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.privacy.title', 'Privacy')}</Text>
<Text size="sm" c="dimmed">
@ -123,8 +138,13 @@ export default function AdminPrivacySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.enableAnalytics || false}
onChange={(e) => setSettings({ ...settings, enableAnalytics: e.target.checked })}
checked={settings?.enableAnalytics || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableAnalytics: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableAnalytics')} />
</Group>
@ -139,8 +159,13 @@ export default function AdminPrivacySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.metricsEnabled || false}
onChange={(e) => setSettings({ ...settings, metricsEnabled: e.target.checked })}
checked={settings?.metricsEnabled || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, metricsEnabled: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('metricsEnabled')} />
</Group>
@ -162,8 +187,13 @@ export default function AdminPrivacySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.googleVisibility || false}
onChange={(e) => setSettings({ ...settings, googleVisibility: e.target.checked })}
checked={settings?.googleVisibility || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, googleVisibility: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('googleVisibility')} />
</Group>
@ -173,7 +203,7 @@ export default function AdminPrivacySection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -8,6 +8,8 @@ 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';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
interface SecuritySettingsData {
enableLogin?: boolean;
@ -44,6 +46,7 @@ interface SecuritySettingsData {
export default function AdminSecuritySection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
@ -157,10 +160,21 @@ export default function AdminSecuritySection() {
});
useEffect(() => {
fetchSettings();
}, []);
if (loginEnabled) {
fetchSettings();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loginEnabled]);
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
const handleSave = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
await saveSettings();
showRestartModal();
@ -173,7 +187,7 @@ export default function AdminSecuritySection() {
}
};
if (loading) {
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
@ -183,6 +197,8 @@ export default function AdminSecuritySection() {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.security.title', 'Security')}</Text>
<Text size="sm" c="dimmed">
@ -204,8 +220,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.enableLogin || false}
checked={settings?.enableLogin || false}
onChange={(e) => setSettings({ ...settings, enableLogin: e.target.checked })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('enableLogin')} />
</Group>
@ -215,7 +232,7 @@ export default function AdminSecuritySection() {
<Select
label={t('admin.settings.security.loginMethod.label', 'Login Method')}
description={t('admin.settings.security.loginMethod.description', 'The authentication method to use for user login')}
value={settings.loginMethod || 'all'}
value={settings?.loginMethod || 'all'}
onChange={(value) => setSettings({ ...settings, loginMethod: value || 'all' })}
data={[
{ value: 'all', label: t('admin.settings.security.loginMethod.all', 'All Methods') },
@ -224,6 +241,7 @@ export default function AdminSecuritySection() {
{ value: 'saml2', label: t('admin.settings.security.loginMethod.saml2', 'SAML2 Only') },
]}
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
{isFieldPending('loginMethod') && (
<Group mt="xs">
@ -241,10 +259,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.loginAttemptCount.description', 'Maximum number of failed login attempts before account lockout')}
value={settings.loginAttemptCount || 0}
value={settings?.loginAttemptCount || 0}
onChange={(value) => setSettings({ ...settings, loginAttemptCount: Number(value) })}
min={0}
max={100}
disabled={!loginEnabled}
/>
</div>
@ -257,10 +276,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.loginResetTimeMinutes.description', 'Time before failed login attempts are reset')}
value={settings.loginResetTimeMinutes || 0}
value={settings?.loginResetTimeMinutes || 0}
onChange={(value) => setSettings({ ...settings, loginResetTimeMinutes: Number(value) })}
min={0}
max={1440}
disabled={!loginEnabled}
/>
</div>
@ -273,8 +293,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.csrfDisabled || false}
checked={settings?.csrfDisabled || false}
onChange={(e) => setSettings({ ...settings, csrfDisabled: e.target.checked })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('csrfDisabled')} />
</Group>
@ -308,8 +329,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.persistence || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, persistence: e.target.checked } })}
checked={settings?.jwt?.persistence || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings?.jwt, persistence: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('jwt.persistence')} />
</Group>
@ -324,8 +346,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.enableKeyRotation || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyRotation: e.target.checked } })}
checked={settings?.jwt?.enableKeyRotation || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings?.jwt, enableKeyRotation: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('jwt.enableKeyRotation')} />
</Group>
@ -340,8 +363,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.enableKeyCleanup || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, enableKeyCleanup: e.target.checked } })}
checked={settings?.jwt?.enableKeyCleanup || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings?.jwt, enableKeyCleanup: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('jwt.enableKeyCleanup')} />
</Group>
@ -356,10 +380,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')}
value={settings.jwt?.keyRetentionDays || 7}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings.jwt, keyRetentionDays: Number(value) } })}
value={settings?.jwt?.keyRetentionDays || 7}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, keyRetentionDays: Number(value) } })}
min={1}
max={365}
disabled={!loginEnabled}
/>
</div>
@ -372,8 +397,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.jwt?.secureCookie || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, secureCookie: e.target.checked } })}
checked={settings?.jwt?.secureCookie || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings?.jwt, secureCookie: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('jwt.secureCookie')} />
</Group>
@ -398,8 +424,9 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.audit?.enabled || false}
onChange={(e) => setSettings({ ...settings, audit: { ...settings.audit, enabled: e.target.checked } })}
checked={settings?.audit?.enabled || false}
onChange={(e) => setSettings({ ...settings, audit: { ...settings?.audit, enabled: e.target.checked } })}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('audit.enabled')} />
</Group>
@ -414,10 +441,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.audit.level.description', '0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE')}
value={settings.audit?.level || 2}
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, level: Number(value) } })}
value={settings?.audit?.level || 2}
onChange={(value) => setSettings({ ...settings, audit: { ...settings?.audit, level: Number(value) } })}
min={0}
max={3}
disabled={!loginEnabled}
/>
</div>
@ -430,10 +458,11 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.audit.retentionDays.description', 'Number of days to retain audit logs')}
value={settings.audit?.retentionDays || 90}
onChange={(value) => setSettings({ ...settings, audit: { ...settings.audit, retentionDays: Number(value) } })}
value={settings?.audit?.retentionDays || 90}
onChange={(value) => setSettings({ ...settings, audit: { ...settings?.audit, retentionDays: Number(value) } })}
min={1}
max={3650}
disabled={!loginEnabled}
/>
</div>
</Stack>
@ -458,14 +487,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.enabled || false}
checked={settings?.html?.urlSecurity?.enabled || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, enabled: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, enabled: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.enabled')} />
</Group>
@ -480,12 +510,12 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.level.description', 'MAX: whitelist only, MEDIUM: block internal networks, OFF: no restrictions')}
value={settings.html?.urlSecurity?.level || 'MEDIUM'}
value={settings?.html?.urlSecurity?.level || 'MEDIUM'}
onChange={(value) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, level: value || 'MEDIUM' }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, level: value || 'MEDIUM' }
}
})}
data={[
@ -494,6 +524,7 @@ export default function AdminSecuritySection() {
{ value: 'OFF', label: t('admin.settings.security.htmlUrlSecurity.level.off', 'Off (No Restrictions)') },
]}
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
@ -512,13 +543,13 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.allowedDomains.description', 'One domain per line (e.g., cdn.example.com). Only these domains allowed when level is MAX')}
value={settings.html?.urlSecurity?.allowedDomains?.join('\n') || ''}
value={settings?.html?.urlSecurity?.allowedDomains?.join('\n') || ''}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
...settings?.html,
urlSecurity: {
...settings.html?.urlSecurity,
...settings?.html?.urlSecurity,
allowedDomains: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
@ -526,6 +557,7 @@ export default function AdminSecuritySection() {
placeholder="cdn.example.com&#10;images.google.com"
minRows={3}
autosize
disabled={!loginEnabled}
/>
</div>
@ -539,13 +571,13 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.blockedDomains.description', 'One domain per line (e.g., malicious.com). Additional domains to block')}
value={settings.html?.urlSecurity?.blockedDomains?.join('\n') || ''}
value={settings?.html?.urlSecurity?.blockedDomains?.join('\n') || ''}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
...settings?.html,
urlSecurity: {
...settings.html?.urlSecurity,
...settings?.html?.urlSecurity,
blockedDomains: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
@ -553,6 +585,7 @@ export default function AdminSecuritySection() {
placeholder="malicious.com&#10;evil.org"
minRows={3}
autosize
disabled={!loginEnabled}
/>
</div>
@ -566,13 +599,13 @@ export default function AdminSecuritySection() {
</Group>
}
description={t('admin.settings.security.htmlUrlSecurity.internalTlds.description', 'One TLD per line (e.g., .local, .internal). Block domains with these TLD patterns')}
value={settings.html?.urlSecurity?.internalTlds?.join('\n') || ''}
value={settings?.html?.urlSecurity?.internalTlds?.join('\n') || ''}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
...settings?.html,
urlSecurity: {
...settings.html?.urlSecurity,
...settings?.html?.urlSecurity,
internalTlds: e.target.value ? e.target.value.split('\n').filter(d => d.trim()) : []
}
}
@ -580,6 +613,7 @@ export default function AdminSecuritySection() {
placeholder=".local&#10;.internal&#10;.corp&#10;.home"
minRows={3}
autosize
disabled={!loginEnabled}
/>
</div>
@ -595,14 +629,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockPrivateNetworks || false}
checked={settings?.html?.urlSecurity?.blockPrivateNetworks || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, blockPrivateNetworks: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockPrivateNetworks')} />
</Group>
@ -617,14 +652,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockLocalhost || false}
checked={settings?.html?.urlSecurity?.blockLocalhost || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLocalhost: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, blockLocalhost: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockLocalhost')} />
</Group>
@ -639,14 +675,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockLinkLocal || false}
checked={settings?.html?.urlSecurity?.blockLinkLocal || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockLinkLocal: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, blockLinkLocal: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockLinkLocal')} />
</Group>
@ -661,14 +698,15 @@ export default function AdminSecuritySection() {
</div>
<Group gap="xs">
<Switch
checked={settings.html?.urlSecurity?.blockCloudMetadata || false}
checked={settings?.html?.urlSecurity?.blockCloudMetadata || false}
onChange={(e) => setSettings({
...settings,
html: {
...settings.html,
urlSecurity: { ...settings.html?.urlSecurity, blockCloudMetadata: e.target.checked }
...settings?.html,
urlSecurity: { ...settings?.html?.urlSecurity, blockCloudMetadata: e.target.checked }
}
})}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('html.urlSecurity.blockCloudMetadata')} />
</Group>
@ -682,7 +720,7 @@ export default function AdminSecuritySection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -14,9 +14,12 @@ import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services
import UsageAnalyticsChart from '@app/components/shared/config/configSections/usage/UsageAnalyticsChart';
import UsageAnalyticsTable from '@app/components/shared/config/configSections/usage/UsageAnalyticsTable';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
const AdminUsageSection: React.FC = () => {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const [data, setData] = useState<EndpointStatisticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -24,6 +27,10 @@ const AdminUsageSection: React.FC = () => {
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all');
const fetchData = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
setLoading(true);
setError(null);
@ -40,10 +47,55 @@ const AdminUsageSection: React.FC = () => {
};
useEffect(() => {
fetchData();
}, [displayMode, dataType]);
if (loginEnabled) {
fetchData();
} else {
// Provide example usage analytics data when login is disabled
const totalVisits = 15847;
const allEndpoints = [
{ endpoint: 'merge-pdfs', visits: 3245, percentage: (3245 / totalVisits) * 100 },
{ endpoint: 'compress-pdf', visits: 2891, percentage: (2891 / totalVisits) * 100 },
{ endpoint: 'pdf-to-img', visits: 2156, percentage: (2156 / totalVisits) * 100 },
{ endpoint: 'split-pdf', visits: 1834, percentage: (1834 / totalVisits) * 100 },
{ endpoint: 'rotate-pdf', visits: 1523, percentage: (1523 / totalVisits) * 100 },
{ endpoint: 'ocr-pdf', visits: 1287, percentage: (1287 / totalVisits) * 100 },
{ endpoint: 'add-watermark', visits: 945, percentage: (945 / totalVisits) * 100 },
{ endpoint: 'extract-images', visits: 782, percentage: (782 / totalVisits) * 100 },
{ endpoint: 'add-password', visits: 621, percentage: (621 / totalVisits) * 100 },
{ endpoint: 'html-to-pdf', visits: 563, percentage: (563 / totalVisits) * 100 },
{ endpoint: 'remove-password', visits: 487, percentage: (487 / totalVisits) * 100 },
{ endpoint: 'pdf-to-pdfa', visits: 423, percentage: (423 / totalVisits) * 100 },
{ endpoint: 'extract-pdf-metadata', visits: 356, percentage: (356 / totalVisits) * 100 },
{ endpoint: 'add-page-numbers', visits: 298, percentage: (298 / totalVisits) * 100 },
{ endpoint: 'crop', visits: 245, percentage: (245 / totalVisits) * 100 },
{ endpoint: 'flatten', visits: 187, percentage: (187 / totalVisits) * 100 },
{ endpoint: 'sanitize-pdf', visits: 134, percentage: (134 / totalVisits) * 100 },
{ endpoint: 'auto-split-pdf', visits: 98, percentage: (98 / totalVisits) * 100 },
{ endpoint: 'scale-pages', visits: 76, percentage: (76 / totalVisits) * 100 },
{ endpoint: 'compare-pdfs', visits: 42, percentage: (42 / totalVisits) * 100 },
];
// Filter based on display mode
let filteredEndpoints = allEndpoints;
if (displayMode === 'top10') {
filteredEndpoints = allEndpoints.slice(0, 10);
} else if (displayMode === 'top20') {
filteredEndpoints = allEndpoints.slice(0, 20);
}
setData({
totalVisits: totalVisits,
endpoints: filteredEndpoints,
});
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayMode, dataType, loginEnabled]);
const handleRefresh = () => {
if (!validateLoginEnabled()) {
return;
}
fetchData();
};
@ -60,8 +112,11 @@ const AdminUsageSection: React.FC = () => {
}
};
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
// Early returns for loading/error states
if (loading) {
if (actualLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
<Loader size="lg" />
@ -85,16 +140,18 @@ const AdminUsageSection: React.FC = () => {
);
}
const chartData = data.endpoints.map((e) => ({ label: e.endpoint, value: e.visits }));
const chartData = data?.endpoints?.map((e) => ({ label: e.endpoint, value: e.visits })) || [];
const displayedVisits = data.endpoints.reduce((sum, e) => sum + e.visits, 0);
const displayedVisits = data?.endpoints?.reduce((sum, e) => sum + e.visits, 0) || 0;
const displayedPercentage = data.totalVisits > 0
? ((displayedVisits / data.totalVisits) * 100).toFixed(1)
const displayedPercentage = (data?.totalVisits || 0) > 0
? ((displayedVisits / (data?.totalVisits || 1)) * 100).toFixed(1)
: '0';
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
{/* Controls */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
@ -103,6 +160,7 @@ const AdminUsageSection: React.FC = () => {
<SegmentedControl
value={displayMode}
onChange={(value) => setDisplayMode(value as 'top10' | 'top20' | 'all')}
disabled={!loginEnabled}
data={[
{
value: 'top10',
@ -123,6 +181,7 @@ const AdminUsageSection: React.FC = () => {
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
onClick={handleRefresh}
loading={loading}
disabled={!loginEnabled}
>
{t('usage.controls.refresh', 'Refresh')}
</Button>
@ -136,6 +195,7 @@ const AdminUsageSection: React.FC = () => {
<SegmentedControl
value={dataType}
onChange={(value) => setDataType(value as 'all' | 'api' | 'ui')}
disabled={!loginEnabled}
data={[
{
value: 'all',

View File

@ -10,6 +10,7 @@ interface ProviderCardProps {
settings?: Record<string, any>;
onSave?: (settings: Record<string, any>) => void;
onDisconnect?: () => void;
disabled?: boolean;
}
export default function ProviderCard({
@ -18,6 +19,7 @@ export default function ProviderCard({
settings = {},
onSave,
onDisconnect,
disabled = false,
}: ProviderCardProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
@ -39,6 +41,7 @@ export default function ProviderCard({
};
const handleFieldChange = (key: string, value: any) => {
if (disabled) return; // Block changes when disabled
setLocalSettings((prev) => ({ ...prev, [key]: value }));
};
@ -63,6 +66,7 @@ export default function ProviderCard({
<Switch
checked={value || false}
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
disabled={disabled}
/>
</div>
);
@ -76,6 +80,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={disabled}
/>
);
@ -88,6 +93,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={disabled}
/>
);
@ -100,6 +106,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={disabled}
/>
);
}
@ -174,11 +181,12 @@ export default function ProviderCard({
color="red"
size="sm"
onClick={onDisconnect}
disabled={disabled}
>
{t('admin.settings.connections.disconnect', 'Disconnect')}
</Button>
)}
<Button size="sm" onClick={handleSave}>
<Button size="sm" onClick={handleSave} disabled={disabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>

View File

@ -53,7 +53,11 @@ const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, title, color = 'b
);
};
const AuditChartsSection: React.FC = () => {
interface AuditChartsSectionProps {
loginEnabled?: boolean;
}
const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled = true }) => {
const { t } = useTranslation();
const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week');
const [chartsData, setChartsData] = useState<AuditChartsData | null>(null);
@ -74,8 +78,27 @@ const AuditChartsSection: React.FC = () => {
}
};
fetchChartsData();
}, [timePeriod]);
if (loginEnabled) {
fetchChartsData();
} else {
// Provide example charts data when login is disabled
setChartsData({
eventsByType: {
labels: ['LOGIN', 'LOGOUT', 'SETTINGS_CHANGE', 'FILE_UPLOAD', 'FILE_DOWNLOAD'],
values: [342, 289, 145, 678, 523],
},
eventsByUser: {
labels: ['admin', 'user1', 'user2', 'user3', 'user4'],
values: [456, 321, 287, 198, 165],
},
eventsOverTime: {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
values: [123, 145, 167, 189, 201, 87, 65],
},
});
setLoading(false);
}
}, [timePeriod, loginEnabled]);
if (loading) {
return (
@ -123,7 +146,11 @@ const AuditChartsSection: React.FC = () => {
</Text>
<SegmentedControl
value={timePeriod}
onChange={(value) => setTimePeriod(value as 'day' | 'week' | 'month')}
onChange={(value) => {
if (!loginEnabled) return;
setTimePeriod(value as 'day' | 'week' | 'month');
}}
disabled={!loginEnabled}
data={[
{ label: t('audit.charts.day', 'Day'), value: 'day' },
{ label: t('audit.charts.week', 'Week'), value: 'week' },

View File

@ -18,7 +18,11 @@ import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAuditFilters } from '@app/hooks/useAuditFilters';
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
const AuditEventsTable: React.FC = () => {
interface AuditEventsTableProps {
loginEnabled?: boolean;
}
const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true }) => {
const { t } = useTranslation();
const [events, setEvents] = useState<AuditEvent[]>([]);
const [totalPages, setTotalPages] = useState(0);
@ -51,8 +55,57 @@ const AuditEventsTable: React.FC = () => {
}
};
fetchEvents();
}, [filters, currentPage]);
if (loginEnabled) {
fetchEvents();
} else {
// Provide example audit events when login is disabled
const now = new Date();
setEvents([
{
id: '1',
timestamp: new Date(now.getTime() - 1000 * 60 * 15).toISOString(),
eventType: 'LOGIN',
username: 'admin',
ipAddress: '192.168.1.100',
details: 'User logged in successfully',
},
{
id: '2',
timestamp: new Date(now.getTime() - 1000 * 60 * 30).toISOString(),
eventType: 'FILE_UPLOAD',
username: 'user1',
ipAddress: '192.168.1.101',
details: 'Uploaded document.pdf',
},
{
id: '3',
timestamp: new Date(now.getTime() - 1000 * 60 * 45).toISOString(),
eventType: 'SETTINGS_CHANGE',
username: 'admin',
ipAddress: '192.168.1.100',
details: 'Modified system settings',
},
{
id: '4',
timestamp: new Date(now.getTime() - 1000 * 60 * 60).toISOString(),
eventType: 'FILE_DOWNLOAD',
username: 'user2',
ipAddress: '192.168.1.102',
details: 'Downloaded report.pdf',
},
{
id: '5',
timestamp: new Date(now.getTime() - 1000 * 60 * 90).toISOString(),
eventType: 'LOGOUT',
username: 'user1',
ipAddress: '192.168.1.101',
details: 'User logged out',
},
]);
setTotalPages(1);
setLoading(false);
}
}, [filters, currentPage, loginEnabled]);
// Wrap filter handlers to reset pagination
const handleFilterChangeWithReset = (key: keyof typeof filters, value: any) => {
@ -83,6 +136,7 @@ const AuditEventsTable: React.FC = () => {
users={users}
onFilterChange={handleFilterChangeWithReset}
onClearFilters={handleClearFiltersWithReset}
disabled={!loginEnabled}
/>
{/* Table */}
@ -153,6 +207,7 @@ const AuditEventsTable: React.FC = () => {
variant="subtle"
size="xs"
onClick={() => setSelectedEvent(event)}
disabled={!loginEnabled}
>
{t('audit.events.viewDetails', 'View Details')}
</Button>

View File

@ -13,7 +13,11 @@ import LocalIcon from '@app/components/shared/LocalIcon';
import { useAuditFilters } from '@app/hooks/useAuditFilters';
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
const AuditExportSection: React.FC = () => {
interface AuditExportSectionProps {
loginEnabled?: boolean;
}
const AuditExportSection: React.FC<AuditExportSectionProps> = ({ loginEnabled = true }) => {
const { t } = useTranslation();
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
const [exporting, setExporting] = useState(false);
@ -22,6 +26,8 @@ const AuditExportSection: React.FC = () => {
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters();
const handleExport = async () => {
if (!loginEnabled) return;
try {
setExporting(true);
@ -65,7 +71,11 @@ const AuditExportSection: React.FC = () => {
</Text>
<SegmentedControl
value={exportFormat}
onChange={(value) => setExportFormat(value as 'csv' | 'json')}
onChange={(value) => {
if (!loginEnabled) return;
setExportFormat(value as 'csv' | 'json');
}}
disabled={!loginEnabled}
data={[
{ label: 'CSV', value: 'csv' },
{ label: 'JSON', value: 'json' },
@ -84,6 +94,7 @@ const AuditExportSection: React.FC = () => {
users={users}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
disabled={!loginEnabled}
/>
</div>
@ -93,7 +104,7 @@ const AuditExportSection: React.FC = () => {
leftSection={<LocalIcon icon="download" width="1rem" height="1rem" />}
onClick={handleExport}
loading={exporting}
disabled={exporting}
disabled={!loginEnabled || exporting}
>
{t('audit.export.exportButton', 'Export Data')}
</Button>

View File

@ -11,6 +11,7 @@ interface AuditFiltersFormProps {
users: string[];
onFilterChange: (key: keyof AuditFilters, value: any) => void;
onClearFilters: () => void;
disabled?: boolean;
}
/**
@ -22,6 +23,7 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
users,
onFilterChange,
onClearFilters,
disabled = false,
}) => {
const { t } = useTranslation();
@ -33,6 +35,7 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
value={filters.eventType}
onChange={(value) => onFilterChange('eventType', value || undefined)}
clearable
disabled={disabled}
style={{ flex: 1, minWidth: 200 }}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
@ -43,6 +46,7 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
onChange={(value) => onFilterChange('username', value || undefined)}
clearable
searchable
disabled={disabled}
style={{ flex: 1, minWidth: 200 }}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
@ -53,6 +57,7 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
onFilterChange('startDate', value ?? undefined)
}
clearable
disabled={disabled}
style={{ flex: 1, minWidth: 150 }}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
@ -63,10 +68,11 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
onFilterChange('endDate', value ?? undefined)
}
clearable
disabled={disabled}
style={{ flex: 1, minWidth: 150 }}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button variant="outline" onClick={onClearFilters}>
<Button variant="outline" onClick={onClearFilters} disabled={disabled}>
{t('audit.events.clearFilters', 'Clear')}
</Button>
</Group>

View File

@ -0,0 +1,89 @@
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { alert } from '@app/components/toast';
import { useTranslation } from 'react-i18next';
/**
* Hook to manage login-required functionality in admin sections
* Provides login state, validation, and alert functionality
*/
export function useLoginRequired() {
const { config } = useAppConfig();
const { t } = useTranslation();
const loginEnabled = config?.enableLogin ?? true;
/**
* Show alert when user tries to modify settings with login disabled
*/
const showLoginRequiredAlert = () => {
alert({
alertType: 'warning',
title: t('admin.error', 'Error'),
body: t('admin.settings.loginRequired', 'Login mode must be enabled to modify admin settings'),
});
};
/**
* Validate that login is enabled before allowing action
* Returns true if login is enabled, false otherwise (and shows alert)
*/
const validateLoginEnabled = (): boolean => {
if (!loginEnabled) {
showLoginRequiredAlert();
return false;
}
return true;
};
/**
* Wrap an async handler to check login state before executing
*/
const withLoginCheck = <T extends (...args: any[]) => Promise<any>>(
handler: T
): T => {
return (async (...args: any[]) => {
if (!validateLoginEnabled()) {
return;
}
return handler(...args);
}) as T;
};
/**
* Get styles for disabled inputs (cursor not-allowed)
*/
const getDisabledStyles = () => {
if (!loginEnabled) {
return {
input: { cursor: 'not-allowed' },
track: { cursor: 'not-allowed' },
thumb: { cursor: 'not-allowed' }
};
}
return undefined;
};
/**
* Wrap fetch function to skip API call when login disabled
*/
const withLoginCheckForFetch = <T extends (...args: any[]) => Promise<any>>(
fetchHandler: T,
skipWhenDisabled: boolean = true
): T => {
return (async (...args: any[]) => {
if (!loginEnabled && skipWhenDisabled) {
// Skip fetch when login disabled - component will use default/empty values
return;
}
return fetchHandler(...args);
}) as T;
};
return {
loginEnabled,
showLoginRequiredAlert,
validateLoginEnabled,
withLoginCheck,
withLoginCheckForFetch,
getDisabledStyles,
};
}

View File

@ -31,4 +31,14 @@ export const accountService = {
formData.append('newPassword', newPassword);
await apiClient.post('/api/v1/user/change-password', formData);
},
/**
* Change user password on first login (resets firstLogin flag)
*/
async changePasswordOnLogin(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-on-login', formData);
},
};

View File

@ -8,10 +8,11 @@ import TeamsSection from '@app/components/shared/config/configSections/TeamsSect
*/
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false
runningEE: boolean = false,
loginEnabled: boolean = true
): ConfigNavSection[] => {
// Get the core sections
const sections = createCoreConfigNavSections(isAdmin, runningEE);
const sections = createCoreConfigNavSections(isAdmin, runningEE, loginEnabled);
// Add Workspace section if user is admin
if (isAdmin) {