mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
# Description of Changes <img width="1569" height="980" alt="image" src="https://github.com/user-attachments/assets/dca1c227-ed84-4393-97a1-e3ce6eb1620b" /> <img width="1596" height="935" alt="image" src="https://github.com/user-attachments/assets/2003e1be-034a-4cbb-869e-6d5d912ab61d" /> <img width="1543" height="997" alt="image" src="https://github.com/user-attachments/assets/fe0c4f4b-eeee-4db4-a041-e554f350255a" /> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge } from '@mantine/core';
|
|
import { alert } from '@app/components/toast';
|
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
|
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
|
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
|
import ProviderCard from '@app/components/shared/config/configSections/ProviderCard';
|
|
import {
|
|
ALL_PROVIDERS,
|
|
Provider,
|
|
} from '@app/components/shared/config/configSections/providerDefinitions';
|
|
import apiClient from '@app/services/apiClient';
|
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
|
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
|
|
|
interface ConnectionsSettingsData {
|
|
oauth2?: {
|
|
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;
|
|
enableInvites?: boolean;
|
|
host?: string;
|
|
port?: number;
|
|
username?: string;
|
|
password?: string;
|
|
from?: string;
|
|
};
|
|
ssoAutoLogin?: boolean;
|
|
}
|
|
|
|
export default function AdminConnectionsSection() {
|
|
const { t } = useTranslation();
|
|
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
|
|
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
|
|
|
const adminSettings = useAdminSettings<ConnectionsSettingsData>({
|
|
sectionName: 'connections',
|
|
fetchTransformer: async () => {
|
|
// Fetch security settings (oauth2, saml2)
|
|
const securityResponse = await apiClient.get('/api/v1/admin/settings/section/security');
|
|
const securityData = securityResponse.data || {};
|
|
|
|
// Fetch mail settings
|
|
const mailResponse = await apiClient.get('/api/v1/admin/settings/section/mail');
|
|
const mailData = mailResponse.data || {};
|
|
|
|
// Fetch premium settings for SSO Auto Login
|
|
const premiumResponse = await apiClient.get('/api/v1/admin/settings/section/premium');
|
|
const premiumData = premiumResponse.data || {};
|
|
|
|
const result: any = {
|
|
oauth2: securityData.oauth2 || {},
|
|
saml2: securityData.saml2 || {},
|
|
mail: mailData || {},
|
|
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false
|
|
};
|
|
|
|
// Merge pending blocks from all three endpoints
|
|
const pendingBlock: any = {};
|
|
if (securityData._pending?.oauth2) {
|
|
pendingBlock.oauth2 = securityData._pending.oauth2;
|
|
}
|
|
if (securityData._pending?.saml2) {
|
|
pendingBlock.saml2 = securityData._pending.saml2;
|
|
}
|
|
if (mailData._pending) {
|
|
pendingBlock.mail = mailData._pending;
|
|
}
|
|
if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) {
|
|
pendingBlock.ssoAutoLogin = premiumData._pending.proFeatures.ssoAutoLogin;
|
|
}
|
|
|
|
if (Object.keys(pendingBlock).length > 0) {
|
|
result._pending = pendingBlock;
|
|
}
|
|
|
|
return result;
|
|
},
|
|
saveTransformer: () => {
|
|
// This section doesn't have a global save button
|
|
// Individual providers save through their own handlers
|
|
return {
|
|
sectionData: {},
|
|
deltaSettings: {}
|
|
};
|
|
}
|
|
});
|
|
|
|
const {
|
|
settings,
|
|
setSettings,
|
|
loading,
|
|
fetchSettings,
|
|
isFieldPending,
|
|
} = adminSettings;
|
|
|
|
useEffect(() => {
|
|
if (loginEnabled) {
|
|
fetchSettings();
|
|
}
|
|
}, [loginEnabled, fetchSettings]);
|
|
|
|
// Override loading state when login is disabled
|
|
const actualLoading = loginEnabled ? loading : false;
|
|
|
|
const isProviderConfigured = (provider: Provider): boolean => {
|
|
if (provider.id === 'saml2') {
|
|
return settings?.saml2?.enabled === true;
|
|
}
|
|
|
|
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>) => {
|
|
// Block save if login is disabled
|
|
if (!validateLoginEnabled()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (provider.id === 'smtp') {
|
|
// Mail settings use a different endpoint
|
|
const response = await apiClient.put('/api/v1/admin/settings/section/mail', providerSettings);
|
|
|
|
if (response.status === 200) {
|
|
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 {
|
|
// 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 apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
|
|
|
|
if (response.status === 200) {
|
|
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({
|
|
alertType: 'error',
|
|
title: t('admin.error', 'Error'),
|
|
body: t('admin.settings.saveError', 'Failed to save settings'),
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleProviderDisconnect = async (provider: Provider) => {
|
|
// Block disconnect if login is disabled
|
|
if (!validateLoginEnabled()) {
|
|
return;
|
|
}
|
|
|
|
try{
|
|
if (provider.id === 'smtp') {
|
|
// Mail settings use a different endpoint
|
|
const response = await apiClient.put('/api/v1/admin/settings/section/mail', { enabled: false });
|
|
|
|
if (response.status === 200) {
|
|
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 apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
|
|
|
|
if (response.status === 200) {
|
|
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'),
|
|
});
|
|
}
|
|
};
|
|
|
|
if (actualLoading) {
|
|
return (
|
|
<Stack align="center" justify="center" h={200}>
|
|
<Loader size="lg" />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
const handleSSOAutoLoginSave = async () => {
|
|
// Block save if login is disabled
|
|
if (!validateLoginEnabled()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const deltaSettings = {
|
|
'premium.proFeatures.ssoAutoLogin': settings?.ssoAutoLogin
|
|
};
|
|
|
|
const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
|
|
|
|
if (response.status === 200) {
|
|
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({
|
|
alertType: 'error',
|
|
title: t('admin.error', 'Error'),
|
|
body: t('admin.settings.saveError', 'Failed to save settings'),
|
|
});
|
|
}
|
|
};
|
|
|
|
const linkedProviders = ALL_PROVIDERS.filter((p) => isProviderConfigured(p));
|
|
const availableProviders = ALL_PROVIDERS.filter((p) => !isProviderConfigured(p));
|
|
|
|
return (
|
|
<Stack gap="xl">
|
|
<LoginRequiredBanner show={!loginEnabled} />
|
|
|
|
{/* Header */}
|
|
<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>
|
|
|
|
{/* SSO Auto Login - Premium Feature */}
|
|
<Paper withBorder p="md" radius="md">
|
|
<Stack gap="md">
|
|
<Group justify="space-between" align="center">
|
|
<Text fw={600} size="sm">{t('admin.settings.connections.ssoAutoLogin.label', 'SSO Auto Login')}</Text>
|
|
<Badge color="yellow" size="sm">PRO</Badge>
|
|
</Group>
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<div>
|
|
<Text fw={500} size="sm">{t('admin.settings.connections.ssoAutoLogin.enable', 'Enable SSO Auto Login')}</Text>
|
|
<Text size="xs" c="dimmed" mt={4}>
|
|
{t('admin.settings.connections.ssoAutoLogin.description', 'Automatically redirect to SSO login when authentication is required')}
|
|
</Text>
|
|
</div>
|
|
<Group gap="xs">
|
|
<Switch
|
|
checked={settings?.ssoAutoLogin || false}
|
|
onChange={(e) => {
|
|
if (!loginEnabled) return; // Block change when login disabled
|
|
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
|
|
handleSSOAutoLoginSave();
|
|
}}
|
|
disabled={!loginEnabled}
|
|
styles={getDisabledStyles()}
|
|
/>
|
|
<PendingBadge show={isFieldPending('ssoAutoLogin')} />
|
|
</Group>
|
|
</div>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
{/* Linked Services Section - Only show if there are linked providers */}
|
|
{linkedProviders.length > 0 && (
|
|
<>
|
|
<div>
|
|
<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)}
|
|
disabled={!loginEnabled}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</div>
|
|
|
|
{/* Divider between sections */}
|
|
{availableProviders.length > 0 && <Divider />}
|
|
</>
|
|
)}
|
|
|
|
{/* 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)}
|
|
disabled={!loginEnabled}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</div>
|
|
)}
|
|
|
|
{/* Restart Confirmation Modal */}
|
|
<RestartConfirmationModal
|
|
opened={restartModalOpened}
|
|
onClose={closeRestartModal}
|
|
onRestart={restartServer}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|