From d3061ee16666085db4adc0be5ff4fc995455b91a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 20 Mar 2026 20:43:01 +0000 Subject: [PATCH] Extract SSO providers runtime owners --- .../subsystems/frontend-primitives.md | 31 +- .../v6/internal/subsystems/registry.json | 6 +- .../components/Settings/SSOProvidersPanel.tsx | 614 ++---------------- .../monitoredSystemModelGuardrails.test.ts | 18 +- .../__tests__/settingsArchitecture.test.ts | 20 + .../components/Settings/ssoProvidersModel.ts | 292 +++++++++ .../Settings/useSSOProvidersState.ts | 384 +++++++++++ .../frontendResourceTypeBoundaries.test.ts | 18 +- 8 files changed, 799 insertions(+), 584 deletions(-) create mode 100644 frontend-modern/src/components/Settings/ssoProvidersModel.ts create mode 100644 frontend-modern/src/components/Settings/useSSOProvidersState.ts diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 4a111a893..7e3857bd0 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 7d0521fc8..579e451d2 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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": [], diff --git a/frontend-modern/src/components/Settings/SSOProvidersPanel.tsx b/frontend-modern/src/components/Settings/SSOProvidersPanel.tsx index 76b244933..54b2e92a4 100644 --- a/frontend-modern/src/components/Settings/SSOProvidersPanel.tsx +++ b/frontend-modern/src/components/Settings/SSOProvidersPanel.tsx @@ -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) => - mappings - ? Object.entries(mappings) - .map(([k, v]) => `${k}=${v}`) - .join(', ') - : ''; - -const stringToMappings = (input: string) => { - const result: Record = {}; - 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 = (props) => { - const [providers, setProviders] = createSignal([]); - const [loading, setLoading] = createSignal(true); - const [saving, setSaving] = createSignal(false); - const [showModal, setShowModal] = createSignal(false); - const [editingProvider, setEditingProvider] = createSignal(null); - const [form, setForm] = createStore(emptyForm()); - const [advancedOpen, setAdvancedOpen] = createSignal(false); - const [deleteConfirm, setDeleteConfirm] = createSignal(null); - const [publicUrl, setPublicUrl] = createSignal(''); - - const [showSamlUpsell, setShowSamlUpsell] = createSignal(false); - - // Test connection state - const [testing, setTesting] = createSignal(false); - const [testResult, setTestResult] = createSignal(null); - - // Metadata preview state - const [showMetadataPreview, setShowMetadataPreview] = createSignal(false); - const [metadataPreview, setMetadataPreview] = createSignal(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 = { - 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 = { - 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 (
@@ -1409,8 +886,7 @@ export const SSOProvidersPanel: Component = (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" diff --git a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts index e8ec66974..34e1567e1 100644 --- a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts @@ -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', () => { diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 4384c1335..915011ae1 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -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 = diff --git a/frontend-modern/src/components/Settings/ssoProvidersModel.ts b/frontend-modern/src/components/Settings/ssoProvidersModel.ts new file mode 100644 index 000000000..d7b2bfa06 --- /dev/null +++ b/frontend-modern/src/components/Settings/ssoProvidersModel.ts @@ -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; +} + +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) => + mappings + ? Object.entries(mappings) + .map(([key, value]) => `${key}=${value}`) + .join(', ') + : ''; + +export const stringToMappings = (input: string) => { + const result: Record = {}; + 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 => { + const payload: Record = { + 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 => { + const payload: Record = { + 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(), +}); diff --git a/frontend-modern/src/components/Settings/useSSOProvidersState.ts b/frontend-modern/src/components/Settings/useSSOProvidersState.ts new file mode 100644 index 000000000..d5421e524 --- /dev/null +++ b/frontend-modern/src/components/Settings/useSSOProvidersState.ts @@ -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([]); + const [loading, setLoading] = createSignal(true); + const [saving, setSaving] = createSignal(false); + const [showModal, setShowModal] = createSignal(false); + const [editingProvider, setEditingProvider] = createSignal(null); + const [form, setForm] = createStore(createEmptyProviderForm()); + const [advancedOpen, setAdvancedOpen] = createSignal(false); + const [deleteConfirm, setDeleteConfirm] = createSignal(null); + const [publicUrl, setPublicUrl] = createSignal(''); + const [showSamlUpsell, setShowSamlUpsell] = createSignal(false); + const [testing, setTesting] = createSignal(false); + const [testResult, setTestResult] = createSignal(null); + const [showMetadataPreview, setShowMetadataPreview] = createSignal(false); + const [metadataPreview, setMetadataPreview] = createSignal(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, + }; +}; diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 5537d8887..8ad240100 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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');