Extract SSO providers runtime owners

This commit is contained in:
rcourtman
2026-03-20 20:43:01 +00:00
parent d94d4b8b97
commit d3061ee166
8 changed files with 799 additions and 584 deletions
@@ -40,17 +40,19 @@ work extends shared components instead of creating new local variants.
18. `frontend-modern/src/components/Settings/SettingsPageShell.tsx`
19. `frontend-modern/src/components/Settings/settingsPanelRegistry.ts`
20. `frontend-modern/src/components/Settings/SSOProvidersPanel.tsx`
21. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx`
22. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts`
23. `tests/integration/tests/15-settings-shell-consistency.spec.ts`
24. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts`
25. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts`
26. `frontend-modern/src/features/`
27. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx`
28. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx`
29. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx`
30. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx`
31. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx`
21. `frontend-modern/src/components/Settings/useSSOProvidersState.ts`
22. `frontend-modern/src/components/Settings/ssoProvidersModel.ts`
23. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx`
24. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts`
25. `tests/integration/tests/15-settings-shell-consistency.spec.ts`
26. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts`
27. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts`
28. `frontend-modern/src/features/`
29. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx`
30. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx`
31. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx`
32. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx`
33. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx`
## Shared Boundaries
@@ -281,7 +283,12 @@ must extend those extracted owners instead of being re-inlined into the shell.
`frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` must keep
`frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx`,
`frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx`,
`frontend-modern/src/components/Settings/SSOProvidersPanel.tsx`, and
`frontend-modern/src/components/Settings/SSOProvidersPanel.tsx`,
`frontend-modern/src/components/Settings/useSSOProvidersState.ts`, and
`frontend-modern/src/components/Settings/ssoProvidersModel.ts` now also define
the canonical SSO provider settings runtime boundary: `SSOProvidersPanel.tsx`
is the shell, `useSSOProvidersState.ts` owns the reactive/API lifecycle, and
`ssoProvidersModel.ts` owns provider-form normalization and payload building.
`frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` must keep
page-shell titles, descriptions, and lead panel framing aligned instead of
letting navigation/header labels drift away from the actual settings surface.
@@ -1651,8 +1651,10 @@
"frontend-modern/src/components/Settings/settingsHeaderMeta.ts",
"frontend-modern/src/components/Settings/SettingsPageShell.tsx",
"frontend-modern/src/components/Settings/settingsPanelRegistry.ts",
"frontend-modern/src/components/Settings/ssoProvidersModel.ts",
"frontend-modern/src/components/Settings/SSOProvidersPanel.tsx",
"frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx",
"frontend-modern/src/components/Settings/useSSOProvidersState.ts",
"frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx",
"frontend-modern/src/components/SetupWizard/SetupWizard.tsx"
],
@@ -1706,8 +1708,10 @@
"frontend-modern/src/components/Settings/settingsHeaderMeta.ts",
"frontend-modern/src/components/Settings/SettingsPageShell.tsx",
"frontend-modern/src/components/Settings/settingsPanelRegistry.ts",
"frontend-modern/src/components/Settings/ssoProvidersModel.ts",
"frontend-modern/src/components/Settings/SSOProvidersPanel.tsx",
"frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx"
"frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx",
"frontend-modern/src/components/Settings/useSSOProvidersState.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
@@ -1,20 +1,8 @@
import { Component, Show, For, createSignal, onMount, createMemo, createEffect } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Component, For, Show } from 'solid-js';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { Dialog } from '@/components/shared/Dialog';
import { Toggle } from '@/components/shared/Toggle';
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import {
getUpgradeActionUrlOrFallback,
hasFeature,
loadLicenseStatus,
licenseLoaded,
startProTrial,
entitlements,
} from '@/stores/license';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
import Plus from 'lucide-solid/icons/plus';
import Pencil from 'lucide-solid/icons/pencil';
import Trash2 from 'lucide-solid/icons/trash-2';
@@ -29,192 +17,23 @@ import { SSOProviderTypeIcon } from './SSOProviderTypeIcon';
import {
getSSOProviderAddButtonLabel,
getSSOCertificatePresentation,
getSSOCopySuccessMessage,
getSSOConnectionTestErrorMessage,
getSSOConnectionTestFailureMessage,
getSSOConnectionTestSuccessMessage,
getSSOProviderCardClass,
getSSOProviderDeleteErrorMessage,
getSSOProviderDeleteSuccessMessage,
getSSOProviderDetailsLoadErrorMessage,
getSSOProviderEmptyStateDescription,
getSSOProviderEmptyStateTitle,
getSSOMetadataFetchErrorMessage,
getSSOMetadataUrlRequiredMessage,
getSSOProvidersLoadingState,
getSSOProvidersLoadErrorMessage,
getSSOProviderModalTitle,
getSSOProviderSaveErrorMessage,
getSSOProviderSaveSuccessMessage,
getSSOProviderSummary,
getSSOProviderToggleErrorMessage,
getSSOProviderToggleSuccessMessage,
getSSOProviderTypeBadgeClass,
getSSOProviderTypeLabel,
getSSOTestResultPresentation,
} from '@/utils/ssoProviderPresentation';
import {
getProTrialStartedMessage,
getTrialAlreadyUsedMessage,
getTrialStartErrorMessage,
getUpgradeActionButtonClass,
UPGRADE_ACTION_LABEL,
UPGRADE_TRIAL_LABEL,
UPGRADE_TRIAL_LINK_CLASS,
} from '@/utils/upgradePresentation';
import { ALERT_EMAIL_REPLY_TO_PLACEHOLDER } from '@/utils/alertEmailPresentation';
// Types
interface SSOProvider {
id: string;
name: string;
type: 'oidc' | 'saml';
enabled: boolean;
displayName?: string;
iconUrl?: string;
priority: number;
oidcIssuerUrl?: string;
oidcClientId?: string;
oidcClientSecretSet?: boolean;
samlIdpEntityId?: string;
samlSpEntityId?: string;
samlMetadataUrl?: string;
samlAcsUrl?: string;
allowedGroups?: string[];
allowedDomains?: string[];
allowedEmails?: string[];
}
interface SSOProvidersResponse {
providers: SSOProvider[];
defaultProviderId?: string;
allowMultipleProviders: boolean;
}
// Test connection response
interface TestResult {
success: boolean;
message: string;
error?: string;
details?: {
type: string;
entityId?: string;
ssoUrl?: string;
sloUrl?: string;
tokenEndpoint?: string;
userinfoEndpoint?: string;
certificates?: Array<{
subject: string;
issuer: string;
notBefore: string;
notAfter: string;
isExpired: boolean;
}>;
};
}
// Metadata preview response
interface MetadataPreview {
xml: string;
parsed: {
entityId: string;
ssoUrl?: string;
sloUrl?: string;
certificates?: Array<{
subject: string;
notAfter: string;
isExpired?: boolean;
}>;
nameIdFormats?: string[];
};
}
// Provider form for creating/editing
interface ProviderForm {
id: string;
name: string;
type: 'oidc' | 'saml';
enabled: boolean;
displayName: string;
priority: number;
// OIDC fields
oidcIssuerUrl: string;
oidcClientId: string;
oidcClientSecret: string;
oidcRedirectUrl: string;
oidcLogoutUrl: string;
oidcScopes: string;
// SAML fields
samlIdpMetadataUrl: string;
samlIdpMetadataXml: string;
samlIdpSsoUrl: string;
samlIdpEntityId: string;
samlIdpCertificate: string;
samlSpEntityId: string;
samlSignRequests: boolean;
samlAllowIdpInitiated: boolean;
samlUsernameAttr: string;
samlEmailAttr: string;
samlGroupsAttr: string;
// Common
allowedGroups: string;
allowedDomains: string;
allowedEmails: string;
groupRoleMappings: string;
}
const emptyForm = (): ProviderForm => ({
id: '',
name: '',
type: 'oidc',
enabled: true,
displayName: '',
priority: 0,
oidcIssuerUrl: '',
oidcClientId: '',
oidcClientSecret: '',
oidcRedirectUrl: '',
oidcLogoutUrl: '',
oidcScopes: 'openid profile email',
samlIdpMetadataUrl: '',
samlIdpMetadataXml: '',
samlIdpSsoUrl: '',
samlIdpEntityId: '',
samlIdpCertificate: '',
samlSpEntityId: '',
samlSignRequests: false,
samlAllowIdpInitiated: false,
samlUsernameAttr: '',
samlEmailAttr: 'email',
samlGroupsAttr: '',
allowedGroups: '',
allowedDomains: '',
allowedEmails: '',
groupRoleMappings: '',
});
const listToString = (values?: string[]) => (values && values.length > 0 ? values.join(', ') : '');
const splitList = (input: string) =>
input
.split(/[,\s]+/)
.map((v) => v.trim())
.filter(Boolean);
const mappingsToString = (mappings?: Record<string, string>) =>
mappings
? Object.entries(mappings)
.map(([k, v]) => `${k}=${v}`)
.join(', ')
: '';
const stringToMappings = (input: string) => {
const result: Record<string, string> = {};
splitList(input).forEach((pair) => {
const [k, v] = pair.split('=').map((s) => s.trim());
if (k && v) result[k] = v;
});
return result;
};
import { useSSOProvidersState } from '@/components/Settings/useSSOProvidersState';
interface SSOProvidersPanelProps {
onConfigUpdated?: () => void;
@@ -222,390 +41,48 @@ interface SSOProvidersPanelProps {
}
export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
const [providers, setProviders] = createSignal<SSOProvider[]>([]);
const [loading, setLoading] = createSignal(true);
const [saving, setSaving] = createSignal(false);
const [showModal, setShowModal] = createSignal(false);
const [editingProvider, setEditingProvider] = createSignal<SSOProvider | null>(null);
const [form, setForm] = createStore<ProviderForm>(emptyForm());
const [advancedOpen, setAdvancedOpen] = createSignal(false);
const [deleteConfirm, setDeleteConfirm] = createSignal<string | null>(null);
const [publicUrl, setPublicUrl] = createSignal<string>('');
const [showSamlUpsell, setShowSamlUpsell] = createSignal(false);
// Test connection state
const [testing, setTesting] = createSignal(false);
const [testResult, setTestResult] = createSignal<TestResult | null>(null);
// Metadata preview state
const [showMetadataPreview, setShowMetadataPreview] = createSignal(false);
const [metadataPreview, setMetadataPreview] = createSignal<MetadataPreview | null>(null);
const [loadingPreview, setLoadingPreview] = createSignal(false);
const hasAdvancedSSO = createMemo(() => hasFeature('advanced_sso'));
const canManage = () => props.canManage !== false;
const [startingTrial, setStartingTrial] = createSignal(false);
const canStartTrial = () => entitlements()?.trial_eligible !== false;
const handleStartTrial = async () => {
if (startingTrial()) return;
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
window.location.href = result.actionUrl;
return;
}
notificationStore.success(getProTrialStartedMessage());
} catch (err) {
const statusCode = (err as { status?: number } | null)?.status;
if (statusCode === 409) {
notificationStore.error(getTrialAlreadyUsedMessage());
} else {
notificationStore.error(
getTrialStartErrorMessage(err instanceof Error ? err.message : undefined),
);
}
} finally {
setStartingTrial(false);
}
};
createEffect((wasBannerVisible) => {
const isBannerVisible = licenseLoaded() && !hasAdvancedSSO() && !loading();
if (isBannerVisible && !wasBannerVisible) {
trackPaywallViewed('advanced_sso', 'settings_sso_providers_banner');
}
return isBannerVisible;
}, false);
createEffect((wasUpsellVisible) => {
const isUpsellVisible = showSamlUpsell();
if (isUpsellVisible && !wasUpsellVisible) {
trackPaywallViewed('advanced_sso', 'settings_sso_providers_add_saml_gate');
}
return isUpsellVisible;
}, false);
const loadProviders = async () => {
setLoading(true);
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/security/sso/providers');
if (!response.ok) {
throw new Error(`Failed to load SSO providers (${response.status})`);
}
const data = (await response.json()) as SSOProvidersResponse;
setProviders(data.providers || []);
// Also get public URL for metadata display
const statusResp = await apiFetch('/api/security/status');
if (statusResp.ok) {
const status = await statusResp.json();
setPublicUrl(status.publicUrl || window.location.origin);
}
} catch (error) {
logger.error('[SSOProvidersPanel] Failed to load providers:', error);
notificationStore.error(getSSOProvidersLoadErrorMessage());
} finally {
setLoading(false);
}
};
onMount(() => {
loadLicenseStatus();
loadProviders();
});
const openAddModal = (type: 'oidc' | 'saml') => {
if (!canManage()) return;
setEditingProvider(null);
setForm(emptyForm());
setForm('type', type);
setAdvancedOpen(false);
setShowModal(true);
};
const openEditModal = async (provider: SSOProvider) => {
if (!canManage()) return;
setEditingProvider(provider);
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch(`/api/security/sso/providers/${provider.id}`);
if (!response.ok) throw new Error('Failed to load provider details');
const full = await response.json();
setForm({
id: full.id,
name: full.name,
type: full.type,
enabled: full.enabled,
displayName: full.displayName || '',
priority: full.priority || 0,
oidcIssuerUrl: full.oidc?.issuerUrl || '',
oidcClientId: full.oidc?.clientId || '',
oidcClientSecret: '',
oidcRedirectUrl: full.oidc?.redirectUrl || '',
oidcLogoutUrl: full.oidc?.logoutUrl || '',
oidcScopes: full.oidc?.scopes?.join(' ') || 'openid profile email',
samlIdpMetadataUrl: full.saml?.idpMetadataUrl || '',
samlIdpMetadataXml: full.saml?.idpMetadataXml || '',
samlIdpSsoUrl: full.saml?.idpSsoUrl || '',
samlIdpEntityId: full.saml?.idpEntityId || '',
samlIdpCertificate: full.saml?.idpCertificate || '',
samlSpEntityId: full.saml?.spEntityId || '',
samlSignRequests: full.saml?.signRequests || false,
samlAllowIdpInitiated: full.saml?.allowIdpInitiated || false,
samlUsernameAttr: full.saml?.usernameAttr || '',
samlEmailAttr: full.saml?.emailAttr || 'email',
samlGroupsAttr: full.saml?.groupsAttr || full.groupsClaim || '',
allowedGroups: listToString(full.allowedGroups),
allowedDomains: listToString(full.allowedDomains),
allowedEmails: listToString(full.allowedEmails),
groupRoleMappings: mappingsToString(full.groupRoleMappings),
});
setAdvancedOpen(false);
setShowModal(true);
} catch (error) {
logger.error('[SSOProvidersPanel] Failed to load provider for editing:', error);
notificationStore.error(getSSOProviderDetailsLoadErrorMessage());
}
};
const handleSave = async (e?: Event) => {
e?.preventDefault();
if (!canManage()) return;
setSaving(true);
try {
const { apiFetch } = await import('@/utils/apiClient');
const isEdit = !!editingProvider();
// Build payload based on type
const payload: Record<string, unknown> = {
id: form.id || undefined,
name: form.name.trim(),
type: form.type,
enabled: form.enabled,
displayName: form.displayName.trim() || undefined,
priority: form.priority,
allowedGroups: splitList(form.allowedGroups),
allowedDomains: splitList(form.allowedDomains),
allowedEmails: splitList(form.allowedEmails),
groupRoleMappings: stringToMappings(form.groupRoleMappings),
};
if (form.type === 'oidc') {
payload.oidc = {
issuerUrl: form.oidcIssuerUrl.trim(),
clientId: form.oidcClientId.trim(),
clientSecret: form.oidcClientSecret.trim() || undefined,
redirectUrl: form.oidcRedirectUrl.trim() || undefined,
logoutUrl: form.oidcLogoutUrl.trim() || undefined,
scopes: splitList(form.oidcScopes),
};
payload.groupsClaim = form.samlGroupsAttr.trim() || undefined;
} else {
payload.saml = {
idpMetadataUrl: form.samlIdpMetadataUrl.trim() || undefined,
idpMetadataXml: form.samlIdpMetadataXml.trim() || undefined,
idpSsoUrl: form.samlIdpSsoUrl.trim() || undefined,
idpEntityId: form.samlIdpEntityId.trim() || undefined,
idpCertificate: form.samlIdpCertificate.trim() || undefined,
spEntityId: form.samlSpEntityId.trim() || undefined,
signRequests: form.samlSignRequests,
allowIdpInitiated: form.samlAllowIdpInitiated,
usernameAttr: form.samlUsernameAttr.trim() || undefined,
emailAttr: form.samlEmailAttr.trim() || undefined,
groupsAttr: form.samlGroupsAttr.trim() || undefined,
};
payload.groupsClaim = form.samlGroupsAttr.trim() || undefined;
}
const url = isEdit
? `/api/security/sso/providers/${editingProvider()!.id}`
: '/api/security/sso/providers';
const response = await apiFetch(url, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errText = await response.text();
throw new Error(errText || `Failed to save provider (${response.status})`);
}
notificationStore.success(getSSOProviderSaveSuccessMessage(isEdit));
setShowModal(false);
loadProviders();
props.onConfigUpdated?.();
} catch (error) {
logger.error('[SSOProvidersPanel] Failed to save provider:', error);
notificationStore.error(getSSOProviderSaveErrorMessage(error));
} finally {
setSaving(false);
}
};
const handleDelete = async (providerId: string) => {
if (!canManage()) return;
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch(`/api/security/sso/providers/${providerId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Failed to delete provider (${response.status})`);
}
notificationStore.success(getSSOProviderDeleteSuccessMessage());
setDeleteConfirm(null);
loadProviders();
props.onConfigUpdated?.();
} catch (error) {
logger.error('[SSOProvidersPanel] Failed to delete provider:', error);
notificationStore.error(getSSOProviderDeleteErrorMessage());
}
};
const handleToggleEnabled = async (provider: SSOProvider) => {
if (!canManage()) return;
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch(`/api/security/sso/providers/${provider.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...provider, enabled: !provider.enabled }),
});
if (!response.ok) {
throw new Error(`Failed to update provider (${response.status})`);
}
notificationStore.success(getSSOProviderToggleSuccessMessage(!provider.enabled));
loadProviders();
props.onConfigUpdated?.();
} catch (error) {
logger.error('[SSOProvidersPanel] Failed to toggle provider:', error);
notificationStore.error(getSSOProviderToggleErrorMessage());
}
};
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
notificationStore.success(getSSOCopySuccessMessage(label));
};
const testResultPresentation = createMemo(() =>
testResult() ? getSSOTestResultPresentation(Boolean(testResult()?.success)) : null,
);
// Test connection for current form configuration
const testConnection = async () => {
setTesting(true);
setTestResult(null);
try {
const { apiFetch } = await import('@/utils/apiClient');
const payload: Record<string, unknown> = {
type: form.type,
};
if (form.type === 'oidc') {
payload.oidc = {
issuerUrl: form.oidcIssuerUrl.trim(),
clientId: form.oidcClientId.trim() || undefined,
};
} else {
payload.saml = {
idpMetadataUrl: form.samlIdpMetadataUrl.trim() || undefined,
idpMetadataXml: form.samlIdpMetadataXml.trim() || undefined,
idpSsoUrl: form.samlIdpSsoUrl.trim() || undefined,
idpCertificate: form.samlIdpCertificate.trim() || undefined,
};
}
const response = await apiFetch('/api/security/sso/providers/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const result = (await response.json()) as TestResult;
setTestResult(result);
if (result.success) {
notificationStore.success(getSSOConnectionTestSuccessMessage());
} else {
notificationStore.error(getSSOConnectionTestFailureMessage(result.message));
}
} catch (error) {
logger.error('[SSOProvidersPanel] Test connection error:', error);
setTestResult({
success: false,
message: getSSOConnectionTestErrorMessage(),
error: String(error),
});
notificationStore.error(getSSOConnectionTestErrorMessage());
} finally {
setTesting(false);
}
};
// Check if we have enough info to test
const canTest = () => {
if (form.type === 'oidc') {
return !!form.oidcIssuerUrl.trim();
}
return !!(
form.samlIdpMetadataUrl.trim() ||
form.samlIdpMetadataXml.trim() ||
form.samlIdpSsoUrl.trim()
);
};
// Fetch and preview metadata
const fetchMetadataPreview = async () => {
if (!form.samlIdpMetadataUrl.trim()) {
notificationStore.error(getSSOMetadataUrlRequiredMessage());
return;
}
setLoadingPreview(true);
setMetadataPreview(null);
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/security/sso/providers/metadata/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'saml',
metadataUrl: form.samlIdpMetadataUrl.trim(),
}),
});
if (!response.ok) {
const err = await response.text();
throw new Error(err || 'Failed to fetch metadata');
}
const preview = (await response.json()) as MetadataPreview;
setMetadataPreview(preview);
setShowMetadataPreview(true);
} catch (error) {
logger.error('[SSOProvidersPanel] Metadata preview error:', error);
notificationStore.error(getSSOMetadataFetchErrorMessage(error));
} finally {
setLoadingPreview(false);
}
};
const {
providers,
loading,
saving,
showModal,
setShowModal,
editingProvider,
form,
setForm,
advancedOpen,
setAdvancedOpen,
deleteConfirm,
setDeleteConfirm,
publicUrl,
showSamlUpsell,
setShowSamlUpsell,
testing,
testResult,
setTestResult,
showMetadataPreview,
setShowMetadataPreview,
metadataPreview,
loadingPreview,
hasAdvancedSSO,
canManage,
startingTrial,
canStartTrial,
handleStartTrial,
openAddModal,
openEditModal,
handleSave,
handleDelete,
handleToggleEnabled,
copyToClipboard,
testResultPresentation,
testConnection,
canTest,
fetchMetadataPreview,
licenseLoaded,
getUpgradeActionUrlOrFallback,
trackUpgradeClicked,
} = useSSOProvidersState(props);
return (
<div class="space-y-6">
@@ -1409,8 +886,7 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
type="button"
onClick={() => {
if (metadataPreview()?.xml) {
navigator.clipboard.writeText(metadataPreview()!.xml);
notificationStore.success(getSSOCopySuccessMessage('XML'));
copyToClipboard(metadataPreview()!.xml, 'XML');
}
}}
class="px-2 py-1 text-xs font-medium text-muted bg-surface-hover rounded hover:bg-surface-hover flex items-center gap-1"
@@ -111,6 +111,8 @@ import recoverySettingsPanelSource from '../RecoverySettingsPanel.tsx?raw';
import rolesPanelSource from '../RolesPanel.tsx?raw';
import userAssignmentsPanelSource from '../UserAssignmentsPanel.tsx?raw';
import ssoProvidersPanelSource from '../SSOProvidersPanel.tsx?raw';
import ssoProvidersStateSource from '../useSSOProvidersState.ts?raw';
import ssoProvidersModelSource from '../ssoProvidersModel.ts?raw';
import rbacPermissionsSource from '@/utils/rbacPermissions.ts?raw';
import rbacPresentationSource from '@/utils/rbacPresentation.ts?raw';
import apiTokenPresentationSource from '@/utils/apiTokenPresentation.ts?raw';
@@ -770,10 +772,10 @@ describe('monitored-system model guardrails', () => {
it('keeps SSO provider presentation on shared utilities', () => {
expect(ssoProvidersPanelSource).toContain('@/utils/ssoProviderPresentation');
expect(ssoProvidersPanelSource).toContain('@/components/Settings/useSSOProvidersState');
expect(ssoProvidersPanelSource).toContain('getSSOProviderTypeLabel');
expect(ssoProvidersPanelSource).toContain('getSSOProviderSummary');
expect(ssoProvidersPanelSource).toContain('getSSOProviderCardClass');
expect(ssoProvidersPanelSource).toContain('getSSOTestResultPresentation');
expect(ssoProvidersPanelSource).toContain('getSSOCertificatePresentation');
expect(ssoProvidersPanelSource).toContain('getSSOProviderAddButtonLabel');
expect(ssoProvidersPanelSource).toContain('getSSOProviderModalTitle');
@@ -802,6 +804,9 @@ describe('monitored-system model guardrails', () => {
expect(ssoProvidersPanelSource).not.toContain(
"notificationStore.error('Please enter an IdP Metadata URL')",
);
expect(ssoProvidersPanelSource).not.toContain('const loadProviders = async () =>');
expect(ssoProvidersPanelSource).not.toContain('const handleSave = async (');
expect(ssoProvidersPanelSource).not.toContain('const testConnection = async () =>');
expect(ssoProvidersPanelSource).not.toContain('provider.type.toUpperCase()');
expect(ssoProvidersPanelSource).not.toContain("provider.type === 'oidc' ? (");
expect(ssoProvidersPanelSource).not.toContain(
@@ -844,6 +849,17 @@ describe('monitored-system model guardrails', () => {
expect(ssoProviderPresentationSource).toContain(
'export function getSSOMetadataUrlRequiredMessage',
);
expect(ssoProvidersStateSource).toContain('@/components/Settings/ssoProvidersModel');
expect(ssoProvidersStateSource).toContain('getSSOTestResultPresentation');
expect(ssoProvidersStateSource).toContain('getSSOProvidersLoadErrorMessage');
expect(ssoProvidersStateSource).toContain('getSSOProviderDetailsLoadErrorMessage');
expect(ssoProvidersStateSource).toContain('getSSOProviderSaveSuccessMessage');
expect(ssoProvidersStateSource).toContain('getSSOProviderDeleteSuccessMessage');
expect(ssoProvidersStateSource).toContain('getSSOConnectionTestSuccessMessage');
expect(ssoProvidersModelSource).toContain('export const createEmptyProviderForm =');
expect(ssoProvidersModelSource).toContain('export const mapProviderDetailsToForm =');
expect(ssoProvidersModelSource).toContain('export const buildProviderPayload =');
expect(ssoProvidersModelSource).toContain('export const buildProviderTestPayload =');
});
it('keeps RBAC permission vocabulary on shared utilities', () => {
@@ -34,6 +34,8 @@ import organizationBillingPanelSource from '../OrganizationBillingPanel.tsx?raw'
import securityOverviewPanelSource from '../SecurityOverviewPanel.tsx?raw';
import securityAuthPanelSource from '../SecurityAuthPanel.tsx?raw';
import ssoProvidersPanelSource from '../SSOProvidersPanel.tsx?raw';
import ssoProvidersStateSource from '../useSSOProvidersState.ts?raw';
import ssoProvidersModelSource from '../ssoProvidersModel.ts?raw';
import rolesPanelSource from '../RolesPanel.tsx?raw';
import userAssignmentsPanelSource from '../UserAssignmentsPanel.tsx?raw';
import { SETTINGS_HEADER_META } from '../settingsHeaderMeta';
@@ -57,6 +59,8 @@ const extractedModules = [
'../AIProviderConfigurationSection.tsx',
'../AISettingsDialogs.tsx',
'../aiSettingsModel.ts',
'../useSSOProvidersState.ts',
'../ssoProvidersModel.ts',
'../ProxmoxSettingsPanel.tsx',
'../SettingsDialogs.tsx',
'../SettingsPageShell.tsx',
@@ -343,6 +347,22 @@ describe('Settings architecture guardrails', () => {
expect(aiSettingsModelSource).toContain('export const AI_SETUP_PROVIDER_OPTIONS');
});
it('keeps the SSO providers shell behind extracted runtime owners', () => {
expect(ssoProvidersPanelSource).toContain('@/components/Settings/useSSOProvidersState');
expect(ssoProvidersPanelSource).not.toContain('const loadProviders = async () =>');
expect(ssoProvidersPanelSource).not.toContain('const handleSave = async (');
expect(ssoProvidersPanelSource).not.toContain('const testConnection = async () =>');
expect(ssoProvidersStateSource).toContain('@/components/Settings/ssoProvidersModel');
expect(ssoProvidersStateSource).toContain('export const useSSOProvidersState =');
expect(ssoProvidersStateSource).toContain('const loadProviders = async () =>');
expect(ssoProvidersStateSource).toContain('const handleSave = async (event?: Event) =>');
expect(ssoProvidersStateSource).toContain('const testConnection = async () =>');
expect(ssoProvidersModelSource).toContain('export const createEmptyProviderForm =');
expect(ssoProvidersModelSource).toContain('export const mapProviderDetailsToForm =');
expect(ssoProvidersModelSource).toContain('export const buildProviderPayload =');
expect(ssoProvidersModelSource).toContain('export const buildProviderTestPayload =');
});
it('routes every top-level settings surface through the canonical panel shell framing', () => {
for (const [panelName, source] of Object.entries(topLevelSettingsPanelSources)) {
const usesCanonicalShell =
@@ -0,0 +1,292 @@
export interface SSOProvider {
id: string;
name: string;
type: 'oidc' | 'saml';
enabled: boolean;
displayName?: string;
iconUrl?: string;
priority: number;
oidcIssuerUrl?: string;
oidcClientId?: string;
oidcClientSecretSet?: boolean;
samlIdpEntityId?: string;
samlSpEntityId?: string;
samlMetadataUrl?: string;
samlAcsUrl?: string;
allowedGroups?: string[];
allowedDomains?: string[];
allowedEmails?: string[];
}
export interface SSOProvidersResponse {
providers: SSOProvider[];
defaultProviderId?: string;
allowMultipleProviders: boolean;
}
export interface SSOProviderTestResult {
success: boolean;
message: string;
error?: string;
details?: {
type: string;
entityId?: string;
ssoUrl?: string;
sloUrl?: string;
tokenEndpoint?: string;
userinfoEndpoint?: string;
certificates?: Array<{
subject: string;
issuer: string;
notBefore: string;
notAfter: string;
isExpired: boolean;
}>;
};
}
export interface MetadataPreview {
xml: string;
parsed: {
entityId: string;
ssoUrl?: string;
sloUrl?: string;
certificates?: Array<{
subject: string;
notAfter: string;
isExpired?: boolean;
}>;
nameIdFormats?: string[];
};
}
export interface ProviderForm {
id: string;
name: string;
type: 'oidc' | 'saml';
enabled: boolean;
displayName: string;
priority: number;
oidcIssuerUrl: string;
oidcClientId: string;
oidcClientSecret: string;
oidcRedirectUrl: string;
oidcLogoutUrl: string;
oidcScopes: string;
samlIdpMetadataUrl: string;
samlIdpMetadataXml: string;
samlIdpSsoUrl: string;
samlIdpEntityId: string;
samlIdpCertificate: string;
samlSpEntityId: string;
samlSignRequests: boolean;
samlAllowIdpInitiated: boolean;
samlUsernameAttr: string;
samlEmailAttr: string;
samlGroupsAttr: string;
allowedGroups: string;
allowedDomains: string;
allowedEmails: string;
groupRoleMappings: string;
}
interface SSOProviderDetailsResponse {
id: string;
name: string;
type: 'oidc' | 'saml';
enabled: boolean;
displayName?: string;
priority?: number;
oidc?: {
issuerUrl?: string;
clientId?: string;
redirectUrl?: string;
logoutUrl?: string;
scopes?: string[];
};
saml?: {
idpMetadataUrl?: string;
idpMetadataXml?: string;
idpSsoUrl?: string;
idpEntityId?: string;
idpCertificate?: string;
spEntityId?: string;
signRequests?: boolean;
allowIdpInitiated?: boolean;
usernameAttr?: string;
emailAttr?: string;
groupsAttr?: string;
};
groupsClaim?: string;
allowedGroups?: string[];
allowedDomains?: string[];
allowedEmails?: string[];
groupRoleMappings?: Record<string, string>;
}
export const createEmptyProviderForm = (): ProviderForm => ({
id: '',
name: '',
type: 'oidc',
enabled: true,
displayName: '',
priority: 0,
oidcIssuerUrl: '',
oidcClientId: '',
oidcClientSecret: '',
oidcRedirectUrl: '',
oidcLogoutUrl: '',
oidcScopes: 'openid profile email',
samlIdpMetadataUrl: '',
samlIdpMetadataXml: '',
samlIdpSsoUrl: '',
samlIdpEntityId: '',
samlIdpCertificate: '',
samlSpEntityId: '',
samlSignRequests: false,
samlAllowIdpInitiated: false,
samlUsernameAttr: '',
samlEmailAttr: 'email',
samlGroupsAttr: '',
allowedGroups: '',
allowedDomains: '',
allowedEmails: '',
groupRoleMappings: '',
});
export const listToString = (values?: string[]) =>
values && values.length > 0 ? values.join(', ') : '';
export const splitList = (input: string) =>
input
.split(/[,\s]+/)
.map((value) => value.trim())
.filter(Boolean);
export const mappingsToString = (mappings?: Record<string, string>) =>
mappings
? Object.entries(mappings)
.map(([key, value]) => `${key}=${value}`)
.join(', ')
: '';
export const stringToMappings = (input: string) => {
const result: Record<string, string> = {};
splitList(input).forEach((pair) => {
const [key, value] = pair.split('=').map((segment) => segment.trim());
if (key && value) {
result[key] = value;
}
});
return result;
};
export const mapProviderDetailsToForm = (full: SSOProviderDetailsResponse): ProviderForm => ({
id: full.id,
name: full.name,
type: full.type,
enabled: full.enabled,
displayName: full.displayName || '',
priority: full.priority || 0,
oidcIssuerUrl: full.oidc?.issuerUrl || '',
oidcClientId: full.oidc?.clientId || '',
oidcClientSecret: '',
oidcRedirectUrl: full.oidc?.redirectUrl || '',
oidcLogoutUrl: full.oidc?.logoutUrl || '',
oidcScopes: full.oidc?.scopes?.join(' ') || 'openid profile email',
samlIdpMetadataUrl: full.saml?.idpMetadataUrl || '',
samlIdpMetadataXml: full.saml?.idpMetadataXml || '',
samlIdpSsoUrl: full.saml?.idpSsoUrl || '',
samlIdpEntityId: full.saml?.idpEntityId || '',
samlIdpCertificate: full.saml?.idpCertificate || '',
samlSpEntityId: full.saml?.spEntityId || '',
samlSignRequests: full.saml?.signRequests || false,
samlAllowIdpInitiated: full.saml?.allowIdpInitiated || false,
samlUsernameAttr: full.saml?.usernameAttr || '',
samlEmailAttr: full.saml?.emailAttr || 'email',
samlGroupsAttr: full.saml?.groupsAttr || full.groupsClaim || '',
allowedGroups: listToString(full.allowedGroups),
allowedDomains: listToString(full.allowedDomains),
allowedEmails: listToString(full.allowedEmails),
groupRoleMappings: mappingsToString(full.groupRoleMappings),
});
export const buildProviderPayload = (form: ProviderForm): Record<string, unknown> => {
const payload: Record<string, unknown> = {
id: form.id || undefined,
name: form.name.trim(),
type: form.type,
enabled: form.enabled,
displayName: form.displayName.trim() || undefined,
priority: form.priority,
allowedGroups: splitList(form.allowedGroups),
allowedDomains: splitList(form.allowedDomains),
allowedEmails: splitList(form.allowedEmails),
groupRoleMappings: stringToMappings(form.groupRoleMappings),
};
if (form.type === 'oidc') {
payload.oidc = {
issuerUrl: form.oidcIssuerUrl.trim(),
clientId: form.oidcClientId.trim(),
clientSecret: form.oidcClientSecret.trim() || undefined,
redirectUrl: form.oidcRedirectUrl.trim() || undefined,
logoutUrl: form.oidcLogoutUrl.trim() || undefined,
scopes: splitList(form.oidcScopes),
};
payload.groupsClaim = form.samlGroupsAttr.trim() || undefined;
return payload;
}
payload.saml = {
idpMetadataUrl: form.samlIdpMetadataUrl.trim() || undefined,
idpMetadataXml: form.samlIdpMetadataXml.trim() || undefined,
idpSsoUrl: form.samlIdpSsoUrl.trim() || undefined,
idpEntityId: form.samlIdpEntityId.trim() || undefined,
idpCertificate: form.samlIdpCertificate.trim() || undefined,
spEntityId: form.samlSpEntityId.trim() || undefined,
signRequests: form.samlSignRequests,
allowIdpInitiated: form.samlAllowIdpInitiated,
usernameAttr: form.samlUsernameAttr.trim() || undefined,
emailAttr: form.samlEmailAttr.trim() || undefined,
groupsAttr: form.samlGroupsAttr.trim() || undefined,
};
payload.groupsClaim = form.samlGroupsAttr.trim() || undefined;
return payload;
};
export const buildProviderTestPayload = (form: ProviderForm): Record<string, unknown> => {
const payload: Record<string, unknown> = {
type: form.type,
};
if (form.type === 'oidc') {
payload.oidc = {
issuerUrl: form.oidcIssuerUrl.trim(),
clientId: form.oidcClientId.trim() || undefined,
};
return payload;
}
payload.saml = {
idpMetadataUrl: form.samlIdpMetadataUrl.trim() || undefined,
idpMetadataXml: form.samlIdpMetadataXml.trim() || undefined,
idpSsoUrl: form.samlIdpSsoUrl.trim() || undefined,
idpCertificate: form.samlIdpCertificate.trim() || undefined,
};
return payload;
};
export const canTestProviderForm = (form: ProviderForm): boolean => {
if (form.type === 'oidc') {
return Boolean(form.oidcIssuerUrl.trim());
}
return Boolean(
form.samlIdpMetadataUrl.trim() || form.samlIdpMetadataXml.trim() || form.samlIdpSsoUrl.trim(),
);
};
export const buildMetadataPreviewPayload = (form: ProviderForm) => ({
type: 'saml' as const,
metadataUrl: form.samlIdpMetadataUrl.trim(),
});
@@ -0,0 +1,384 @@
import { createEffect, createMemo, createSignal, onMount } from 'solid-js';
import { createStore } from 'solid-js/store';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import {
getUpgradeActionUrlOrFallback,
hasFeature,
loadLicenseStatus,
licenseLoaded,
startProTrial,
entitlements,
} from '@/stores/license';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
import {
getSSOCopySuccessMessage,
getSSOConnectionTestErrorMessage,
getSSOConnectionTestFailureMessage,
getSSOConnectionTestSuccessMessage,
getSSOMetadataFetchErrorMessage,
getSSOMetadataUrlRequiredMessage,
getSSOProviderDeleteErrorMessage,
getSSOProviderDeleteSuccessMessage,
getSSOProviderDetailsLoadErrorMessage,
getSSOProviderSaveErrorMessage,
getSSOProviderSaveSuccessMessage,
getSSOProviderToggleErrorMessage,
getSSOProviderToggleSuccessMessage,
getSSOProvidersLoadErrorMessage,
getSSOTestResultPresentation,
} from '@/utils/ssoProviderPresentation';
import {
getProTrialStartedMessage,
getTrialAlreadyUsedMessage,
getTrialStartErrorMessage,
} from '@/utils/upgradePresentation';
import type {
MetadataPreview,
ProviderForm,
SSOProvider,
SSOProvidersResponse,
SSOProviderTestResult,
} from '@/components/Settings/ssoProvidersModel';
import {
buildMetadataPreviewPayload,
buildProviderPayload,
buildProviderTestPayload,
canTestProviderForm,
createEmptyProviderForm,
mapProviderDetailsToForm,
} from '@/components/Settings/ssoProvidersModel';
interface SSOProvidersPanelProps {
onConfigUpdated?: () => void;
canManage?: boolean;
}
export const useSSOProvidersState = (props: SSOProvidersPanelProps) => {
const [providers, setProviders] = createSignal<SSOProvider[]>([]);
const [loading, setLoading] = createSignal(true);
const [saving, setSaving] = createSignal(false);
const [showModal, setShowModal] = createSignal(false);
const [editingProvider, setEditingProvider] = createSignal<SSOProvider | null>(null);
const [form, setForm] = createStore<ProviderForm>(createEmptyProviderForm());
const [advancedOpen, setAdvancedOpen] = createSignal(false);
const [deleteConfirm, setDeleteConfirm] = createSignal<string | null>(null);
const [publicUrl, setPublicUrl] = createSignal('');
const [showSamlUpsell, setShowSamlUpsell] = createSignal(false);
const [testing, setTesting] = createSignal(false);
const [testResult, setTestResult] = createSignal<SSOProviderTestResult | null>(null);
const [showMetadataPreview, setShowMetadataPreview] = createSignal(false);
const [metadataPreview, setMetadataPreview] = createSignal<MetadataPreview | null>(null);
const [loadingPreview, setLoadingPreview] = createSignal(false);
const [startingTrial, setStartingTrial] = createSignal(false);
const hasAdvancedSSO = createMemo(() => hasFeature('advanced_sso'));
const canManage = () => props.canManage !== false;
const canStartTrial = () => entitlements()?.trial_eligible !== false;
const handleStartTrial = async () => {
if (startingTrial()) {
return;
}
setStartingTrial(true);
try {
const result = await startProTrial();
if (result?.outcome === 'redirect') {
window.location.href = result.actionUrl;
return;
}
notificationStore.success(getProTrialStartedMessage());
} catch (err) {
const statusCode = (err as { status?: number } | null)?.status;
if (statusCode === 409) {
notificationStore.error(getTrialAlreadyUsedMessage());
} else {
notificationStore.error(
getTrialStartErrorMessage(err instanceof Error ? err.message : undefined),
);
}
} finally {
setStartingTrial(false);
}
};
createEffect((wasBannerVisible) => {
const isBannerVisible = licenseLoaded() && !hasAdvancedSSO() && !loading();
if (isBannerVisible && !wasBannerVisible) {
trackPaywallViewed('advanced_sso', 'settings_sso_providers_banner');
}
return isBannerVisible;
}, false);
createEffect((wasUpsellVisible) => {
const isUpsellVisible = showSamlUpsell();
if (isUpsellVisible && !wasUpsellVisible) {
trackPaywallViewed('advanced_sso', 'settings_sso_providers_add_saml_gate');
}
return isUpsellVisible;
}, false);
const loadProviders = async () => {
setLoading(true);
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/security/sso/providers');
if (!response.ok) {
throw new Error(`Failed to load SSO providers (${response.status})`);
}
const data = (await response.json()) as SSOProvidersResponse;
setProviders(data.providers || []);
const statusResponse = await apiFetch('/api/security/status');
if (statusResponse.ok) {
const status = (await statusResponse.json()) as { publicUrl?: string };
setPublicUrl(status.publicUrl || window.location.origin);
}
} catch (error) {
logger.error('[SSOProvidersPanel] Failed to load providers:', error);
notificationStore.error(getSSOProvidersLoadErrorMessage());
} finally {
setLoading(false);
}
};
onMount(() => {
loadLicenseStatus();
void loadProviders();
});
const openAddModal = (type: 'oidc' | 'saml') => {
if (!canManage()) {
return;
}
setEditingProvider(null);
setForm(createEmptyProviderForm());
setForm('type', type);
setAdvancedOpen(false);
setShowModal(true);
};
const openEditModal = async (provider: SSOProvider) => {
if (!canManage()) {
return;
}
setEditingProvider(provider);
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch(`/api/security/sso/providers/${provider.id}`);
if (!response.ok) {
throw new Error('Failed to load provider details');
}
setForm(mapProviderDetailsToForm(await response.json()));
setAdvancedOpen(false);
setShowModal(true);
} catch (error) {
logger.error('[SSOProvidersPanel] Failed to load provider for editing:', error);
notificationStore.error(getSSOProviderDetailsLoadErrorMessage());
}
};
const handleSave = async (event?: Event) => {
event?.preventDefault();
if (!canManage()) {
return;
}
setSaving(true);
try {
const { apiFetch } = await import('@/utils/apiClient');
const isEdit = Boolean(editingProvider());
const response = await apiFetch(
isEdit ? `/api/security/sso/providers/${editingProvider()!.id}` : '/api/security/sso/providers',
{
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildProviderPayload(form)),
},
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `Failed to save provider (${response.status})`);
}
notificationStore.success(getSSOProviderSaveSuccessMessage(isEdit));
setShowModal(false);
await loadProviders();
props.onConfigUpdated?.();
} catch (error) {
logger.error('[SSOProvidersPanel] Failed to save provider:', error);
notificationStore.error(getSSOProviderSaveErrorMessage(error));
} finally {
setSaving(false);
}
};
const handleDelete = async (providerId: string) => {
if (!canManage()) {
return;
}
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch(`/api/security/sso/providers/${providerId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Failed to delete provider (${response.status})`);
}
notificationStore.success(getSSOProviderDeleteSuccessMessage());
setDeleteConfirm(null);
await loadProviders();
props.onConfigUpdated?.();
} catch (error) {
logger.error('[SSOProvidersPanel] Failed to delete provider:', error);
notificationStore.error(getSSOProviderDeleteErrorMessage());
}
};
const handleToggleEnabled = async (provider: SSOProvider) => {
if (!canManage()) {
return;
}
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch(`/api/security/sso/providers/${provider.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...provider, enabled: !provider.enabled }),
});
if (!response.ok) {
throw new Error(`Failed to update provider (${response.status})`);
}
notificationStore.success(getSSOProviderToggleSuccessMessage(!provider.enabled));
await loadProviders();
props.onConfigUpdated?.();
} catch (error) {
logger.error('[SSOProvidersPanel] Failed to toggle provider:', error);
notificationStore.error(getSSOProviderToggleErrorMessage());
}
};
const copyToClipboard = (text: string, label: string) => {
void navigator.clipboard.writeText(text);
notificationStore.success(getSSOCopySuccessMessage(label));
};
const testResultPresentation = createMemo(() =>
testResult() ? getSSOTestResultPresentation(Boolean(testResult()?.success)) : null,
);
const testConnection = async () => {
setTesting(true);
setTestResult(null);
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/security/sso/providers/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildProviderTestPayload(form)),
});
const result = (await response.json()) as SSOProviderTestResult;
setTestResult(result);
if (result.success) {
notificationStore.success(getSSOConnectionTestSuccessMessage());
} else {
notificationStore.error(getSSOConnectionTestFailureMessage(result.message));
}
} catch (error) {
logger.error('[SSOProvidersPanel] Test connection error:', error);
setTestResult({
success: false,
message: getSSOConnectionTestErrorMessage(),
error: String(error),
});
notificationStore.error(getSSOConnectionTestErrorMessage());
} finally {
setTesting(false);
}
};
const canTest = () => canTestProviderForm(form);
const fetchMetadataPreview = async () => {
if (!form.samlIdpMetadataUrl.trim()) {
notificationStore.error(getSSOMetadataUrlRequiredMessage());
return;
}
setLoadingPreview(true);
setMetadataPreview(null);
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/security/sso/providers/metadata/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildMetadataPreviewPayload(form)),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to fetch metadata');
}
setMetadataPreview((await response.json()) as MetadataPreview);
setShowMetadataPreview(true);
} catch (error) {
logger.error('[SSOProvidersPanel] Metadata preview error:', error);
notificationStore.error(getSSOMetadataFetchErrorMessage(error));
} finally {
setLoadingPreview(false);
}
};
return {
providers,
loading,
saving,
showModal,
setShowModal,
editingProvider,
form,
setForm,
advancedOpen,
setAdvancedOpen,
deleteConfirm,
setDeleteConfirm,
publicUrl,
showSamlUpsell,
setShowSamlUpsell,
testing,
testResult,
setTestResult,
showMetadataPreview,
setShowMetadataPreview,
metadataPreview,
loadingPreview,
hasAdvancedSSO,
canManage,
startingTrial,
canStartTrial,
handleStartTrial,
openAddModal,
openEditModal,
handleSave,
handleDelete,
handleToggleEnabled,
copyToClipboard,
testResultPresentation,
testConnection,
canTest,
fetchMetadataPreview,
licenseLoaded,
getUpgradeActionUrlOrFallback,
trackUpgradeClicked,
};
};
@@ -285,6 +285,8 @@ import auditWebhookPresentationSource from '@/utils/auditWebhookPresentation.ts?
import auditLogPanelSource from '@/components/Settings/AuditLogPanel.tsx?raw';
import auditLogPresentationSource from '@/utils/auditLogPresentation.ts?raw';
import ssoProvidersPanelSource from '@/components/Settings/SSOProvidersPanel.tsx?raw';
import ssoProvidersStateSource from '@/components/Settings/useSSOProvidersState.ts?raw';
import ssoProvidersModelSource from '@/components/Settings/ssoProvidersModel.ts?raw';
import ssoProviderPresentationSource from '@/utils/ssoProviderPresentation.ts?raw';
import userAssignmentsPanelSource from '@/components/Settings/UserAssignmentsPanel.tsx?raw';
import investigationMessagesSource from '@/components/patrol/InvestigationMessages.tsx?raw';
@@ -2532,10 +2534,10 @@ describe('frontend resource type boundaries', () => {
'export function resolveConfiguredInstanceStatusIndicator',
);
expect(ssoProvidersPanelSource).toContain('@/utils/ssoProviderPresentation');
expect(ssoProvidersPanelSource).toContain('@/components/Settings/useSSOProvidersState');
expect(ssoProvidersPanelSource).toContain('getSSOProviderTypeLabel');
expect(ssoProvidersPanelSource).toContain('getSSOProviderSummary');
expect(ssoProvidersPanelSource).toContain('getSSOProviderCardClass');
expect(ssoProvidersPanelSource).toContain('getSSOTestResultPresentation');
expect(ssoProvidersPanelSource).toContain('getSSOCertificatePresentation');
expect(ssoProvidersPanelSource).toContain('getSSOProviderAddButtonLabel');
expect(ssoProvidersPanelSource).toContain('getSSOProviderModalTitle');
@@ -2564,6 +2566,9 @@ describe('frontend resource type boundaries', () => {
expect(ssoProvidersPanelSource).not.toContain(
"notificationStore.error('Please enter an IdP Metadata URL')",
);
expect(ssoProvidersPanelSource).not.toContain('const loadProviders = async () =>');
expect(ssoProvidersPanelSource).not.toContain('const handleSave = async (');
expect(ssoProvidersPanelSource).not.toContain('const testConnection = async () =>');
expect(ssoProvidersPanelSource).not.toContain('provider.type.toUpperCase()');
expect(ssoProvidersPanelSource).not.toContain("provider.type === 'oidc' ? (");
expect(ssoProviderPresentationSource).toContain('export function getSSOProviderTypeLabel');
@@ -2600,6 +2605,17 @@ describe('frontend resource type boundaries', () => {
expect(ssoProviderPresentationSource).toContain(
'export function getSSOMetadataUrlRequiredMessage',
);
expect(ssoProvidersStateSource).toContain('@/components/Settings/ssoProvidersModel');
expect(ssoProvidersStateSource).toContain('getSSOTestResultPresentation');
expect(ssoProvidersStateSource).toContain('getSSOProvidersLoadErrorMessage');
expect(ssoProvidersStateSource).toContain('getSSOProviderDetailsLoadErrorMessage');
expect(ssoProvidersStateSource).toContain('getSSOProviderSaveSuccessMessage');
expect(ssoProvidersStateSource).toContain('getSSOProviderDeleteSuccessMessage');
expect(ssoProvidersStateSource).toContain('getSSOConnectionTestSuccessMessage');
expect(ssoProvidersModelSource).toContain('export const createEmptyProviderForm =');
expect(ssoProvidersModelSource).toContain('export const mapProviderDetailsToForm =');
expect(ssoProvidersModelSource).toContain('export const buildProviderPayload =');
expect(ssoProvidersModelSource).toContain('export const buildProviderTestPayload =');
expect(auditWebhookPanelSource).toContain('@/utils/auditWebhookPresentation');
expect(auditWebhookPanelSource).toContain('getAuditWebhookFeatureGateCopy');
expect(auditWebhookPanelSource).toContain('getAuditWebhookEmptyStateCopy');