From df1c2920def57dec22e42484fe13de09d2a5d40e Mon Sep 17 00:00:00 2001 From: JG Heithcock Date: Mon, 6 Oct 2025 15:41:29 -0700 Subject: [PATCH] MM-65126: Allow admins to change CPA values for users from the system console (#33984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update system_user_detail to enable editing of User Attributes - This adds a `updateUserCustomProfileAttributesValues` method to client4 to allow updating attributes for a given userID, as a match to getUserCustomProfilesAttributesValues - Fixed issue in saveCustomProfileAttributes where it used the version that did not take the passed in userId * Fixes for tests * Update saveCustomProfileAttribute mock in users.test.ts The `saveCustomProfileAttribute` test was still using the old getCustomProfileAttributeValuesRoute instead of the same route that `getCustomProfileAttributeValues` uses. * Adding e2e tests for editing User attributes (custom and system) in System Console * Fix testing issue with selector for 'Authentication Method' * workflows/server-ci-report.yml: security fixes by validating inputs (#33892) * [MM-61899] Properly restrict users who previously shared a team from DMs/GMs when they no longer share a team. (#30094) * [MM-61899] Properly restrict users who previously shared a team from DMs/GMs when they no longer share a team. * Fix checks * Fix test * Fix i18n * Added E2E tests * Merge'd * Add restricted DM check to more places * Merge'd * Restrict patching the channel (updating the channel) * Update verbiage in the admin console * Fix lint * More tests --------- Co-authored-by: Mattermost Build * Mirror Postgres 14 docker image (#34009) * ugprade to go 1.24.6 (#34004) * Bump Postgres minimum supported version to 14 (#34010) * Downgrade French language (#33826) * Downgrade French language * Update i18n.jsx * Updates buildFieldAttrs to preseve existing attrs when editing a field (#33991) * Updates buildFieldAttrs to preseve existing attrs when editing a field * Fix preserve option issue for select/multiselect type fields * Fix linter --------- Co-authored-by: Miguel de la Cruz * MM-64423 - Removing the clear func on switch (#31214) * MM-64423 - Removing the clear func on switch * Select all teams by default * Updating test * Updating test * Removing showFilterHaveBeenReset * Removing search string * Do not clear the search term * Updating tests --------- Co-authored-by: Mattermost Build * Mm 65123 remove channel abac ff (#33953) * MM-65123 - remove channel abac feature flag * enable the channel scope access control to true * fix linters * adjust expected error in tests * remove no longer needed comment * Remove write_restrictable from core ABAC settings and fix channel access control logic --------- Co-authored-by: Mattermost Build * [MM-65837], [MM-65824] - Update Dependencies (#33972) * Update github.com/mholt/archives * Update github.com/spf13/viper * make batch migration worker tests less flaky --------- Co-authored-by: Jesse Hallam * (a11y-test): team menu (#33998) * MM-66071: Do not error on empty slice in /groups/names (#34021) * Do not error on empty slice in /groups/names If group_names is an empty slice, this should not be an invalid parameter. We return what we were asked for: an empty array. * Avoid requests to /groups/names if list is empty If the list of group names is empty, we do not need to ask the server for the corresponding groups: we already know it'll be an empty list. * Fix ABAC not available for entry (#34027) Automatic Merge * Explicitly name Postgres container volume (#33954) * Explicitly name Postgres container volume * Remove unused server/docker-compose.yaml This file doesn't seem to actually be used. When we run docker compose locally, it uses docker-compose.makefile.yml merged with the output of build/docker-compose-generator/main.go. * Revert "Remove unused server/docker-compose.yaml" This reverts commit 5a4596521752585eba7153787abb5cb65d88dc33. * Update volume name * Flag post API (#33765) * Added enable/disable setting and feature flag * added rest of notifgication settings * Added backend for content flagging setting and populated notification values from server side defaults * WIP user selector * Added common reviewers UI * Added additonal reviewers section * WIP * WIP * Team table base * Added search in teams * Added search in teams * Added additional settings section * WIP * Inbtegrated reviewers settings * WIP * WIP * Added server side validation * cleanup * cleanup * [skip ci] * Some refactoring * type fixes * lint fix * test: add content flagging settings test file * test: add comprehensive unit tests for content flagging settings * enhanced tests * test: add test file for content flagging additional settings * test: add comprehensive unit tests for ContentFlaggingAdditionalSettingsSection * Added additoonal settings test * test: add empty test file for team reviewers section * test: add comprehensive unit tests for TeamReviewersSection component * test: update tests to handle async data fetching in team reviewers section * test: add empty test file for content reviewers component * feat: add comprehensive unit tests for ContentFlaggingContentReviewers component * Added ContentFlaggingContentReviewersContentFlaggingContentReviewers test * test: add notification settings test file for content flagging * test: add comprehensive unit tests for content flagging notification settings * Added ContentFlaggingNotificationSettingsSection tests * test: add user profile pill test file * test: add comprehensive unit tests for UserProfilePill component * refactor: Replace enzyme shallow with renderWithContext in user_profile_pill tests * Added UserProfilePill tests * test: add empty test file for content reviewers team option * test: add comprehensive unit tests for TeamOptionComponent * Added TeamOptionComponent tests * test: add empty test file for reason_option component * test: add comprehensive unit tests for ReasonOption component * Added ReasonOption tests * cleanup * Fixed i18n error * fixed e2e test lijnt issues * Updated test cases * Added snaoshot * Updated snaoshot * lint fix * WIP * lint fix * Added post flagging properties setup * review fixes * updated snapshot * CI * Added base APIs * Fetched team status data on load and team switch * WIP * Review fixes * wip * WIP * Removed an test, updated comment * CI * Added tests * Added tests * Lint fix * Added API specs * Fixed types * CI fixes * API tests * lint fixes * Set env variable so API routes are regiustered * Test update * term renaming and disabling API tests on MySQL * typo * Updated store type definition * Minor tweaks * Added tests * Removed error in app startup when content flaghging setup fails * Updated sync condition: * Flag message modal basE * added post preview * displaying options * Adde comment input * Updated tests and docs * finction rename * WIP * Updated tests * refactor * lint fix * MOved to data migration * lint fix * CI * added new migration mocks * Used setup for tests * some comment * Removed unnecesseery nil check * Form validation * WIP tests * WIP tests * WIP tests * fix: mock content flagging config selector with correct reasons format Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) * fix: add mock for getContentFlaggingConfig in flag post modal test Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) * Updated error code order in API docs * removed empty files * Added tests * lint fixes * minor tweak * lint fix * type fix * fixed test * nit * test enhancements * API WIP * API WIP * creating values * creating content flagging channel and properties * Able to save properties * Added another property field * WIP * WIP * Added validations * Added data validations and hidden post if confifgured to * lint fixes * Added API spec * Added some tests * Added tests for getContentReviewBot * test: add comprehensive tests for getContentReviewChannels function * Added more app layer tests * Added TestCanFlagPost * test: Add comprehensive tests for FlagPost function * Added all app layer tests * Removed a file that was reamoved downstream * test: add content flagging test file * test: add comprehensive tests for FlagContentRequest.IsValid method * Added model tests * test: add comprehensive tests for SqlPropertyValueStore.CreateMany * test: add comprehensive tests for flagPost() API function * Added API tests * linter fix * WIP * sent post flagging confirmation message * fixed i18n nissues * fixed i18n nissues * CI * Updated test * fix: reset contentFlaggingGroupId for test isolation in content flagging tests * removed cached group ID * removed debug log * review fixes * Used correct ot name * CI * Updated mobile text * Handled JSON error * fixerdf i18n * CI * Integrate flag post api (#33798) * WIP * WIP * Added API call * test: add test for Client4.flagPost API call in FlagPostModal * fix: remove userEvent.setup() from flag post modal test * test: wrap submit button click in act for proper state updates * Updated tests * lint fix * CI * Updated to allow special characters in comments * Handled empty comment * Used finally * CI * Fixed test * Spillage card integration (#33832) * Created getContentFlaggingFields API * created getPostPropertyValues API * WIP * Created useContentFlaggingFields hook * WIP * WIP * Added option to retain data for reviewers * Displayed deleted post's preview * DIsplayed all properties * Adding field name i18n * WIP - managing i18n able texts * Finished displaying all fields * Manual cleanup * lint fixes * team role filter logic fix * Fixed tests * created new API to fetch flagged posts * lint fix * Added new client methods * test: add comprehensive tests for content flagging APIs * Added new API tests * fixed openapi spec * Fixed DataSpillageReport tests * Fixed PostMarkdown test * Fixed PostPreviewPropertyRenderer test * Added metadata to card renderer * test fixes * Added no comment placeholder * Fixed test * refactor: improve test mocking for data spillage report component * test mock updates * Updated reducer * not resetting mocks * WIP * review fixes * CI * Fixed * fixes * Content flagging actions implementation (#33852) * Added view detail button * Created RemoveFlaggedMessageConfirmationModal modal * Added key and remove flag request modal * IMplemented delete flagged post * Handled edge cases of deleting flagged post * keep message * UI integration * Added WS event for post report update and handled deleted files of flagged post * Added error handling in keep/remove forms * i18n fixes * Updated OpenAPI specs * fixed types * fixed types * refactoring * Fixed tests * review fixes * Added new property translations * Improved test * fixed test * CI * fixes * CI * fixed a test * CI --------- Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) * GH-34031: Add accessibility tests for Sidebar settings panel (#34041) * Add accessibility tests for Sidebar settings panel * Add aria snapshots and fix accessibility test expectations for sidebar settings * Update system_user_detail to enable editing of User Attributes - This adds a `updateUserCustomProfileAttributesValues` method to client4 to allow updating attributes for a given userID, as a match to getUserCustomProfilesAttributesValues - Fixed issue in saveCustomProfileAttributes where it used the version that did not take the passed in userId * Fixes for tests * Update saveCustomProfileAttribute mock in users.test.ts The `saveCustomProfileAttribute` test was still using the old getCustomProfileAttributeValuesRoute instead of the same route that `getCustomProfileAttributeValues` uses. * Adding e2e tests for editing User attributes (custom and system) in System Console * Fix testing issue with selector for 'Authentication Method' * Fixes from PR suggestions * Fix lint/export issues * Fix playwright lint issue halting e2e smoke test * Results from npm run prettier:fix (in e2e-tests/playwright folder) --------- Co-authored-by: Mattermost Build Co-authored-by: Ibrahim Serdar Acikgoz Co-authored-by: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Co-authored-by: Alejandro García Montoro Co-authored-by: Jesse Hallam Co-authored-by: Amy Blais <29708087+amyblais@users.noreply.github.com> Co-authored-by: Miguel de la Cruz Co-authored-by: Miguel de la Cruz Co-authored-by: Asaad Mahmood Co-authored-by: Pablo Vélez Co-authored-by: Eva Sarafianou Co-authored-by: Jesse Hallam Co-authored-by: sabril <5334504+saturninoabril@users.noreply.github.com> Co-authored-by: Maria A Nunez Co-authored-by: Harrison Healey Co-authored-by: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) Co-authored-by: Mudit Sharma --- .../authentication_method_spec.js | 8 +- .../custom_profile_attributes/helpers.ts | 47 +- .../user_attributes_admin_editing.spec.ts | 429 +++++++++ .../system_user_detail.test.tsx.snap | 870 ++++++++++-------- .../admin_console/system_user_detail/index.ts | 10 +- .../system_user_detail.scss | 73 +- .../system_user_detail.test.tsx | 4 + .../system_user_detail/system_user_detail.tsx | 524 +++++++++-- webapp/channels/src/i18n/en.json | 12 + .../src/actions/users.test.ts | 2 +- .../mattermost-redux/src/actions/users.ts | 20 +- .../mattermost-redux/src/utils/helpers.ts | 7 + webapp/platform/client/src/client4.ts | 7 + 13 files changed, 1561 insertions(+), 452 deletions(-) create mode 100644 e2e-tests/playwright/specs/functional/system_console/system_users/user_attributes_admin_editing.spec.ts diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/authentication_method_spec.js b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/authentication_method_spec.js index 183479c32f6..6fd9eca1812 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/authentication_method_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/authentication_method_spec.js @@ -69,7 +69,7 @@ describe('Settings', () => { // * Verify sign-in method from user profile cy.get('.displayName').click(); - cy.get('.AdminUserCard__body > :nth-child(4) > span:nth-child(3)').should('have.text', 'Email'); + cy.get('label').contains('Authentication Method').find('span').last().should('have.text', 'Email'); cy.get('a.back').click(); // # Type saml user @@ -77,7 +77,7 @@ describe('Settings', () => { // * Verify sign-in method from user profile cy.get('.displayName').click(); - cy.get('.AdminUserCard__body > :nth-child(4) > span:nth-child(3)').should('have.text', 'SAML'); + cy.get('label').contains('Authentication Method').find('span').last().should('have.text', 'SAML'); cy.get('a.back').click(); // # Type ldap user @@ -85,7 +85,7 @@ describe('Settings', () => { // * Verify sign-in method from user profile cy.get('.displayName').click(); - cy.get('.AdminUserCard__body > :nth-child(4) > span:nth-child(3)').should('have.text', 'LDAP'); + cy.get('label').contains('Authentication Method').find('span').last().should('have.text', 'LDAP'); cy.get('a.back').click(); // # Type mfa user @@ -93,7 +93,7 @@ describe('Settings', () => { // * Verify sign-in method from user profile cy.get('.displayName').click(); - cy.get('.AdminUserCard__body > :nth-child(4) > span:nth-child(3)').should('contain.text', 'MFA'); + cy.get('label').contains('Authentication Method').find('span').last().should('contain.text', 'MFA'); cy.get('a.back').click(); }); }); diff --git a/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/helpers.ts b/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/helpers.ts index c83957aa7c2..1a14585fe8b 100644 --- a/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/helpers.ts +++ b/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/helpers.ts @@ -377,7 +377,7 @@ export async function setupCustomProfileAttributeValues( fields: Record, ): Promise { // Create a map of attribute values by field ID - const valuesByFieldId: Record = {}; + const valuesByFieldId: Record = {}; for (const attr of attributes) { let fieldID = ''; @@ -407,6 +407,51 @@ export async function setupCustomProfileAttributeValues( } } +/** + * Sets up custom profile attribute values for a specific user (admin function) + * @param {Client4} adminClient - Admin client object + * @param {Array} attributes - Array of attribute objects with name and value + * @param {Object} fields - Map of field IDs to field objects + * @param {string} targetUserId - ID of the user to set values for + */ +export async function setupCustomProfileAttributeValuesForUser( + adminClient: Client4, + attributes: CustomProfileAttribute[], + fields: Record, + targetUserId: string, +): Promise { + // Create a map of attribute values by field ID + const valuesByFieldId: Record = {}; + + for (const attr of attributes) { + let fieldID = ''; + + // Find the field ID for this attribute name + for (const [id, field] of Object.entries(fields)) { + if (field.name === attr.name) { + fieldID = id; + break; + } + } + + // If we found a matching field, add it to our values object + if (fieldID && attr.value) { + valuesByFieldId[fieldID] = attr.value; + } + } + + // Only make the API call if we have values to set + if (Object.keys(valuesByFieldId).length > 0) { + try { + // Use the admin client method for updating other user's values + await adminClient.updateUserCustomProfileAttributesValues(targetUserId, valuesByFieldId); + } catch (error) { + // eslint-disable-next-line no-console + console.log('Failed to set attribute values for user:', error); + } + } +} + /** * Deletes all custom profile attributes * @param {Client4} adminClient - Admin API client diff --git a/e2e-tests/playwright/specs/functional/system_console/system_users/user_attributes_admin_editing.spec.ts b/e2e-tests/playwright/specs/functional/system_console/system_users/user_attributes_admin_editing.spec.ts new file mode 100644 index 00000000000..c0d26d768e1 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/system_users/user_attributes_admin_editing.spec.ts @@ -0,0 +1,429 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/** + * LOCALIZATION NOTE: + * This test suite uses test attributes (data-testid, id) for buttons to avoid + * language dependencies, but still has some localization dependencies: + * - Error message text assertions (e.g., 'Invalid email address') + * - Field labels (e.g., 'Username', 'Email') + * - Test data values (e.g., 'JavaScript', 'Python' option names) + * + * Currently runs in English-only (locale: 'en-US' in playwright.config.ts). + * For multi-language support, these assertions would need refactoring. + */ + +import {Team} from '@mattermost/types/teams'; +import {UserProfile} from '@mattermost/types/users'; +import {Client4} from '@mattermost/client'; +import {UserPropertyField} from '@mattermost/types/properties'; + +import {expect, test} from '@mattermost/playwright-lib'; + +import { + CustomProfileAttribute, + setupCustomProfileAttributeFields, + setupCustomProfileAttributeValuesForUser, + deleteCustomProfileAttributes, +} from '../../channels/custom_profile_attributes/helpers'; + +// Test data for different user attribute types (non-synced only) +const testUserAttributes: CustomProfileAttribute[] = [ + { + name: 'Department', + value: 'Engineering', + type: 'text', + attrs: { + visibility: 'when_set', // Ensure it's not synced + }, + }, + { + name: 'Work Email', + value: 'work@company.com', + type: 'text', + attrs: { + value_type: 'email', + visibility: 'when_set', // Ensure it's not synced + }, + }, + { + name: 'Personal Website', + value: 'https://johndoe.com', + type: 'text', + attrs: { + value_type: 'url', + visibility: 'when_set', // Ensure it's not synced + }, + }, + { + name: 'Location', + type: 'select', + attrs: { + visibility: 'when_set', // Ensure it's not synced + }, + options: [ + {name: 'Remote', color: '#00FFFF'}, + {name: 'Office', color: '#FF00FF'}, + {name: 'Hybrid', color: '#FFFF00'}, + ], + }, + { + name: 'Skills', + type: 'multiselect', + attrs: { + visibility: 'when_set', // Ensure it's not synced + }, + options: [ + {name: 'JavaScript', color: '#F0DB4F'}, + {name: 'React', color: '#61DAFB'}, + {name: 'Python', color: '#3776AB'}, + {name: 'Go', color: '#00ADD8'}, + ], + }, +]; + +let team: Team; +let adminUser: UserProfile; +let testUser: UserProfile; +let attributeFieldsMap: Record; +let adminClient: Client4; +let systemConsolePage: any; + +test.describe('System Console - Admin User Profile Editing', () => { + test.beforeEach(async ({pw}) => { + // Ensure license for Custom Profile Attributes functionality + await pw.ensureLicense(); + await pw.skipIfNoLicense(); + + // Initialize with admin client + ({team, adminUser, adminClient} = await pw.initSetup()); + + // Create test user to edit + testUser = await pw.createNewUserProfile(adminClient, 'admin-edit-target-'); + await adminClient.addToTeam(team.id, testUser.id); + + // Set up custom user attribute fields + attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, testUserAttributes); + + // Set initial custom attribute values for the test user + await setupCustomProfileAttributeValuesForUser( + adminClient, + testUserAttributes, + attributeFieldsMap, + testUser.id, + ); + + // Login as admin + ({systemConsolePage} = await pw.testBrowser.login(adminUser)); + + // Navigate to system console users + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + await systemConsolePage.sidebar.goToItem('Users'); + await systemConsolePage.systemUsers.toBeVisible(); + + // Search for target user and navigate to user detail page + await systemConsolePage.systemUsers.enterSearchText(testUser.email); + const userRow = await systemConsolePage.systemUsers.getNthRow(1); + await userRow.getByText(testUser.email).click(); + + // Wait for user detail page to load + await systemConsolePage.page.waitForURL(`**/admin_console/user_management/user/${testUser.id}`); + }); + + test.afterEach(async ({pw}) => { + // Clean up custom user attribute fields + const {adminClient: cleanupClient} = await pw.getAdminClient(); + await deleteCustomProfileAttributes(cleanupClient, attributeFieldsMap); + }); + + test('MM-65126 Should edit custom user attributes from system console', async () => { + // # Find and edit Department field (custom text attribute) - look for input near Department label + const departmentLabel = systemConsolePage.page.locator('label').filter({hasText: /Department/}); + const departmentInput = departmentLabel.locator('input').first(); + await departmentInput.clear(); + await departmentInput.fill('Marketing'); + + // # Click Save button (using test ID instead of text) + const saveButton = systemConsolePage.page.locator('[data-testid="saveSetting"]'); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + + // * Verify success (no error message and field retains new value) + const errorMessage = systemConsolePage.page.locator('.error-message'); + await expect(errorMessage).not.toBeVisible(); + await expect(departmentInput).toHaveValue('Marketing'); + + // * Verify Save button becomes disabled after successful save + await expect(saveButton).toBeDisabled(); + }); + + test('Should display user attributes in two-column layout', async () => { + // * Verify two-column layout exists + const twoColumnLayout = systemConsolePage.page.locator('.two-column-layout'); + await expect(twoColumnLayout).toBeVisible(); + + // * Verify system fields are present (be more specific to avoid multiple matches) + await expect(systemConsolePage.page.locator('label').filter({hasText: /^Username/})).toBeVisible(); + await expect(systemConsolePage.page.locator('label').filter({hasText: /^Authentication Method/})).toBeVisible(); + // Email field - check for system email (avoid Work Email by being more specific) + const systemEmailExists = (await systemConsolePage.page.locator('input[type="email"]').count()) > 0; + expect(systemEmailExists).toBe(true); + + // * Verify custom user attributes are present + for (const field of testUserAttributes) { + await expect( + systemConsolePage.page.locator('label').filter({hasText: new RegExp(field.name)}), + ).toBeVisible(); + } + + // * Verify we have input fields (at least 4-5 total) + const inputElements = systemConsolePage.page.locator('input, select'); + const inputCount = await inputElements.count(); + expect(inputCount).toBeGreaterThan(4); + + // * Verify fields are arranged in rows with two columns + const fieldRows = systemConsolePage.page.locator('.field-row'); + const rowCount = await fieldRows.count(); + expect(rowCount).toBeGreaterThan(0); + }); + + test('Should edit system email attribute and save', async () => { + // # Find system email field + const systemEmailInput = systemConsolePage.page.locator('input[type="email"]').first(); + + // # Enter new valid email + const newEmail = `updated-${testUser.email}`; + await systemEmailInput.clear(); + await systemEmailInput.fill(newEmail); + + // # Click Save button + const saveButton = systemConsolePage.page.locator('[data-testid="saveSetting"]'); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + + // * Verify success + const errorMessage = systemConsolePage.page.locator('.error-message'); + await expect(errorMessage).not.toBeVisible(); + await expect(systemEmailInput).toHaveValue(newEmail); + await expect(saveButton).toBeDisabled(); + }); + + test('Should edit custom select attribute and save', async () => { + // # Find Location select field near its label + const locationLabel = systemConsolePage.page.locator('label').filter({hasText: /Location/}); + const locationSelect = locationLabel.locator('select').first(); + + // # Get the first available option (since we can't predict the option value/ID) + const firstOption = await locationSelect.locator('option').nth(1); // Skip the default "Select an option" + const firstOptionValue = await firstOption.getAttribute('value'); + await locationSelect.selectOption(firstOptionValue || ''); + + // # Click Save button + const saveButton = systemConsolePage.page.locator('[data-testid="saveSetting"]'); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + + // * Verify success and persistence + const errorMessage = systemConsolePage.page.locator('.error-message'); + await expect(errorMessage).not.toBeVisible(); + // Don't check exact value since it's a generated ID, just verify it's not empty + const selectedValue = await locationSelect.inputValue(); + expect(selectedValue).toBeTruthy(); + await expect(saveButton).toBeDisabled(); + }); + + test('Should display custom multiselect attribute and save form', async () => { + // * Verify Skills multiselect component is displayed + const skillsLabel = systemConsolePage.page.locator('label').filter({hasText: /Skills/}); + await expect(skillsLabel).toBeVisible(); + + // * Verify the multiselect control is present (React Select component) + // Look for common React Select patterns + const hasMultiselectElement = + (await skillsLabel.locator('div, [class*="select"], [class*="Select"]').count()) > 0; + expect(hasMultiselectElement).toBe(true); + + // # Make a change to a different field to trigger save state + const departmentLabel = systemConsolePage.page.locator('label').filter({hasText: /Department/}); + const departmentInput = departmentLabel.locator('input').first(); + await departmentInput.fill('Engineering Updated'); + + // # Verify save button becomes enabled + const saveButton = systemConsolePage.page.locator('[data-testid="saveSetting"]'); + await expect(saveButton).toBeEnabled(); + + // # Save the form + await saveButton.click(); + + // * Verify success (no error message) + const errorMessage = systemConsolePage.page.locator('.error-message'); + await expect(errorMessage).not.toBeVisible(); + + // * Verify save completed + await expect(saveButton).toBeDisabled(); + + // * Verify the change persisted + await expect(departmentInput).toHaveValue('Engineering Updated'); + }); + + test('Should validate invalid email and show error with cancel option', async () => { + // # Find system email field + const systemEmailInput = systemConsolePage.page.locator('input[type="email"]').first(); + const originalEmail = await systemEmailInput.inputValue(); + + // # Enter invalid email + await systemEmailInput.clear(); + await systemEmailInput.fill('not-an-email'); + + // # Click Save button + const saveButton = systemConsolePage.page.locator('[data-testid="saveSetting"]'); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + + // * Verify error message appears + const errorMessage = systemConsolePage.page.locator('.error-message'); + await expect(errorMessage).toBeVisible(); + await expect(errorMessage).toContainText('Invalid email address'); + + // * Verify Cancel button is visible and enabled + const cancelButton = systemConsolePage.page.locator('button:has-text("Cancel")'); + await expect(cancelButton).toBeVisible(); + await expect(cancelButton).toBeEnabled(); + + // * Verify Save button remains enabled (user can fix and retry) + await expect(saveButton).toBeEnabled(); + + // # Test the cancel functionality + await cancelButton.click(); + + // * Verify email reverts to original value + await expect(systemEmailInput).toHaveValue(originalEmail); + + // * Verify error message disappears + await expect(errorMessage).not.toBeVisible(); + + // * Verify Cancel button disappears + await expect(cancelButton).not.toBeVisible(); + + // * Verify Save button becomes disabled + await expect(saveButton).toBeDisabled(); + }); + + test('Should validate invalid URL and show error with cancel option', async () => { + // # Find custom URL field (Personal Website) + const urlInput = systemConsolePage.page.locator('input[type="url"]').first(); + const originalUrl = await urlInput.inputValue(); + + // # Enter invalid URL (specifically the one mentioned: "<%>") + await urlInput.clear(); + await urlInput.fill('<%>'); + + // # Click Save button + const saveButton = systemConsolePage.page.locator('[data-testid="saveSetting"]'); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + + // * Verify error message appears + const errorMessage = systemConsolePage.page.locator('.error-message'); + await expect(errorMessage).toBeVisible(); + await expect(errorMessage).toContainText('Invalid URL'); + + // * Verify Cancel button is visible + const cancelButton = systemConsolePage.page.locator('button:has-text("Cancel")'); + await expect(cancelButton).toBeVisible(); + await expect(cancelButton).toBeEnabled(); + + // # Test cancel functionality + await cancelButton.click(); + + // * Verify URL reverts to original value + await expect(urlInput).toHaveValue(originalUrl); + + // * Verify error message disappears + await expect(errorMessage).not.toBeVisible(); + + // * Verify Cancel button disappears + await expect(cancelButton).not.toBeVisible(); + }); + + test('Should validate invalid email in custom email attribute', async () => { + // # Find custom email field (Work Email) by its label + const workEmailLabel = systemConsolePage.page.locator('label').filter({hasText: /Work Email/}); + const workEmailInput = workEmailLabel.locator('input[type="email"]').first(); + + // # Enter invalid email + await workEmailInput.clear(); + await workEmailInput.fill('not-an-email-either'); + + // # Click Save button + const saveButton = systemConsolePage.page.locator('button:has-text("Save")'); + await saveButton.click(); + + // * Verify error message appears + const errorMessage = systemConsolePage.page.locator('.error-message'); + await expect(errorMessage).toBeVisible(); + await expect(errorMessage).toContainText('Invalid email address'); + + // * Verify Cancel button is available + const cancelButton = systemConsolePage.page.locator('button:has-text("Cancel")'); + await expect(cancelButton).toBeVisible(); + }); + + test('Should show save/cancel buttons when changes are made', async () => { + // * Initially, Save should be disabled and Cancel should not be visible + const saveButton = systemConsolePage.page.locator('[data-testid="saveSetting"]'); + const cancelButton = systemConsolePage.page.locator('button:has-text("Cancel")'); + await expect(saveButton).toBeDisabled(); + await expect(cancelButton).not.toBeVisible(); + + // # Make a change to trigger save needed state - find Department field by label + const departmentLabel = systemConsolePage.page.locator('label').filter({hasText: /Department/}); + const departmentInput = departmentLabel.locator('input').first(); + const originalValue = await departmentInput.inputValue(); + await departmentInput.clear(); + await departmentInput.fill('Changed Value'); + + // * Verify Save button becomes enabled and Cancel button appears + await expect(saveButton).toBeEnabled(); + await expect(cancelButton).toBeVisible(); + await expect(cancelButton).toBeEnabled(); + + // # Click Cancel + await cancelButton.click(); + + // * Verify changes are reverted + await expect(departmentInput).toHaveValue(originalValue); + + // * Verify Cancel button disappears + await expect(cancelButton).not.toBeVisible(); + + // * Verify Save button is disabled + await expect(saveButton).toBeDisabled(); + }); + + test('Should save all user attribute changes atomically', async () => { + // # Make changes to both system and custom attributes + const systemEmailInput = systemConsolePage.page.locator('input[type="email"]').first(); + const newEmail = `atomic-test-${testUser.email}`; + await systemEmailInput.clear(); + await systemEmailInput.fill(newEmail); + + const departmentLabel = systemConsolePage.page.locator('label').filter({hasText: /Department/}); + const departmentInput = departmentLabel.locator('input').first(); + await departmentInput.clear(); + await departmentInput.fill('Sales'); + + // # Click Save button + const saveButton = systemConsolePage.page.locator('[data-testid="saveSetting"]'); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + + // * Verify both changes were saved successfully + const errorMessage = systemConsolePage.page.locator('.error-message'); + await expect(errorMessage).not.toBeVisible(); + await expect(systemEmailInput).toHaveValue(newEmail); + await expect(departmentInput).toHaveValue('Sales'); + await expect(saveButton).toBeDisabled(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/system_user_detail/__snapshots__/system_user_detail.test.tsx.snap b/webapp/channels/src/components/admin_console/system_user_detail/__snapshots__/system_user_detail.test.tsx.snap index 01d7c3fd105..9d4278929eb 100644 --- a/webapp/channels/src/components/admin_console/system_user_detail/__snapshots__/system_user_detail.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/system_user_detail/__snapshots__/system_user_detail.test.tsx.snap @@ -67,62 +67,77 @@ exports[`SystemUserDetail should match default snapshot 1`] = ` class="AdminUserCard__body" > -