settingsPage Init selfhost

This commit is contained in:
Anthony Stirling
2025-10-15 23:37:27 +01:00
parent f6a7b983a0
commit b3c1b4791c
14 changed files with 2637 additions and 4 deletions

View File

@@ -44,13 +44,17 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
console.log('Logout placeholder for SaaS compatibility');
};
// TODO: Replace with actual role check from JWT/auth context
const isAdmin = true; // Emulated admin role for now
// Left navigation structure and icons
const configNavSections = useMemo(() =>
createConfigNavSections(
Overview,
handleLogout
handleLogout,
isAdmin
),
[]
[isAdmin]
);
const activeLabel = useMemo(() => {

View File

@@ -2,6 +2,15 @@ import React from 'react';
import { NavKey } from './types';
import HotkeysSection from './configSections/HotkeysSection';
import GeneralSection from './configSections/GeneralSection';
import AdminGeneralSection from './configSections/AdminGeneralSection';
import AdminSecuritySection from './configSections/AdminSecuritySection';
import AdminConnectionsSection from './configSections/AdminConnectionsSection';
import AdminPrivacySection from './configSections/AdminPrivacySection';
import AdminAdvancedSection from './configSections/AdminAdvancedSection';
import AdminMailSection from './configSections/AdminMailSection';
import AdminLegalSection from './configSections/AdminLegalSection';
import AdminPremiumSection from './configSections/AdminPremiumSection';
import AdminEndpointsSection from './configSections/AdminEndpointsSection';
export interface ConfigNavItem {
key: NavKey;
@@ -27,7 +36,8 @@ export interface ConfigColors {
export const createConfigNavSections = (
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
onLogoutClick: () => void
onLogoutClick: () => void,
isAdmin: boolean = false
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
{
@@ -60,5 +70,68 @@ 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: 'adminMail',
label: 'Mail',
icon: 'mail-rounded',
component: <AdminMailSection />
},
{
key: 'adminLegal',
label: 'Legal',
icon: 'gavel-rounded',
component: <AdminLegalSection />
},
{
key: 'adminPrivacy',
label: 'Privacy',
icon: 'visibility-rounded',
component: <AdminPrivacySection />
},
{
key: 'adminPremium',
label: 'Premium',
icon: 'star-rounded',
component: <AdminPremiumSection />
},
{
key: 'adminEndpoints',
label: 'Endpoints',
icon: 'api-rounded',
component: <AdminEndpointsSection />
},
{
key: 'adminAdvanced',
label: 'Advanced',
icon: 'tune-rounded',
component: <AdminAdvancedSection />
},
],
});
}
return sections;
};

View File

@@ -0,0 +1,202 @@
import { useState, 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 '../../../toast';
interface AdvancedSettingsData {
enableAlphaFunctionality?: boolean;
maxDPI?: number;
enableUrlToPDF?: boolean;
tessdataDir?: string;
disableSanitize?: boolean;
}
export default function AdminAdvancedSection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<AdvancedSettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const response = await fetch('/api/v1/admin/settings/section/system');
if (response.ok) {
const data = await response.json();
setSettings({
enableAlphaFunctionality: data.enableAlphaFunctionality || false,
maxDPI: data.maxDPI || 0,
enableUrlToPDF: data.enableUrlToPDF || false,
tessdataDir: data.tessdataDir || '',
disableSanitize: data.disableSanitize || false
});
}
} catch (error) {
console.error('Failed to fetch advanced settings:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.fetchError', 'Failed to load settings'),
});
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
// Use delta update endpoint with dot notation
const deltaSettings = {
'system.enableAlphaFunctionality': settings.enableAlphaFunctionality,
'system.maxDPI': settings.maxDPI,
'system.enableUrlToPDF': settings.enableUrlToPDF,
'system.tessdataDir': settings.tessdataDir,
'system.disableSanitize': settings.disableSanitize
};
const response = await fetch('/api/v1/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: deltaSettings }),
});
if (response.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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'),
});
} finally {
setSaving(false);
}
};
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>
<Switch
checked={settings.enableAlphaFunctionality || false}
onChange={(e) => setSettings({ ...settings, enableAlphaFunctionality: e.target.checked })}
/>
</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>
<Switch
checked={settings.enableUrlToPDF || false}
onChange={(e) => setSettings({ ...settings, enableUrlToPDF: e.target.checked })}
/>
</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>
<Switch
checked={settings.disableSanitize || false}
onChange={(e) => setSettings({ ...settings, disableSanitize: e.target.checked })}
/>
</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={t('admin.settings.advanced.maxDPI', 'Maximum DPI')}
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={t('admin.settings.advanced.tessdataDir', 'Tessdata Directory')}
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>
{/* Endpoints Info */}
<Paper withBorder p="md" radius="md">
<Accordion variant="separated">
<Accordion.Item value="endpoints">
<Accordion.Control>
{t('admin.settings.advanced.endpoints.manage', 'Manage API Endpoints')}
</Accordion.Control>
<Accordion.Panel>
<Text size="sm" c="dimmed">
{t('admin.settings.advanced.endpoints.description', 'Endpoint management is configured via YAML. See documentation for details on enabling/disabling specific endpoints.')}
</Text>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,341 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, Select, Badge, PasswordInput } from '@mantine/core';
import { alert } from '../../../toast';
import LocalIcon from '../../LocalIcon';
interface OAuth2Settings {
enabled?: boolean;
issuer?: string;
clientId?: string;
clientSecret?: string;
provider?: string;
autoCreateUser?: boolean;
blockRegistration?: boolean;
useAsUsername?: string;
scopes?: string;
}
interface SAML2Settings {
enabled?: boolean;
provider?: string;
autoCreateUser?: boolean;
blockRegistration?: boolean;
registrationId?: string;
}
interface ConnectionsSettingsData {
oauth2?: OAuth2Settings;
saml2?: SAML2Settings;
}
export default function AdminConnectionsSection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<ConnectionsSettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
// OAuth2 and SAML2 are nested under security section
const response = await fetch('/api/v1/admin/settings/section/security');
if (response.ok) {
const data = await response.json();
// Extract oauth2 and saml2 from security section
setSettings({
oauth2: data.oauth2 || {},
saml2: data.saml2 || {}
});
}
} catch (error) {
console.error('Failed to fetch connections settings:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.fetchError', 'Failed to load settings'),
});
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
// Use delta update endpoint with dot notation for nested oauth2/saml2 settings
const deltaSettings: Record<string, any> = {};
// Convert oauth2 settings to dot notation
if (settings.oauth2) {
Object.keys(settings.oauth2).forEach(key => {
deltaSettings[`security.oauth2.${key}`] = (settings.oauth2 as any)[key];
});
}
// Convert saml2 settings to dot notation
if (settings.saml2) {
Object.keys(settings.saml2).forEach(key => {
deltaSettings[`security.saml2.${key}`] = (settings.saml2 as any)[key];
});
}
const response = await fetch('/api/v1/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: deltaSettings }),
});
if (response.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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'),
});
} finally {
setSaving(false);
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
const getProviderIcon = (provider?: string) => {
switch (provider?.toLowerCase()) {
case 'google':
return <LocalIcon icon="google-rounded" width="1rem" height="1rem" />;
case 'github':
return <LocalIcon icon="github-rounded" width="1rem" height="1rem" />;
case 'keycloak':
return <LocalIcon icon="key-rounded" width="1rem" height="1rem" />;
default:
return <LocalIcon icon="cloud-rounded" width="1rem" height="1rem" />;
}
};
return (
<Stack gap="lg">
<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>
{/* OAuth2 Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between">
<Group gap="xs">
<LocalIcon icon="cloud-rounded" width="1.25rem" height="1.25rem" />
<Text fw={600} size="sm">{t('admin.settings.connections.oauth2', 'OAuth2')}</Text>
</Group>
<Badge color={settings.oauth2?.enabled ? 'green' : 'gray'} size="sm">
{settings.oauth2?.enabled ? t('admin.status.active', 'Active') : t('admin.status.inactive', 'Inactive')}
</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.oauth2.enabled', 'Enable OAuth2')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.oauth2.enabled.description', 'Allow users to authenticate using OAuth2 providers')}
</Text>
</div>
<Switch
checked={settings.oauth2?.enabled || false}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, enabled: e.target.checked } })}
/>
</div>
<div>
<Select
label={t('admin.settings.connections.oauth2.provider', 'Provider')}
description={t('admin.settings.connections.oauth2.provider.description', 'The OAuth2 provider to use for authentication')}
value={settings.oauth2?.provider || ''}
onChange={(value) => setSettings({ ...settings, oauth2: { ...settings.oauth2, provider: value || '' } })}
data={[
{ value: 'google', label: 'Google' },
{ value: 'github', label: 'GitHub' },
{ value: 'keycloak', label: 'Keycloak' },
]}
leftSection={getProviderIcon(settings.oauth2?.provider)}
comboboxProps={{ zIndex: 1400 }}
/>
</div>
<div>
<TextInput
label={t('admin.settings.connections.oauth2.issuer', 'Issuer URL')}
description={t('admin.settings.connections.oauth2.issuer.description', 'The OAuth2 provider issuer URL')}
value={settings.oauth2?.issuer || ''}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, issuer: e.target.value } })}
placeholder="https://accounts.google.com"
/>
</div>
<div>
<TextInput
label={t('admin.settings.connections.oauth2.clientId', 'Client ID')}
description={t('admin.settings.connections.oauth2.clientId.description', 'The OAuth2 client ID from your provider')}
value={settings.oauth2?.clientId || ''}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, clientId: e.target.value } })}
/>
</div>
<div>
<PasswordInput
label={t('admin.settings.connections.oauth2.clientSecret', 'Client Secret')}
description={t('admin.settings.connections.oauth2.clientSecret.description', 'The OAuth2 client secret from your provider')}
value={settings.oauth2?.clientSecret || ''}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, clientSecret: e.target.value } })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.oauth2.autoCreateUser', 'Auto Create Users')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.oauth2.autoCreateUser.description', 'Automatically create user accounts on first OAuth2 login')}
</Text>
</div>
<Switch
checked={settings.oauth2?.autoCreateUser || false}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, autoCreateUser: e.target.checked } })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.oauth2.blockRegistration', 'Block Registration')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.oauth2.blockRegistration.description', 'Prevent new user registration via OAuth2')}
</Text>
</div>
<Switch
checked={settings.oauth2?.blockRegistration || false}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, blockRegistration: e.target.checked } })}
/>
</div>
<div>
<TextInput
label={t('admin.settings.connections.oauth2.useAsUsername', 'Use as Username')}
description={t('admin.settings.connections.oauth2.useAsUsername.description', 'The OAuth2 claim to use as the username (e.g., email, sub)')}
value={settings.oauth2?.useAsUsername || ''}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, useAsUsername: e.target.value } })}
placeholder="email"
/>
</div>
<div>
<TextInput
label={t('admin.settings.connections.oauth2.scopes', 'Scopes')}
description={t('admin.settings.connections.oauth2.scopes.description', 'OAuth2 scopes (comma-separated, e.g., openid, profile, email)')}
value={settings.oauth2?.scopes || ''}
onChange={(e) => setSettings({ ...settings, oauth2: { ...settings.oauth2, scopes: e.target.value } })}
placeholder="openid, profile, email"
/>
</div>
</Stack>
</Paper>
{/* SAML2 Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between">
<Group gap="xs">
<LocalIcon icon="key-rounded" width="1.25rem" height="1.25rem" />
<Text fw={600} size="sm">{t('admin.settings.connections.saml2', 'SAML2')}</Text>
</Group>
<Badge color={settings.saml2?.enabled ? 'green' : 'gray'} size="sm">
{settings.saml2?.enabled ? t('admin.status.active', 'Active') : t('admin.status.inactive', 'Inactive')}
</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.saml2.enabled', 'Enable SAML2')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.saml2.enabled.description', 'Allow users to authenticate using SAML2 providers')}
</Text>
</div>
<Switch
checked={settings.saml2?.enabled || false}
onChange={(e) => setSettings({ ...settings, saml2: { ...settings.saml2, enabled: e.target.checked } })}
/>
</div>
<div>
<TextInput
label={t('admin.settings.connections.saml2.provider', 'Provider')}
description={t('admin.settings.connections.saml2.provider.description', 'The SAML2 provider name')}
value={settings.saml2?.provider || ''}
onChange={(e) => setSettings({ ...settings, saml2: { ...settings.saml2, provider: e.target.value } })}
/>
</div>
<div>
<TextInput
label={t('admin.settings.connections.saml2.registrationId', 'Registration ID')}
description={t('admin.settings.connections.saml2.registrationId.description', 'The SAML2 registration identifier')}
value={settings.saml2?.registrationId || ''}
onChange={(e) => setSettings({ ...settings, saml2: { ...settings.saml2, registrationId: e.target.value } })}
placeholder="stirling"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.saml2.autoCreateUser', 'Auto Create Users')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.saml2.autoCreateUser.description', 'Automatically create user accounts on first SAML2 login')}
</Text>
</div>
<Switch
checked={settings.saml2?.autoCreateUser || false}
onChange={(e) => setSettings({ ...settings, saml2: { ...settings.saml2, autoCreateUser: e.target.checked } })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.saml2.blockRegistration', 'Block Registration')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.saml2.blockRegistration.description', 'Prevent new user registration via SAML2')}
</Text>
</div>
<Switch
checked={settings.saml2?.blockRegistration || false}
onChange={(e) => setSettings({ ...settings, saml2: { ...settings.saml2, blockRegistration: e.target.checked } })}
/>
</div>
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,180 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Stack, Paper, Text, Loader, Group, MultiSelect } from '@mantine/core';
import { alert } from '../../../toast';
interface EndpointsSettingsData {
toRemove?: string[];
groupsToRemove?: string[];
}
export default function AdminEndpointsSection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<EndpointsSettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const response = await fetch('/api/v1/admin/settings/section/endpoints');
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) {
console.error('Failed to fetch endpoints settings:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.fetchError', 'Failed to load settings'),
});
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch('/api/v1/admin/settings/section/endpoints', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (response.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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'),
});
} finally {
setSaving(false);
}
};
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={t('admin.settings.endpoints.toRemove', 'Disabled Endpoints')}
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={t('admin.settings.endpoints.groupsToRemove', 'Disabled Endpoint Groups')}
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>
</Stack>
);
}

View File

@@ -0,0 +1,250 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect } from '@mantine/core';
import { alert } from '../../../toast';
interface GeneralSettingsData {
ui: {
appName?: string;
homeDescription?: string;
appNameNavbar?: string;
languages?: string[];
};
system: {
defaultLocale?: string;
showUpdate?: boolean;
showUpdateOnlyAdmin?: boolean;
customHTMLFiles?: boolean;
fileUploadLimit?: string;
};
}
export default function AdminGeneralSection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<GeneralSettingsData>({
ui: {},
system: {},
});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
// Fetch both ui and system sections from proprietary admin API
const [uiResponse, systemResponse] = await Promise.all([
fetch('/api/v1/admin/settings/section/ui'),
fetch('/api/v1/admin/settings/section/system')
]);
if (uiResponse.ok && systemResponse.ok) {
const ui = await uiResponse.json();
const system = await systemResponse.json();
setSettings({ ui, system });
}
} catch (error) {
console.error('Failed to fetch general settings:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.fetchError', 'Failed to load settings'),
});
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
// Save both ui and system sections separately using proprietary admin API
const [uiResponse, systemResponse] = await Promise.all([
fetch('/api/v1/admin/settings/section/ui', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings.ui),
}),
fetch('/api/v1/admin/settings/section/system', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings.system),
})
]);
if (uiResponse.ok && systemResponse.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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'),
});
} finally {
setSaving(false);
}
};
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>
{/* UI Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.general.ui', 'User Interface')}</Text>
<div>
<TextInput
label={t('admin.settings.general.appName', 'Application Name')}
description={t('admin.settings.general.appName.description', 'The name displayed in the browser tab and home page')}
value={settings.ui.appName || ''}
onChange={(e) => setSettings({ ...settings, ui: { ...settings.ui, appName: e.target.value } })}
placeholder="Stirling PDF"
/>
</div>
<div>
<TextInput
label={t('admin.settings.general.appNameNavbar', 'Navbar Brand')}
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>
<TextInput
label={t('admin.settings.general.homeDescription', 'Home Description')}
description={t('admin.settings.general.homeDescription.description', 'The description text shown on the home page')}
value={settings.ui.homeDescription || ''}
onChange={(e) => setSettings({ ...settings, ui: { ...settings.ui, homeDescription: e.target.value } })}
placeholder="Your locally hosted one-stop-shop for all your PDF needs"
/>
</div>
<div>
<MultiSelect
label={t('admin.settings.general.languages', 'Available Languages')}
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>
</Stack>
</Paper>
{/* 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={t('admin.settings.general.defaultLocale', 'Default Locale')}
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={t('admin.settings.general.fileUploadLimit', 'File Upload Limit')}
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>
<Switch
checked={settings.system.showUpdate || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdate: e.target.checked } })}
/>
</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>
<Switch
checked={settings.system.showUpdateOnlyAdmin || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, showUpdateOnlyAdmin: e.target.checked } })}
/>
</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>
<Switch
checked={settings.system.customHTMLFiles || false}
onChange={(e) => setSettings({ ...settings, system: { ...settings.system, customHTMLFiles: e.target.checked } })}
/>
</div>
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,150 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core';
import { alert } from '../../../toast';
interface LegalSettingsData {
termsAndConditions?: string;
privacyPolicy?: string;
accessibilityStatement?: string;
cookiePolicy?: string;
impressum?: string;
}
export default function AdminLegalSection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<LegalSettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const response = await fetch('/api/v1/admin/settings/section/legal');
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) {
console.error('Failed to fetch legal settings:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.fetchError', 'Failed to load settings'),
});
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch('/api/v1/admin/settings/section/legal', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (response.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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'),
});
} finally {
setSaving(false);
}
};
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>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={t('admin.settings.legal.termsAndConditions', 'Terms and Conditions')}
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={t('admin.settings.legal.privacyPolicy', 'Privacy Policy')}
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={t('admin.settings.legal.accessibilityStatement', 'Accessibility Statement')}
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={t('admin.settings.legal.cookiePolicy', 'Cookie Policy')}
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={t('admin.settings.legal.impressum', 'Impressum')}
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>
</Stack>
);
}

View File

@@ -0,0 +1,163 @@
import { useState, 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 '../../../toast';
interface MailSettingsData {
enabled?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
from?: string;
}
export default function AdminMailSection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<MailSettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const response = await fetch('/api/v1/admin/settings/section/mail');
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) {
console.error('Failed to fetch mail settings:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.fetchError', 'Failed to load settings'),
});
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch('/api/v1/admin/settings/section/mail', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (response.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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'),
});
} finally {
setSaving(false);
}
};
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">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<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>
<Switch
checked={settings.enabled || false}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
/>
</div>
<div>
<TextInput
label={t('admin.settings.mail.host', 'SMTP Host')}
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={t('admin.settings.mail.port', 'SMTP Port')}
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={t('admin.settings.mail.username', 'SMTP Username')}
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={t('admin.settings.mail.password', 'SMTP Password')}
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={t('admin.settings.mail.from', 'From Address')}
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>
</Stack>
);
}

View File

@@ -0,0 +1,310 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core';
import { alert } from '../../../toast';
interface PremiumSettingsData {
key?: string;
enabled?: boolean;
proFeatures?: {
SSOAutoLogin?: boolean;
CustomMetadata?: {
autoUpdateMetadata?: boolean;
author?: string;
creator?: string;
producer?: string;
};
};
enterpriseFeatures?: {
audit?: {
enabled?: boolean;
level?: number;
retentionDays?: number;
};
};
}
export default function AdminPremiumSection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<PremiumSettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const response = await fetch('/api/v1/admin/settings/section/premium');
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) {
console.error('Failed to fetch premium settings:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.fetchError', 'Failed to load settings'),
});
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch('/api/v1/admin/settings/section/premium', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (response.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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'),
});
} finally {
setSaving(false);
}
};
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 premium and enterprise features.')}
</Text>
</div>
{/* License */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.premium.license', 'License')}</Text>
<div>
<TextInput
label={t('admin.settings.premium.key', 'License Key')}
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>
<Switch
checked={settings.enabled || false}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
/>
</div>
</Stack>
</Paper>
{/* Pro Features */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.premium.proFeatures', 'Pro Features')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.premium.ssoAutoLogin', 'SSO Auto Login')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.premium.ssoAutoLogin.description', 'Automatically redirect to SSO login')}
</Text>
</div>
<Switch
checked={settings.proFeatures?.SSOAutoLogin || false}
onChange={(e) => setSettings({
...settings,
proFeatures: { ...settings.proFeatures, SSOAutoLogin: e.target.checked }
})}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.premium.customMetadata.autoUpdate', 'Auto Update Metadata')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.premium.customMetadata.autoUpdate.description', 'Automatically update PDF metadata')}
</Text>
</div>
<Switch
checked={settings.proFeatures?.CustomMetadata?.autoUpdateMetadata || false}
onChange={(e) => setSettings({
...settings,
proFeatures: {
...settings.proFeatures,
CustomMetadata: {
...settings.proFeatures?.CustomMetadata,
autoUpdateMetadata: e.target.checked
}
}
})}
/>
</div>
<div>
<TextInput
label={t('admin.settings.premium.customMetadata.author', 'Default Author')}
description={t('admin.settings.premium.customMetadata.author.description', 'Default author for PDF metadata')}
value={settings.proFeatures?.CustomMetadata?.author || ''}
onChange={(e) => setSettings({
...settings,
proFeatures: {
...settings.proFeatures,
CustomMetadata: {
...settings.proFeatures?.CustomMetadata,
author: e.target.value
}
}
})}
placeholder="username"
/>
</div>
<div>
<TextInput
label={t('admin.settings.premium.customMetadata.creator', 'Default Creator')}
description={t('admin.settings.premium.customMetadata.creator.description', 'Default creator for PDF metadata')}
value={settings.proFeatures?.CustomMetadata?.creator || ''}
onChange={(e) => setSettings({
...settings,
proFeatures: {
...settings.proFeatures,
CustomMetadata: {
...settings.proFeatures?.CustomMetadata,
creator: e.target.value
}
}
})}
placeholder="Stirling-PDF"
/>
</div>
<div>
<TextInput
label={t('admin.settings.premium.customMetadata.producer', 'Default Producer')}
description={t('admin.settings.premium.customMetadata.producer.description', 'Default producer for PDF metadata')}
value={settings.proFeatures?.CustomMetadata?.producer || ''}
onChange={(e) => setSettings({
...settings,
proFeatures: {
...settings.proFeatures,
CustomMetadata: {
...settings.proFeatures?.CustomMetadata,
producer: e.target.value
}
}
})}
placeholder="Stirling-PDF"
/>
</div>
</Stack>
</Paper>
{/* Enterprise Features */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.premium.enterpriseFeatures', 'Enterprise Features')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.premium.audit.enabled', 'Enable Audit Logging')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.premium.audit.enabled.description', 'Track user actions and system events')}
</Text>
</div>
<Switch
checked={settings.enterpriseFeatures?.audit?.enabled || false}
onChange={(e) => setSettings({
...settings,
enterpriseFeatures: {
...settings.enterpriseFeatures,
audit: {
...settings.enterpriseFeatures?.audit,
enabled: e.target.checked
}
}
})}
/>
</div>
<div>
<NumberInput
label={t('admin.settings.premium.audit.level', 'Audit Level')}
description={t('admin.settings.premium.audit.level.description', '0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE')}
value={settings.enterpriseFeatures?.audit?.level || 2}
onChange={(value) => setSettings({
...settings,
enterpriseFeatures: {
...settings.enterpriseFeatures,
audit: {
...settings.enterpriseFeatures?.audit,
level: Number(value)
}
}
})}
min={0}
max={3}
/>
</div>
<div>
<NumberInput
label={t('admin.settings.premium.audit.retentionDays', 'Audit Retention (days)')}
description={t('admin.settings.premium.audit.retentionDays.description', 'Number of days to retain audit logs')}
value={settings.enterpriseFeatures?.audit?.retentionDays || 90}
onChange={(value) => setSettings({
...settings,
enterpriseFeatures: {
...settings.enterpriseFeatures,
audit: {
...settings.enterpriseFeatures?.audit,
retentionDays: Number(value)
}
}
})}
min={1}
max={3650}
/>
</div>
</Stack>
</Paper>
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,166 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Switch, Button, Stack, Paper, Text, Loader, Group } from '@mantine/core';
import { alert } from '../../../toast';
interface PrivacySettingsData {
enableAnalytics?: boolean;
googleVisibility?: boolean;
metricsEnabled?: boolean;
}
export default function AdminPrivacySection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<PrivacySettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
// Fetch metrics and system sections
const [metricsResponse, systemResponse] = await Promise.all([
fetch('/api/v1/admin/settings/section/metrics'),
fetch('/api/v1/admin/settings/section/system')
]);
if (metricsResponse.ok && systemResponse.ok) {
const metrics = await metricsResponse.json();
const system = await systemResponse.json();
setSettings({
enableAnalytics: system.enableAnalytics || false,
googleVisibility: system.googlevisibility || false,
metricsEnabled: metrics.enabled || false
});
}
} catch (error) {
console.error('Failed to fetch privacy settings:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.fetchError', 'Failed to load settings'),
});
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
// Use delta update endpoint with dot notation for cross-section settings
const deltaSettings = {
'system.enableAnalytics': settings.enableAnalytics,
'system.googlevisibility': settings.googleVisibility,
'metrics.enabled': settings.metricsEnabled
};
const response = await fetch('/api/v1/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: deltaSettings }),
});
if (response.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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'),
});
} finally {
setSaving(false);
}
};
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>
<Switch
checked={settings.enableAnalytics || false}
onChange={(e) => setSettings({ ...settings, enableAnalytics: e.target.checked })}
/>
</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>
<Switch
checked={settings.metricsEnabled || false}
onChange={(e) => setSettings({ ...settings, metricsEnabled: e.target.checked })}
/>
</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>
<Switch
checked={settings.googleVisibility || false}
onChange={(e) => setSettings({ ...settings, googleVisibility: e.target.checked })}
/>
</div>
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,276 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Select, PasswordInput } from '@mantine/core';
import { alert } from '../../../toast';
interface SecuritySettingsData {
enableLogin?: boolean;
csrfDisabled?: boolean;
loginMethod?: string;
loginAttemptCount?: number;
loginResetTimeMinutes?: number;
initialLogin?: {
username?: string;
password?: string;
};
jwt?: {
persistence?: boolean;
enableKeyRotation?: boolean;
enableKeyCleanup?: boolean;
keyRetentionDays?: number;
secureCookie?: boolean;
};
}
export default function AdminSecuritySection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<SecuritySettingsData>({});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const response = await fetch('/api/v1/admin/settings/section/security');
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) {
console.error('Failed to fetch security settings:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.fetchError', 'Failed to load settings'),
});
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch('/api/v1/admin/settings/section/security', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (response.ok) {
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saved', 'Settings saved. Restart required for changes to take effect.'),
});
} 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'),
});
} finally {
setSaving(false);
}
};
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>
<Switch
checked={settings.enableLogin || false}
onChange={(e) => setSettings({ ...settings, enableLogin: e.target.checked })}
/>
</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 }}
/>
</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>
<Switch
checked={settings.csrfDisabled || false}
onChange={(e) => setSettings({ ...settings, csrfDisabled: e.target.checked })}
/>
</div>
</Stack>
</Paper>
{/* Initial Login Credentials */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.initialLogin', 'Initial Login')}</Text>
<div>
<TextInput
label={t('admin.settings.security.initialLogin.username', 'Initial Username')}
description={t('admin.settings.security.initialLogin.username.description', 'Default admin username for first-time setup')}
value={settings.initialLogin?.username || ''}
onChange={(e) => setSettings({ ...settings, initialLogin: { ...settings.initialLogin, username: e.target.value } })}
placeholder="admin"
/>
</div>
<div>
<PasswordInput
label={t('admin.settings.security.initialLogin.password', 'Initial Password')}
description={t('admin.settings.security.initialLogin.password.description', 'Default admin password for first-time setup')}
value={settings.initialLogin?.password || ''}
onChange={(e) => setSettings({ ...settings, initialLogin: { ...settings.initialLogin, password: e.target.value } })}
placeholder="••••••••"
/>
</div>
</Stack>
</Paper>
{/* 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>
<Switch
checked={settings.jwt?.persistence || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, persistence: e.target.checked } })}
/>
</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>
<Switch
checked={settings.jwt?.secureCookie || false}
onChange={(e) => setSettings({ ...settings, jwt: { ...settings.jwt, secureCookie: e.target.checked } })}
/>
</div>
</Stack>
</Paper>
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
);
}

View File

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