remove unused settings and enhance others

This commit is contained in:
Anthony Stirling 2025-10-16 16:25:05 +01:00
parent 535c95b1cb
commit d70ec668f1
10 changed files with 850 additions and 339 deletions

View File

@ -0,0 +1,3 @@
<svg width="37" height="36" viewBox="0 0 37 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.5 3.3125C16.5712 3.3125 14.6613 3.6924 12.8793 4.43052C11.0974 5.16864 9.47823 6.25051 8.11437 7.61437C5.35993 10.3688 3.8125 14.1046 3.8125 18C3.8125 24.4919 8.02781 29.9997 13.8588 31.9531C14.5931 32.0706 14.8281 31.6153 14.8281 31.2187V28.7366C10.7597 29.6178 9.89312 26.7684 9.89312 26.7684C9.2175 25.0647 8.26281 24.6094 8.26281 24.6094C6.92625 23.6987 8.36562 23.7281 8.36562 23.7281C9.83437 23.8309 10.6128 25.2409 10.6128 25.2409C11.8906 27.4734 14.0497 26.8125 14.8869 26.46C15.0191 25.5053 15.4009 24.8591 15.8122 24.4919C12.5516 24.1247 9.12937 22.8616 9.12937 17.2656C9.12937 15.6353 9.6875 14.3281 10.6422 13.2853C10.4953 12.9181 9.98125 11.3906 10.7891 9.40781C10.7891 9.40781 12.0228 9.01125 14.8281 10.9059C15.9884 10.5828 17.2516 10.4212 18.5 10.4212C19.7484 10.4212 21.0116 10.5828 22.1719 10.9059C24.9772 9.01125 26.2109 9.40781 26.2109 9.40781C27.0188 11.3906 26.5047 12.9181 26.3578 13.2853C27.3125 14.3281 27.8706 15.6353 27.8706 17.2656C27.8706 22.8762 24.4338 24.11 21.1584 24.4772C21.6872 24.9325 22.1719 25.8284 22.1719 27.1944V31.2187C22.1719 31.6153 22.4069 32.0853 23.1559 31.9531C28.9869 29.985 33.1875 24.4919 33.1875 18C33.1875 16.0712 32.8076 14.1613 32.0695 12.3793C31.3314 10.5974 30.2495 8.97823 28.8856 7.61437C27.5218 6.25051 25.9026 5.16864 24.1207 4.43052C22.3387 3.6924 20.4288 3.3125 18.5 3.3125Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,14 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2781_85129)">
<path d="M8.36055 0.789432C5.96258 1.62131 3.89457 3.20024 2.46029 5.29431C1.026 7.38838 0.301037 9.8872 0.391883 12.4237C0.482728 14.9603 1.38459 17.4008 2.96501 19.3869C4.54543 21.373 6.72109 22.8 9.17243 23.4582C11.1598 23.971 13.2419 23.9935 15.2399 23.5238C17.0499 23.1172 18.7233 22.2476 20.0962 21.0001C21.5251 19.662 22.5622 17.9597 23.0962 16.0763C23.6765 14.0282 23.7798 11.8743 23.3981 9.78006H12.2381V14.4094H18.7012C18.572 15.1478 18.2952 15.8525 17.8873 16.4814C17.4795 17.1102 16.9489 17.6504 16.3274 18.0694C15.5382 18.5915 14.6485 18.9428 13.7156 19.1007C12.7798 19.2747 11.82 19.2747 10.8843 19.1007C9.93591 18.9046 9.03874 18.5132 8.24993 17.9513C6.98271 17.0543 6.0312 15.7799 5.53118 14.3101C5.02271 12.8127 5.02271 11.1893 5.53118 9.69193C5.8871 8.64234 6.47549 7.68669 7.25243 6.89631C8.14154 5.97521 9.26718 5.3168 10.5058 4.99332C11.7445 4.66985 13.0484 4.6938 14.2743 5.06256C15.232 5.35654 16.1078 5.87019 16.8318 6.56256C17.5606 5.83756 18.2881 5.11068 19.0143 4.38193C19.3893 3.99006 19.7981 3.61693 20.1674 3.21568C19.0622 2.1872 17.765 1.38691 16.3499 0.860682C13.7731 -0.0749615 10.9536 -0.100106 8.36055 0.789432Z" fill="white"/>
<path d="M8.3607 0.789367C10.9536 -0.100776 13.7731 -0.0762934 16.3501 0.858742C17.7654 1.38855 19.062 2.19269 20.1657 3.22499C19.7907 3.62624 19.3951 4.00124 19.0126 4.39124C18.2851 5.11749 17.5582 5.84124 16.832 6.56249C16.1079 5.87012 15.2321 5.35648 14.2745 5.06249C13.0489 4.69244 11.7451 4.66711 10.5061 4.98926C9.26712 5.31141 8.14079 5.96861 7.2507 6.88874C6.47377 7.67912 5.88538 8.63477 5.52945 9.68437L1.64258 6.67499C3.03384 3.91604 5.44273 1.80566 8.3607 0.789367Z" fill="#E33629"/>
<path d="M0.611401 9.65654C0.820316 8.62116 1.16716 7.61847 1.64265 6.67529L5.52953 9.69217C5.02105 11.1896 5.02105 12.8129 5.52953 14.3103C4.23453 15.3103 2.9389 16.3153 1.64265 17.3253C0.452308 14.9559 0.0892746 12.2562 0.611401 9.65654Z" fill="#F8BD00"/>
<path d="M12.2381 9.77783H23.3981C23.7799 11.8721 23.6766 14.026 23.0963 16.0741C22.5623 17.9575 21.5252 19.6597 20.0963 20.9978C18.8419 20.0191 17.5819 19.0478 16.3275 18.0691C16.9494 17.6496 17.4802 17.1089 17.8881 16.4793C18.296 15.8498 18.5726 15.1444 18.7013 14.4053H12.2381C12.2363 12.8641 12.2381 11.321 12.2381 9.77783Z" fill="#587DBD"/>
<path d="M1.64062 17.3251C2.93687 16.3251 4.2325 15.3201 5.5275 14.3101C6.02851 15.7804 6.98138 17.0549 8.25 17.9513C9.04126 18.5106 9.94037 18.8988 10.89 19.0913C11.8257 19.2653 12.7855 19.2653 13.7212 19.0913C14.6542 18.9334 15.5439 18.5821 16.3331 18.0601C17.5875 19.0388 18.8475 20.0101 20.1019 20.9888C18.7292 22.237 17.0558 23.1073 15.2456 23.5144C13.2476 23.9841 11.1655 23.9616 9.17812 23.4488C7.60632 23.0291 6.13814 22.2893 4.86562 21.2757C3.51874 20.2063 2.41867 18.8588 1.64062 17.3251Z" fill="#319F43"/>
</g>
<defs>
<clipPath id="clip0_2781_85129">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -3213,6 +3213,8 @@
"admin": {
"error": "Error",
"success": "Success",
"expand": "Expand",
"close": "Close",
"status": {
"active": "Active",
"inactive": "Inactive"
@ -3223,6 +3225,7 @@
"fetchError": "Failed to load settings",
"saveError": "Failed to save settings",
"saved": "Settings saved successfully",
"saveSuccess": "Settings saved successfully",
"save": "Save Changes",
"restartRequired": "Settings changes require a server restart to take effect.",
"general": {
@ -3252,6 +3255,10 @@
"security": {
"title": "Security",
"description": "Configure authentication, login behaviour, and security policies.",
"ssoNotice": {
"title": "Looking for SSO/SAML settings?",
"message": "OAuth2 and SAML2 authentication providers have been moved to the Connections menu for easier management."
},
"authentication": "Authentication",
"enableLogin": "Enable Login",
"enableLogin.description": "Require users to log in before accessing the application",
@ -3287,6 +3294,12 @@
"connections": {
"title": "Connections",
"description": "Configure external authentication providers like OAuth2 and SAML.",
"linkedServices": "Linked Services",
"unlinkedServices": "Unlinked Services",
"connect": "Connect",
"disconnect": "Disconnect",
"disconnected": "Provider disconnected successfully",
"disconnectError": "Failed to disconnect provider",
"oauth2": "OAuth2",
"oauth2.enabled": "Enable OAuth2",
"oauth2.enabled.description": "Allow users to authenticate using OAuth2 providers",

View File

@ -7,7 +7,6 @@ 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';
@ -93,12 +92,6 @@ export const createConfigNavSections = (
icon: 'link-rounded',
component: <AdminConnectionsSection />
},
{
key: 'adminMail',
label: 'Mail',
icon: 'mail-rounded',
component: <AdminMailSection />
},
{
key: 'adminLegal',
label: 'Legal',

View File

@ -1,40 +1,49 @@
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 { Stack, Text, Loader, Group, Divider } from '@mantine/core';
import { alert } from '../../../toast';
import LocalIcon from '../../LocalIcon';
import RestartConfirmationModal from '../RestartConfirmationModal';
import { useRestartServer } from '../useRestartServer';
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;
}
import ProviderCard from './ProviderCard';
import {
ALL_PROVIDERS,
OAUTH2_PROVIDERS,
GENERIC_OAUTH2_PROVIDER,
SAML2_PROVIDER,
Provider,
} from './providerDefinitions';
interface ConnectionsSettingsData {
oauth2?: OAuth2Settings;
saml2?: SAML2Settings;
oauth2?: {
enabled?: boolean;
issuer?: string;
clientId?: string;
clientSecret?: string;
provider?: string;
autoCreateUser?: boolean;
blockRegistration?: boolean;
useAsUsername?: string;
scopes?: string;
client?: {
[key: string]: any;
};
};
saml2?: {
[key: string]: any;
};
mail?: {
enabled?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
from?: string;
};
}
export default function AdminConnectionsSection() {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<ConnectionsSettingsData>({});
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
@ -44,16 +53,19 @@ export default function AdminConnectionsSection() {
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 || {}
});
}
// Fetch security settings (oauth2, saml2)
const securityResponse = await fetch('/api/v1/admin/settings/section/security');
const securityData = securityResponse.ok ? await securityResponse.json() : {};
// Fetch mail settings
const mailResponse = await fetch('/api/v1/admin/settings/section/mail');
const mailData = mailResponse.ok ? await mailResponse.json() : {};
setSettings({
oauth2: securityData.oauth2 || {},
saml2: securityData.saml2 || {},
mail: mailData || {}
});
} catch (error) {
console.error('Failed to fetch connections settings:', error);
alert({
@ -66,36 +78,111 @@ export default function AdminConnectionsSection() {
}
};
const handleSave = async () => {
setSaving(true);
const isProviderConfigured = (provider: Provider): boolean => {
if (provider.id === 'saml2') {
return settings.saml2?.enabled === true;
}
if (provider.id === 'smtp') {
return settings.mail?.enabled === true;
}
if (provider.id === 'oauth2-generic') {
return settings.oauth2?.enabled === true;
}
// Check if specific OAuth2 provider is configured (has clientId)
const providerSettings = settings.oauth2?.client?.[provider.id];
return !!(providerSettings?.clientId);
};
const getProviderSettings = (provider: Provider): Record<string, any> => {
if (provider.id === 'saml2') {
return settings.saml2 || {};
}
if (provider.id === 'smtp') {
return settings.mail || {};
}
if (provider.id === 'oauth2-generic') {
// Generic OAuth2 settings are at the root oauth2 level
return {
enabled: settings.oauth2?.enabled,
provider: settings.oauth2?.provider,
issuer: settings.oauth2?.issuer,
clientId: settings.oauth2?.clientId,
clientSecret: settings.oauth2?.clientSecret,
scopes: settings.oauth2?.scopes,
useAsUsername: settings.oauth2?.useAsUsername,
autoCreateUser: settings.oauth2?.autoCreateUser,
blockRegistration: settings.oauth2?.blockRegistration,
};
}
// Specific OAuth2 provider settings
return settings.oauth2?.client?.[provider.id] || {};
};
const handleProviderSave = async (provider: Provider, providerSettings: Record<string, any>) => {
try {
// 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];
if (provider.id === 'smtp') {
// Mail settings use a different endpoint
const response = await fetch('/api/v1/admin/settings/section/mail', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(providerSettings),
});
}
// 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) {
showRestartModal();
if (response.ok) {
await fetchSettings(); // Refresh settings
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saveSuccess', 'Settings saved successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to save');
}
} else {
throw new Error('Failed to save');
// OAuth2/SAML2 use delta settings
const deltaSettings: Record<string, any> = {};
if (provider.id === 'saml2') {
// SAML2 settings
Object.keys(providerSettings).forEach((key) => {
deltaSettings[`security.saml2.${key}`] = providerSettings[key];
});
} else if (provider.id === 'oauth2-generic') {
// Generic OAuth2 settings at root level
Object.keys(providerSettings).forEach((key) => {
deltaSettings[`security.oauth2.${key}`] = providerSettings[key];
});
} else {
// Specific OAuth2 provider (google, github, keycloak)
Object.keys(providerSettings).forEach((key) => {
deltaSettings[`security.oauth2.client.${provider.id}.${key}`] = providerSettings[key];
});
}
const response = await fetch('/api/v1/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: deltaSettings }),
});
if (response.ok) {
await fetchSettings(); // Refresh settings
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.saveSuccess', 'Settings saved successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to save');
}
}
} catch (error) {
alert({
@ -103,8 +190,68 @@ export default function AdminConnectionsSection() {
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
} finally {
setSaving(false);
}
};
const handleProviderDisconnect = async (provider: Provider) => {
try {
if (provider.id === 'smtp') {
// Mail settings use a different endpoint
const response = await fetch('/api/v1/admin/settings/section/mail', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: false }),
});
if (response.ok) {
await fetchSettings();
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.connections.disconnected', 'Provider disconnected successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to disconnect');
}
} else {
const deltaSettings: Record<string, any> = {};
if (provider.id === 'saml2') {
deltaSettings['security.saml2.enabled'] = false;
} else if (provider.id === 'oauth2-generic') {
deltaSettings['security.oauth2.enabled'] = false;
} else {
// Clear all fields for specific OAuth2 provider
provider.fields.forEach((field) => {
deltaSettings[`security.oauth2.client.${provider.id}.${field.key}`] = '';
});
}
const response = await fetch('/api/v1/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: deltaSettings }),
});
if (response.ok) {
await fetchSettings();
alert({
alertType: 'success',
title: t('admin.success', 'Success'),
body: t('admin.settings.connections.disconnected', 'Provider disconnected successfully'),
});
showRestartModal();
} else {
throw new Error('Failed to disconnect');
}
}
} catch (error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.connections.disconnectError', 'Failed to disconnect provider'),
});
}
};
@ -116,225 +263,68 @@ export default function AdminConnectionsSection() {
);
}
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" />;
}
};
const linkedProviders = ALL_PROVIDERS.filter((p) => isProviderConfigured(p));
const availableProviders = ALL_PROVIDERS.filter((p) => !isProviderConfigured(p));
return (
<Stack gap="lg">
<Stack gap="xl">
{/* Header */}
<div>
<Text fw={600} size="lg">{t('admin.settings.connections.title', 'Connections')}</Text>
<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.')}
{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>
{/* Linked Services Section - Only show if there are linked providers */}
{linkedProviders.length > 0 && (
<>
<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 }}
/>
<Text fw={600} size="md" mb="md">
{t('admin.settings.connections.linkedServices', 'Linked Services')}
</Text>
<Stack gap="sm">
{linkedProviders.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isConfigured={true}
settings={getProviderSettings(provider)}
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
onDisconnect={() => handleProviderDisconnect(provider)}
/>
))}
</Stack>
</div>
<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>
{/* Divider between sections */}
{availableProviders.length > 0 && <Divider />}
</>
)}
<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>
{/* Unlinked Services Section */}
{availableProviders.length > 0 && (
<div>
<Text fw={600} size="md" mb="md">
{t('admin.settings.connections.unlinkedServices', 'Unlinked Services')}
</Text>
<Stack gap="sm">
{availableProviders.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isConfigured={false}
onSave={(providerSettings) => handleProviderSave(provider, providerSettings)}
/>
))}
</Stack>
</div>
)}
{/* Restart Confirmation Modal */}
<RestartConfirmationModal

View File

@ -111,20 +111,10 @@ export default function AdminGeneralSection() {
</Text>
</div>
{/* UI Settings */}
{/* System 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>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.general.system', 'System')}</Text>
<div>
<TextInput
@ -136,16 +126,6 @@ export default function AdminGeneralSection() {
/>
</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')}
@ -170,13 +150,6 @@ export default function AdminGeneralSection() {
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

View File

@ -1,7 +1,8 @@
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 { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Select, PasswordInput, Alert } from '@mantine/core';
import { alert } from '../../../toast';
import LocalIcon from '../../LocalIcon';
import RestartConfirmationModal from '../RestartConfirmationModal';
import { useRestartServer } from '../useRestartServer';
@ -11,10 +12,6 @@ interface SecuritySettingsData {
loginMethod?: string;
loginAttemptCount?: number;
loginResetTimeMinutes?: number;
initialLogin?: {
username?: string;
password?: string;
};
jwt?: {
persistence?: boolean;
enableKeyRotation?: boolean;
@ -167,32 +164,17 @@ export default function AdminSecuritySection() {
</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>
{/* SSO/SAML Notice */}
<Alert
variant="light"
color="blue"
title={t('admin.settings.security.ssoNotice.title', 'Looking for SSO/SAML settings?')}
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.security.ssoNotice.message', 'OAuth2 and SAML2 authentication providers have been moved to the Connections menu for easier management.')}
</Text>
</Alert>
{/* JWT Settings */}
<Paper withBorder p="md" radius="md">

View File

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

View File

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

View File

@ -19,7 +19,6 @@ export type NavKey =
| 'adminConnections'
| 'adminPrivacy'
| 'adminAdvanced'
| 'adminMail'
| 'adminLegal'
| 'adminPremium'
| 'adminEndpoints';