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

@@ -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';