mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-07 13:21:22 +00:00
Extract SSO providers runtime owners
This commit is contained in:
@@ -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"
|
||||
|
||||
+17
-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');
|
||||
|
||||
Reference in New Issue
Block a user