mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-13 02:18:16 +01:00
settingsPage Init selfhost
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user