diff --git a/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue b/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue index 40c1b5e63d2..f14cbf901e9 100644 --- a/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue +++ b/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue @@ -37,6 +37,7 @@ const { mappingMethod, isUserRoleProvisioningChanged, saveProvisioningConfig, + trackProvisioningChange, roleAssignmentTransition, storedHasProjectRoles, isDroppingProjectRules, @@ -158,7 +159,7 @@ async function onOidcSettingsSave(provisioningChangesConfirmed: boolean = false) loginEnabled: ssoStore.isOidcLoginEnabled, authenticationContextClassReference: acrArray, }); - await saveProvisioningConfig(isDisablingOidcLogin); + const provisioningResult = await saveProvisioningConfig(isDisablingOidcLogin); // If the user's effective role assignment doesn't include project roles, // discard any project-rule state in the editor (both locally-added and @@ -170,9 +171,12 @@ async function onOidcSettingsSave(provisioningChangesConfirmed: boolean = false) roleMappingRuleEditorRef.value?.discardProjectRules(); } - if (mappingMethod.value === 'rules_in_n8n') { - await roleMappingRuleEditorRef.value?.save(); - } + const ruleSaveResult = + mappingMethod.value === 'rules_in_n8n' + ? await roleMappingRuleEditorRef.value?.save() + : undefined; + + trackProvisioningChange(provisioningResult, ruleSaveResult); showUserRoleProvisioningDialog.value = false; diff --git a/packages/frontend/editor-ui/src/features/settings/sso/components/SamlSettingsForm.vue b/packages/frontend/editor-ui/src/features/settings/sso/components/SamlSettingsForm.vue index 920a59b4bfb..beae86cdf51 100644 --- a/packages/frontend/editor-ui/src/features/settings/sso/components/SamlSettingsForm.vue +++ b/packages/frontend/editor-ui/src/features/settings/sso/components/SamlSettingsForm.vue @@ -74,6 +74,7 @@ const { mappingMethod, isUserRoleProvisioningChanged, saveProvisioningConfig, + trackProvisioningChange, roleAssignmentTransition, storedHasProjectRoles, isDroppingProjectRules, @@ -244,7 +245,7 @@ const onSave = async (provisioningChangesConfirmed: boolean = false): Promise { isLoading.value = true; try { // Defensive re-sync: the server may have removed rules between the @@ -173,6 +180,13 @@ export function useRoleMappingRules() { } await loadRules(); + + return { + createdCount: createRules.length, + deletedCount: deleteIds.length, + instanceRuleCount: instanceRules.value.length, + projectRuleCount: projectRules.value.length, + }; } finally { isLoading.value = false; } diff --git a/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.test.ts b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.test.ts index 7fabe74116c..49002bcad85 100644 --- a/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.test.ts @@ -279,12 +279,14 @@ describe('useUserRoleProvisioningForm', () => { const { roleAssignment, saveProvisioningConfig } = useUserRoleProvisioningForm('oidc'); await vi.waitFor(() => expect(roleAssignment.value).toBe('instance')); - await saveProvisioningConfig(false); + const result = await saveProvisioningConfig(false); expect(provisioningApi.saveProvisioningConfig).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ deleteProjectRules: true }), ); + // Reports a change so the caller fires telemetry — the API call did happen. + expect(result).toEqual({ configChanged: true }); }); }); diff --git a/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.ts b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.ts index bba0684a54d..d46346e2fd7 100644 --- a/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.ts +++ b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.ts @@ -9,12 +9,11 @@ import type { import { type SupportedProtocolType } from '../../sso.store'; import { useTelemetry } from '@/app/composables/useTelemetry'; import { useRootStore } from '@n8n/stores/useRootStore'; +import type { RoleMappingRulesSaveResult } from './useRoleMappingRules'; -type TelemetrySettingValue = - | 'disabled' - | 'instance_role' - | 'instance_and_project_roles' - | 'expression_based'; +type TelemetryAssignmentMethod = 'disabled' | 'instance_role' | 'instance_and_project_roles'; + +type TelemetryRoleMappingMethod = 'idp_rule_mapping' | 'n8n_rule_mapping'; export type RoleAssignmentTransitionType = 'none' | 'backup' | 'switchToManual'; @@ -69,16 +68,20 @@ function getProvisioningConfigFromDropdowns( }; } -function getTelemetrySettingValue( +function getTelemetryAssignmentMethod( roleAssignment: RoleAssignmentSetting, - mappingMethod: RoleMappingMethodSetting, -): TelemetrySettingValue { +): TelemetryAssignmentMethod { if (roleAssignment === 'manual') return 'disabled'; - if (mappingMethod === 'rules_in_n8n') return 'expression_based'; if (roleAssignment === 'instance_and_project') return 'instance_and_project_roles'; return 'instance_role'; } +function getTelemetryRoleMappingMethod( + mappingMethod: RoleMappingMethodSetting, +): TelemetryRoleMappingMethod { + return mappingMethod === 'rules_in_n8n' ? 'n8n_rule_mapping' : 'idp_rule_mapping'; +} + export function useUserRoleProvisioningForm(protocol: SupportedProtocolType) { const provisioningStore = useUserRoleProvisioningStore(); const telemetry = useTelemetry(); @@ -98,15 +101,28 @@ export function useUserRoleProvisioningForm(protocol: SupportedProtocolType) { ); }); - const sendTrackingEventForUserProvisioning = (updatedSetting: TelemetrySettingValue) => { + const trackProvisioningChange = ( + provisioningResult: { configChanged: boolean }, + ruleSaveResult: RoleMappingRulesSaveResult | undefined, + ) => { + const rulesChanged = + (ruleSaveResult?.createdCount ?? 0) > 0 || (ruleSaveResult?.deletedCount ?? 0) > 0; + + if (!provisioningResult.configChanged && !rulesChanged) return; + telemetry.track('User updated provisioning settings', { instance_id: useRootStore().instanceId, authentication_method: protocol, - updated_setting: updatedSetting, + assignment_method: getTelemetryAssignmentMethod(roleAssignment.value), + role_mapping_method: getTelemetryRoleMappingMethod(mappingMethod.value), + instance_rule_count: ruleSaveResult?.instanceRuleCount ?? 0, + project_rule_count: ruleSaveResult?.projectRuleCount ?? 0, }); }; - const saveProvisioningConfig = async (isDisablingSso: boolean): Promise => { + const saveProvisioningConfig = async ( + isDisablingSso: boolean, + ): Promise<{ configChanged: boolean }> => { const effectiveRoleAssignment: RoleAssignmentSetting = isDisablingSso ? 'manual' : roleAssignment.value; @@ -121,12 +137,12 @@ export function useUserRoleProvisioningForm(protocol: SupportedProtocolType) { const shouldDeleteProjectRules = effectiveRoleAssignment !== 'instance_and_project'; const stored = storedValues.value; - const configUnchanged = - effectiveRoleAssignment === stored.roleAssignment && - effectiveMappingMethod === stored.mappingMethod; + const configChanged = + effectiveRoleAssignment !== stored.roleAssignment || + effectiveMappingMethod !== stored.mappingMethod; - if (configUnchanged && !shouldDeleteProjectRules) { - return; + if (!configChanged && !shouldDeleteProjectRules) { + return { configChanged: false }; } await provisioningStore.saveProvisioningConfig({ @@ -141,9 +157,11 @@ export function useUserRoleProvisioningForm(protocol: SupportedProtocolType) { storedHasProjectRules.value = false; } - sendTrackingEventForUserProvisioning( - getTelemetrySettingValue(effectiveRoleAssignment, effectiveMappingMethod), - ); + // `shouldDeleteProjectRules` is enough to trigger a server call (to wipe + // stale project rules) even when dropdown values haven't changed. The + // caller relies on this flag to fire telemetry whenever a save actually + // hit the backend. + return { configChanged: configChanged || shouldDeleteProjectRules }; }; const roleAssignmentTransition = computed(() => { @@ -201,6 +219,7 @@ export function useUserRoleProvisioningForm(protocol: SupportedProtocolType) { mappingMethod, isUserRoleProvisioningChanged, saveProvisioningConfig, + trackProvisioningChange, roleAssignmentTransition, storedHasProjectRoles, isDroppingProjectRules,