MM-65126: Allow admins to change CPA values for users from the system console (#33984)

* 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 <build@mattermost.com>

* 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 <miguel@ctrlz.es>

* 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 <build@mattermost.com>

* 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 <build@mattermost.com>

* [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 <jesse@mattermost.com>

* (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 5a45965217.

* 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) <aider@aider.chat>

* fix: add mock for getContentFlaggingConfig in flag post modal test

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>

* 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) <aider@aider.chat>

* 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 <build@mattermost.com>
Co-authored-by: Ibrahim Serdar Acikgoz <serdaracikgoz86@gmail.com>
Co-authored-by: Devin Binnie <52460000+devinbinnie@users.noreply.github.com>
Co-authored-by: Alejandro García Montoro <alejandro.garciamontoro@gmail.com>
Co-authored-by: Jesse Hallam <jesse.hallam@gmail.com>
Co-authored-by: Amy Blais <29708087+amyblais@users.noreply.github.com>
Co-authored-by: Miguel de la Cruz <miguel@mcrx.me>
Co-authored-by: Miguel de la Cruz <miguel@ctrlz.es>
Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Pablo Vélez <pablovv2012@gmail.com>
Co-authored-by: Eva Sarafianou <eva.sarafianou@gmail.com>
Co-authored-by: Jesse Hallam <jesse@mattermost.com>
Co-authored-by: sabril <5334504+saturninoabril@users.noreply.github.com>
Co-authored-by: Maria A Nunez <maria.nunez@mattermost.com>
Co-authored-by: Harrison Healey <harrisonmhealey@gmail.com>
Co-authored-by: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com>
Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>
Co-authored-by: Mudit Sharma <ms1725@srmist.edu.in>
This commit is contained in:
JG Heithcock 2025-10-06 15:41:29 -07:00 committed by GitHub
parent 5a0dee5fc2
commit df1c2920de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1561 additions and 452 deletions

View file

@ -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();
});
});

View file

@ -377,7 +377,7 @@ export async function setupCustomProfileAttributeValues(
fields: Record<string, UserPropertyField>,
): Promise<void> {
// Create a map of attribute values by field ID
const valuesByFieldId: Record<string, string> = {};
const valuesByFieldId: Record<string, string | string[]> = {};
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<string, UserPropertyField>,
targetUserId: string,
): Promise<void> {
// Create a map of attribute values by field ID
const valuesByFieldId: Record<string, string | string[]> = {};
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

View file

@ -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<string, UserPropertyField>;
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();
});
});

View file

@ -67,62 +67,77 @@ exports[`SystemUserDetail should match default snapshot 1`] = `
class="AdminUserCard__body"
>
<span />
<label>
Email
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<div
class="two-column-layout"
>
<div
class="field-row"
>
<div
class="field-column left"
>
<path
d="M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6M20 6L12 11L4 6H20M20 18H4V8L12 13L20 8V18Z"
fill="inherit"
/>
</svg>
</span>
<input
class="form-control"
type="text"
value=""
/>
</label>
<label>
Username
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<label>
Username
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z"
fill="inherit"
/>
</svg>
</span>
<span>
some-user
</span>
</label>
</div>
<div
class="field-column right"
>
<path
d="M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z"
fill="inherit"
/>
</svg>
</span>
<span>
some-user
</span>
</label>
<label>
Authentication Method
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<label>
Authentication Method
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21Z"
fill="inherit"
/>
</svg>
</span>
<span>
Email
</span>
</label>
</div>
</div>
<div
class="field-row"
>
<div
class="field-column left"
>
<path
d="M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21Z"
fill="inherit"
/>
</svg>
</span>
<span>
Email
</span>
</label>
<label>
Email
<input
class="form-control"
type="text"
value=""
/>
</label>
</div>
<div
class="field-column right"
/>
</div>
</div>
</div>
<div
class="AdminUserCard__footer"
@ -190,17 +205,21 @@ exports[`SystemUserDetail should match default snapshot 1`] = `
<div
class="admin-console-save"
>
<button
class="btn btn-primary"
data-testid="saveSetting"
disabled=""
id="saveSetting"
type="submit"
<div
class="admin-console-save-buttons"
>
<span>
Save
</span>
</button>
<button
class="btn btn-primary"
data-testid="saveSetting"
disabled=""
id="saveSetting"
type="submit"
>
<span>
Save
</span>
</button>
</div>
<div
class="error-message"
/>
@ -276,62 +295,77 @@ exports[`SystemUserDetail should match snapshot if MFA is enabled 1`] = `
class="AdminUserCard__body"
>
<span />
<label>
Email
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<div
class="two-column-layout"
>
<div
class="field-row"
>
<div
class="field-column left"
>
<path
d="M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6M20 6L12 11L4 6H20M20 18H4V8L12 13L20 8V18Z"
fill="inherit"
/>
</svg>
</span>
<input
class="form-control"
type="text"
value=""
/>
</label>
<label>
Username
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<label>
Username
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z"
fill="inherit"
/>
</svg>
</span>
<span>
some-user
</span>
</label>
</div>
<div
class="field-column right"
>
<path
d="M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z"
fill="inherit"
/>
</svg>
</span>
<span>
some-user
</span>
</label>
<label>
Authentication Method
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<label>
Authentication Method
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21Z"
fill="inherit"
/>
</svg>
</span>
<span>
Email
</span>
</label>
</div>
</div>
<div
class="field-row"
>
<div
class="field-column left"
>
<path
d="M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21Z"
fill="inherit"
/>
</svg>
</span>
<span>
Email
</span>
</label>
<label>
Email
<input
class="form-control"
type="text"
value=""
/>
</label>
</div>
<div
class="field-column right"
/>
</div>
</div>
</div>
<div
class="AdminUserCard__footer"
@ -399,17 +433,21 @@ exports[`SystemUserDetail should match snapshot if MFA is enabled 1`] = `
<div
class="admin-console-save"
>
<button
class="btn btn-primary"
data-testid="saveSetting"
disabled=""
id="saveSetting"
type="submit"
<div
class="admin-console-save-buttons"
>
<span>
Save
</span>
</button>
<button
class="btn btn-primary"
data-testid="saveSetting"
disabled=""
id="saveSetting"
type="submit"
>
<span>
Save
</span>
</button>
</div>
<div
class="error-message"
/>
@ -485,62 +523,77 @@ exports[`SystemUserDetail should not show manage user settings button when user
class="AdminUserCard__body"
>
<span />
<label>
Email
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<div
class="two-column-layout"
>
<div
class="field-row"
>
<div
class="field-column left"
>
<path
d="M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6M20 6L12 11L4 6H20M20 18H4V8L12 13L20 8V18Z"
fill="inherit"
/>
</svg>
</span>
<input
class="form-control"
type="text"
value=""
/>
</label>
<label>
Username
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<label>
Username
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z"
fill="inherit"
/>
</svg>
</span>
<span>
some-user
</span>
</label>
</div>
<div
class="field-column right"
>
<path
d="M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z"
fill="inherit"
/>
</svg>
</span>
<span>
some-user
</span>
</label>
<label>
Authentication Method
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<label>
Authentication Method
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21Z"
fill="inherit"
/>
</svg>
</span>
<span>
Email
</span>
</label>
</div>
</div>
<div
class="field-row"
>
<div
class="field-column left"
>
<path
d="M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21Z"
fill="inherit"
/>
</svg>
</span>
<span>
Email
</span>
</label>
<label>
Email
<input
class="form-control"
type="text"
value=""
/>
</label>
</div>
<div
class="field-column right"
/>
</div>
</div>
</div>
<div
class="AdminUserCard__footer"
@ -608,17 +661,21 @@ exports[`SystemUserDetail should not show manage user settings button when user
<div
class="admin-console-save"
>
<button
class="btn btn-primary"
data-testid="saveSetting"
disabled=""
id="saveSetting"
type="submit"
<div
class="admin-console-save-buttons"
>
<span>
Save
</span>
</button>
<button
class="btn btn-primary"
data-testid="saveSetting"
disabled=""
id="saveSetting"
type="submit"
>
<span>
Save
</span>
</button>
</div>
<div
class="error-message"
/>
@ -694,62 +751,77 @@ exports[`SystemUserDetail should show manage user settings button as activated 1
class="AdminUserCard__body"
>
<span />
<label>
Email
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<div
class="two-column-layout"
>
<div
class="field-row"
>
<div
class="field-column left"
>
<path
d="M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6M20 6L12 11L4 6H20M20 18H4V8L12 13L20 8V18Z"
fill="inherit"
/>
</svg>
</span>
<input
class="form-control"
type="text"
value=""
/>
</label>
<label>
Username
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<label>
Username
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z"
fill="inherit"
/>
</svg>
</span>
<span>
some-user
</span>
</label>
</div>
<div
class="field-column right"
>
<path
d="M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z"
fill="inherit"
/>
</svg>
</span>
<span>
some-user
</span>
</label>
<label>
Authentication Method
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<label>
Authentication Method
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21Z"
fill="inherit"
/>
</svg>
</span>
<span>
Email
</span>
</label>
</div>
</div>
<div
class="field-row"
>
<div
class="field-column left"
>
<path
d="M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21Z"
fill="inherit"
/>
</svg>
</span>
<span>
Email
</span>
</label>
<label>
Email
<input
class="form-control"
type="text"
value=""
/>
</label>
</div>
<div
class="field-column right"
/>
</div>
</div>
</div>
<div
class="AdminUserCard__footer"
@ -823,17 +895,21 @@ exports[`SystemUserDetail should show manage user settings button as activated 1
<div
class="admin-console-save"
>
<button
class="btn btn-primary"
data-testid="saveSetting"
disabled=""
id="saveSetting"
type="submit"
<div
class="admin-console-save-buttons"
>
<span>
Save
</span>
</button>
<button
class="btn btn-primary"
data-testid="saveSetting"
disabled=""
id="saveSetting"
type="submit"
>
<span>
Save
</span>
</button>
</div>
<div
class="error-message"
/>
@ -909,62 +985,77 @@ exports[`SystemUserDetail should show manage user settings button as disabled wh
class="AdminUserCard__body"
>
<span />
<label>
Email
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<div
class="two-column-layout"
>
<div
class="field-row"
>
<div
class="field-column left"
>
<path
d="M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6M20 6L12 11L4 6H20M20 18H4V8L12 13L20 8V18Z"
fill="inherit"
/>
</svg>
</span>
<input
class="form-control"
type="text"
value=""
/>
</label>
<label>
Username
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<label>
Username
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z"
fill="inherit"
/>
</svg>
</span>
<span>
some-user
</span>
</label>
</div>
<div
class="field-column right"
>
<path
d="M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z"
fill="inherit"
/>
</svg>
</span>
<span>
some-user
</span>
</label>
<label>
Authentication Method
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<label>
Authentication Method
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21Z"
fill="inherit"
/>
</svg>
</span>
<span>
Email
</span>
</label>
</div>
</div>
<div
class="field-row"
>
<div
class="field-column left"
>
<path
d="M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21Z"
fill="inherit"
/>
</svg>
</span>
<span>
Email
</span>
</label>
<label>
Email
<input
class="form-control"
type="text"
value=""
/>
</label>
</div>
<div
class="field-column right"
/>
</div>
</div>
</div>
<div
class="AdminUserCard__footer"
@ -1032,17 +1123,21 @@ exports[`SystemUserDetail should show manage user settings button as disabled wh
<div
class="admin-console-save"
>
<button
class="btn btn-primary"
data-testid="saveSetting"
disabled=""
id="saveSetting"
type="submit"
<div
class="admin-console-save-buttons"
>
<span>
Save
</span>
</button>
<button
class="btn btn-primary"
data-testid="saveSetting"
disabled=""
id="saveSetting"
type="submit"
>
<span>
Save
</span>
</button>
</div>
<div
class="error-message"
/>
@ -1118,62 +1213,77 @@ exports[`SystemUserDetail should show the activate user button as disabled when
class="AdminUserCard__body"
>
<span />
<label>
Email
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<div
class="two-column-layout"
>
<div
class="field-row"
>
<div
class="field-column left"
>
<path
d="M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6M20 6L12 11L4 6H20M20 18H4V8L12 13L20 8V18Z"
fill="inherit"
/>
</svg>
</span>
<input
class="form-control"
type="text"
value=""
/>
</label>
<label>
Username
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<label>
Username
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z"
fill="inherit"
/>
</svg>
</span>
<span>
some-user
</span>
</label>
</div>
<div
class="field-column right"
>
<path
d="M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z"
fill="inherit"
/>
</svg>
</span>
<span>
some-user
</span>
</label>
<label>
Authentication Method
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
<label>
Authentication Method
<span>
<svg
height="100%"
viewBox="0 0 24 24"
width="100%"
>
<path
d="M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21Z"
fill="inherit"
/>
</svg>
</span>
<span>
LDAP
</span>
</label>
</div>
</div>
<div
class="field-row"
>
<div
class="field-column left"
>
<path
d="M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21Z"
fill="inherit"
/>
</svg>
</span>
<span>
LDAP
</span>
</label>
<label>
Email
<input
class="form-control"
type="text"
value=""
/>
</label>
</div>
<div
class="field-column right"
/>
</div>
</div>
</div>
<div
class="AdminUserCard__footer"
@ -1244,17 +1354,21 @@ exports[`SystemUserDetail should show the activate user button as disabled when
<div
class="admin-console-save"
>
<button
class="btn btn-primary"
data-testid="saveSetting"
disabled=""
id="saveSetting"
type="submit"
<div
class="admin-console-save-buttons"
>
<span>
Save
</span>
</button>
<button
class="btn btn-primary"
data-testid="saveSetting"
disabled=""
id="saveSetting"
type="submit"
>
<span>
Save
</span>
</button>
</div>
<div
class="error-message"
/>

View file

@ -6,10 +6,11 @@ import {connect} from 'react-redux';
import type {GlobalState} from '@mattermost/types/store';
import {getCustomProfileAttributeFields} from 'mattermost-redux/actions/general';
import {getUserPreferences} from 'mattermost-redux/actions/preferences';
import {addUserToTeam} from 'mattermost-redux/actions/teams';
import {updateUserActive, getUser, patchUser, updateUserMfa} from 'mattermost-redux/actions/users';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {updateUserActive, getUser, patchUser, updateUserMfa, getCustomProfileAttributeValues, saveCustomProfileAttribute} from 'mattermost-redux/actions/users';
import {getConfig, getCustomProfileAttributes} from 'mattermost-redux/selectors/entities/general';
import {setNavigationBlocked} from 'actions/admin_actions.jsx';
import {openModal} from 'actions/views/modals';
@ -19,12 +20,14 @@ import SystemUserDetail from './system_user_detail';
function mapStateToProps(state: GlobalState) {
const config = getConfig(state);
const customProfileAttributeFields = Object.values(getCustomProfileAttributes(state));
const showManageUserSettings = getShowManageUserSettings(state);
const showLockedManageUserSettings = getShowLockedManageUserSettings(state);
return {
mfaEnabled: config?.EnableMultifactorAuthentication === 'true' || false,
customProfileAttributeFields,
showManageUserSettings,
showLockedManageUserSettings,
};
@ -39,6 +42,9 @@ const mapDispatchToProps = {
setNavigationBlocked,
openModal,
getUserPreferences,
getCustomProfileAttributeFields,
getCustomProfileAttributeValues,
saveCustomProfileAttribute,
};
const connector = connect(mapStateToProps, mapDispatchToProps);

View file

@ -1,5 +1,26 @@
.SystemUserDetail {
.AdminUserCard__body {
.two-column-layout {
.field-row {
display: flex;
margin-bottom: 16px;
gap: 20px;
.field-column {
min-width: 0; // Allow content to shrink
flex: 1;
&.left {
padding-right: 10px;
}
&.right {
padding-left: 10px;
}
}
}
}
label {
display: block;
font-weight: unset;
@ -16,10 +37,40 @@
vertical-align: middle;
}
input {
input, select {
display: inline-block;
width: 100%;
max-width: 320px;
}
select[multiple] {
min-height: 80px;
}
}
.cpa-field {
position: relative;
}
.user-property-field-values__sync-indicator {
display: flex;
align-items: center;
margin-top: 4px;
color: rgba(var(--center-channel-color-rgb), 0.7);
font-size: 11px;
svg {
width: 14px;
height: 14px;
margin-right: 6px;
fill: rgba(var(--center-channel-color-rgb), 0.5);
}
}
.auth-data-field {
padding-top: 20px;
border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.1);
margin-top: 20px;
}
svg {
@ -44,4 +95,24 @@
cursor: pointer;
}
}
// Mobile responsiveness for two-column layout
@media (max-width: 768px) {
.AdminUserCard__body {
.two-column-layout {
.field-row {
flex-direction: column;
gap: 0;
.field-column {
padding: 0;
&.right {
margin-top: 16px;
}
}
}
}
}
}
}

View file

@ -30,6 +30,7 @@ describe('SystemUserDetail', () => {
showManageUserSettings: false,
showLockedManageUserSettings: false,
mfaEnabled: false,
customProfileAttributeFields: [],
patchUser: jest.fn(),
updateUserMfa: jest.fn(),
getUser: getUserMock,
@ -38,6 +39,9 @@ describe('SystemUserDetail', () => {
addUserToTeam: jest.fn(),
openModal: jest.fn(),
getUserPreferences: jest.fn(),
getCustomProfileAttributeFields: jest.fn().mockResolvedValue({data: []}),
getCustomProfileAttributeValues: jest.fn().mockResolvedValue({data: {}}),
saveCustomProfileAttribute: jest.fn().mockResolvedValue({data: {}}),
intl: {
formatMessage: jest.fn(),
} as MockIntl,

View file

@ -1,19 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import classNames from 'classnames';
import React, {PureComponent} from 'react';
import type {ChangeEvent, MouseEvent} from 'react';
import type {IntlShape, WrappedComponentProps} from 'react-intl';
import {FormattedMessage, defineMessage, injectIntl} from 'react-intl';
import type {RouteComponentProps} from 'react-router-dom';
import ReactSelect from 'react-select';
import {SyncIcon} from '@mattermost/compass-icons/components';
import type {ServerError} from '@mattermost/types/errors';
import type {UserPropertyField} from '@mattermost/types/properties';
import type {Team, TeamMembership} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {isEmail} from 'mattermost-redux/utils/helpers';
import {isEmail, getInputTypeFromValueType} from 'mattermost-redux/utils/helpers';
import AdminUserCard from 'components/admin_console/admin_user_card/admin_user_card';
import BlockableLink from 'components/admin_console/blockable_link';
@ -28,18 +33,71 @@ import UserSettingsModal from 'components/user_settings/modal';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import AdminPanel from 'components/widgets/admin_console/admin_panel';
import AtIcon from 'components/widgets/icons/at_icon';
import EmailIcon from 'components/widgets/icons/email_icon';
import SheidOutlineIcon from 'components/widgets/icons/shield_outline_icon';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import WithTooltip from 'components/with_tooltip';
import {Constants, ModalIdentifiers} from 'utils/constants';
import {validHttpUrl} from 'utils/url';
import {toTitleCase} from 'utils/utils';
import type {PropsFromRedux} from './index';
import './system_user_detail.scss';
// Private component for CPA multiselect fields
type CPAMultiSelectProps = {
options: Array<{id: string; name: string}>;
selectedValues: string[];
onChange: (values: string[]) => void;
disabled: boolean;
placeholder: string;
noOptionsMessage: string;
};
const CPAMultiSelect: React.FC<CPAMultiSelectProps> = ({
options,
selectedValues,
onChange,
disabled,
placeholder,
noOptionsMessage,
}) => {
// Transform options to ReactSelect format
const selectOptions = options.map((option) => ({
value: option.id,
label: option.name,
}));
// Transform selected values to ReactSelect format
const selectedOptions = selectedValues.map((selectedId) => {
const option = options.find((opt) => opt.id === selectedId);
return option ? {value: option.id, label: option.name} : null;
}).filter((opt): opt is {value: string; label: string} => opt !== null);
return (
<ReactSelect
isMulti={true}
options={selectOptions}
value={selectedOptions}
onChange={(selectedOptions) => {
const selectedIds = selectedOptions ? selectedOptions.map((opt) => opt.value) : [];
onChange(selectedIds);
}}
isDisabled={disabled}
isClearable={false}
placeholder={placeholder}
noOptionsMessage={() => noOptionsMessage}
styles={{
container: (provided) => ({
...provided,
maxWidth: '320px',
}),
}}
/>
);
};
export type Params = {
user_id?: UserProfile['id'];
};
@ -49,6 +107,9 @@ export type Props = PropsFromRedux & RouteComponentProps<Params> & WrappedCompon
export type State = {
user?: UserProfile;
emailField: string;
customProfileAttributeFields: UserPropertyField[];
customProfileAttributeValues: Record<string, string | string[]>;
originalCpaValues: Record<string, string | string[]>;
isLoading: boolean;
error?: string | null;
isSaveNeeded: boolean;
@ -66,6 +127,9 @@ export class SystemUserDetail extends PureComponent<Props, State> {
super(props);
this.state = {
emailField: '',
customProfileAttributeFields: [],
customProfileAttributeValues: {},
originalCpaValues: {},
isLoading: false,
error: null,
isSaveNeeded: false,
@ -83,15 +147,23 @@ export class SystemUserDetail extends PureComponent<Props, State> {
this.setState({isLoading: true});
try {
const {data, error} = await this.props.getUser(userId) as ActionResult<UserProfile, ServerError>;
if (data) {
// Fetch user data and CPA values in parallel
const [userResult, cpaResult] = await Promise.all([
this.props.getUser(userId) as ActionResult<UserProfile, ServerError>,
this.props.getCustomProfileAttributeValues(userId),
]);
if (userResult.data) {
const cpaValues = (cpaResult as {data?: Record<string, string | string[]>}).data || {};
this.setState({
user: data,
emailField: data.email, // Set emailField to the email of the user for editing purposes
user: userResult.data,
emailField: userResult.data.email, // Set emailField to the email of the user for editing purposes
customProfileAttributeValues: cpaValues,
originalCpaValues: {...cpaValues}, // Deep copy for change tracking
isLoading: false,
});
} else {
throw new Error(error ? error.message : 'Unknown error');
throw new Error(userResult.error ? userResult.error.message : this.props.intl.formatMessage({id: 'admin.user_item.unknownError', defaultMessage: 'Unknown error'}));
}
} catch (error) {
console.log('SystemUserDetails-getUser: ', error); // eslint-disable-line no-console
@ -109,6 +181,11 @@ export class SystemUserDetail extends PureComponent<Props, State> {
// We dont have to handle the case of userId being empty here because the redirect will take care of it from the parent components
this.getUser(userId);
}
// Fetch CPA field definitions if not already available
if (this.props.customProfileAttributeFields.length === 0) {
this.props.getCustomProfileAttributeFields();
}
}
handleTeamsLoaded = (teams: TeamMembership[]) => {
@ -202,13 +279,257 @@ export class SystemUserDetail extends PureComponent<Props, State> {
const {target: {value}} = event;
const didEmailChanged = value !== this.state.user.email;
this.setState({
emailField: value,
isSaveNeeded: didEmailChanged,
error: null, // Clear any validation errors when user starts editing
}, () => {
this.checkForChanges();
});
};
handleCpaValueChange = (fieldId: string, value: string | string[]) => {
this.setState({
customProfileAttributeValues: {
...this.state.customProfileAttributeValues,
[fieldId]: value,
},
error: null, // Clear any validation errors when user starts editing
}, () => {
this.checkForChanges();
});
};
checkForChanges = () => {
if (!this.state.user) {
return;
}
const emailChanged = this.state.emailField !== this.state.user.email;
const cpaChanged = this.hasCpaChanges();
const hasChanges = emailChanged || cpaChanged;
this.setState({
isSaveNeeded: hasChanges,
});
this.props.setNavigationBlocked(didEmailChanged);
this.props.setNavigationBlocked(hasChanges);
};
hasCpaChanges = (): boolean => {
const {customProfileAttributeValues, originalCpaValues} = this.state;
// Check if any CPA value has changed
const currentFields = new Set([...Object.keys(customProfileAttributeValues), ...Object.keys(originalCpaValues)]);
for (const fieldId of currentFields) {
const currentValue = customProfileAttributeValues[fieldId];
const originalValue = originalCpaValues[fieldId];
// Handle array comparison for multiselect fields
if (Array.isArray(currentValue) && Array.isArray(originalValue)) {
if (currentValue.length !== originalValue.length ||
currentValue.some((val, idx) => val !== originalValue[idx])) {
return true;
}
} else if (currentValue !== originalValue) {
return true;
}
}
return false;
};
renderCpaField = (field: UserPropertyField) => {
const value = this.state.customProfileAttributeValues[field.id] || '';
const isSynced = Boolean(field.attrs?.ldap || field.attrs?.saml);
const isDisabled = this.state.isSaving || this.state.isLoading || isSynced;
// Render sync indicator if field is synced
const syncIndicator = isSynced ? (
<div className='user-property-field-values__sync-indicator'>
<SyncIcon size={18}/>
<span>
<FormattedMessage
id='admin.userManagement.userDetail.syncedWith'
defaultMessage='Synced with: {source}'
values={{
source: field.attrs?.ldap ? this.props.intl.formatMessage({id: 'admin.userManagement.userDetail.ldap', defaultMessage: 'AD/LDAP: {propertyName}'}, {propertyName: field.attrs.ldap}) : this.props.intl.formatMessage({id: 'admin.userManagement.userDetail.saml', defaultMessage: 'SAML: {propertyName}'}, {propertyName: field.attrs?.saml}),
}}
/>
</span>
</div>
) : null;
const fieldContent = (() => {
switch (field.type) {
case 'select': {
const options = field.attrs?.options || [];
return (
<select
className='form-control'
value={Array.isArray(value) ? value[0] || '' : value}
onChange={(e) => this.handleCpaValueChange(field.id, e.target.value)}
disabled={isDisabled}
>
<option value=''>
{this.props.intl.formatMessage({
id: 'admin.userManagement.userDetail.selectOption',
defaultMessage: 'Select an option',
})}
</option>
{options.map((option) => (
<option
key={option.id}
value={option.id}
>
{option.name}
</option>
))}
</select>
);
}
case 'multiselect': {
const options = field.attrs?.options || [];
const selectedValues = Array.isArray(value) ? value : [];
return (
<CPAMultiSelect
options={options}
selectedValues={selectedValues}
onChange={(values) => this.handleCpaValueChange(field.id, values)}
disabled={isDisabled}
placeholder={this.props.intl.formatMessage({
id: 'admin.user.selectOptions',
defaultMessage: 'Select options...',
})}
noOptionsMessage={this.props.intl.formatMessage({
id: 'admin.userManagement.userDetail.noOptions',
defaultMessage: 'No options available',
})}
/>
);
}
case 'text':
default: {
const inputType = getInputTypeFromValueType(field.attrs?.value_type);
return (
<input
className='form-control'
type={inputType}
value={Array.isArray(value) ? value.join(this.props.intl.formatMessage({id: 'admin.userManagement.userDetail.arrayValueSeparator', defaultMessage: ', '})) : value}
onChange={(e) => this.handleCpaValueChange(field.id, e.target.value)}
disabled={isDisabled}
/>
);
}
}
})();
return (
<label
key={field.id}
className='cpa-field'
>
<FormattedMessage
id='admin.userManagement.userDetail.cpaField'
defaultMessage='{fieldName}'
values={{fieldName: field.name}}
/>
{fieldContent}
{syncIndicator}
</label>
);
};
renderTwoColumnLayout = () => {
const sortedCpaFields = [...this.props.customProfileAttributeFields].
sort((a, b) => (a.attrs?.sort_order || 0) - (b.attrs?.sort_order || 0));
const fields: Array<React.ReactNode | null> = [];
// Add system fields
fields.push(
<label key='username'>
<FormattedMessage
id='admin.userManagement.userDetail.username'
defaultMessage='Username'
/>
<AtIcon/>
<span>{this.state.user?.username}</span>
</label>,
);
fields.push(
<label key='authMethod'>
<FormattedMessage
id='admin.userManagement.userDetail.authenticationMethod'
defaultMessage='Authentication Method'
/>
<SheidOutlineIcon/>
<span>{getUserAuthenticationTextField(this.props.intl, this.props.mfaEnabled, this.state.user)}</span>
</label>,
);
fields.push(
<label key='email'>
<FormattedMessage
id='admin.userManagement.userDetail.email'
defaultMessage='Email'
/>
<input
className='form-control'
type='text'
value={this.state.emailField}
onChange={this.handleEmailChange}
disabled={this.state.isSaving || this.state.isLoading}
/>
</label>,
);
// Add CPA fields
for (const field of sortedCpaFields) {
fields.push(this.renderCpaField(field));
}
// Pad for even number
if (fields.length % 2) {
fields.push(null);
}
return (
<div className='two-column-layout'>
{fields.map((field, index) => {
if (index % 2 === 0) { // Start of new row
return (
<div
key={`field-row-${Math.trunc(index / 2)}`}
className='field-row'
>
<div className='field-column left'>
{field}
</div>
<div className='field-column right'>
{fields[index + 1]}
</div>
</div>
);
}
return null; // Skip odd indices
}).filter(Boolean)}
</div>
);
};
handleCancel = () => {
// Reset all fields to original values
this.setState({
emailField: this.state.user?.email || '',
customProfileAttributeValues: {...this.state.originalCpaValues},
error: null,
isSaveNeeded: false,
});
this.props.setNavigationBlocked(false);
};
handleSubmit = async (event: MouseEvent<HTMLButtonElement>) => {
@ -218,16 +539,43 @@ export class SystemUserDetail extends PureComponent<Props, State> {
return;
}
if (this.state.user.email === this.state.emailField) {
if (!this.state.isSaveNeeded) {
return;
}
if (!isEmail(this.state.user.email)) {
// Validate email if changed
const emailChanged = this.state.user.email !== this.state.emailField;
if (emailChanged && !isEmail(this.state.emailField)) {
this.setState({error: this.props.intl.formatMessage({id: 'admin.user_item.invalidEmail', defaultMessage: 'Invalid email address'})});
return;
}
const updatedUser = Object.assign({}, this.state.user, {email: this.state.emailField.trim().toLowerCase()});
// Validate CPA values if changed
const cpaChanged = this.hasCpaChanges();
if (cpaChanged) {
const {customProfileAttributeFields} = this.props;
for (const field of customProfileAttributeFields) {
const valueType = field.attrs?.value_type;
const currentValue = this.state.customProfileAttributeValues[field.id];
const originalValue = this.state.originalCpaValues[field.id];
if (!currentValue || !valueType || currentValue === originalValue) {
continue;
}
if (valueType === 'email') {
const stringValue = String(currentValue);
if (!isEmail(stringValue)) {
this.setState({error: this.props.intl.formatMessage({id: 'admin.user_item.invalidEmail', defaultMessage: 'Invalid email address'})});
return;
}
} else if (valueType === 'url') {
const stringValue = String(currentValue);
if (validHttpUrl(stringValue) === null) {
this.setState({error: this.props.intl.formatMessage({id: 'admin.user_item.invalidUrl', defaultMessage: 'Invalid URL'})});
return;
}
}
}
}
this.setState({
error: null,
@ -235,17 +583,79 @@ export class SystemUserDetail extends PureComponent<Props, State> {
});
try {
const {data, error} = await this.props.patchUser(updatedUser) as ActionResult<UserProfile, ServerError>;
if (data) {
this.setState({
user: data,
emailField: data.email,
error: null,
isSaving: false,
isSaveNeeded: false,
});
} else {
throw new Error(error ? error.message : 'Unknown error');
const promises = [];
// Update user profile if email changed
if (emailChanged) {
const updatedUser = Object.assign({}, this.state.user, {email: this.state.emailField.trim().toLowerCase()});
promises.push(this.props.patchUser(updatedUser));
}
// Update CPA values if changed
if (cpaChanged) {
// Get only changed CPA values and save each one using Redux action
const {customProfileAttributeFields} = this.props;
for (const field of customProfileAttributeFields) {
const currentValue = this.state.customProfileAttributeValues[field.id];
const originalValue = this.state.originalCpaValues[field.id];
// Check if this field value has changed
let hasChanged = false;
if (Array.isArray(currentValue) && Array.isArray(originalValue)) {
hasChanged = currentValue.length !== originalValue.length ||
currentValue.some((val, idx) => val !== originalValue[idx]);
} else {
hasChanged = currentValue !== originalValue;
}
if (hasChanged) {
promises.push(this.props.saveCustomProfileAttribute(this.state.user!.id, field.id, currentValue || ''));
}
}
}
// Execute all updates in parallel
const results = await Promise.all(promises);
// Handle results
let updatedUser = this.state.user;
let resultIndex = 0;
// Handle user update result if email was changed
if (emailChanged) {
const userResult = results[resultIndex] as ActionResult<UserProfile, ServerError>;
if (userResult.data) {
updatedUser = userResult.data;
} else if (userResult.error) {
throw new Error(userResult.error.message);
}
resultIndex++;
}
// Handle CPA update results if CPA values were changed
if (cpaChanged) {
// Check remaining results for any CPA save errors
for (let i = resultIndex; i < results.length; i++) {
const cpaResult = results[i] as ActionResult<Record<string, string | string[]>, ServerError>;
if (cpaResult.error) {
throw new Error(cpaResult.error.message);
}
}
}
// Update state with successful results
this.setState({
user: updatedUser,
emailField: updatedUser.email,
originalCpaValues: {...this.state.customProfileAttributeValues}, // Update original values
error: null,
isSaving: false,
isSaveNeeded: false,
});
// Refresh user data to ensure we have latest CPA values from server
if (cpaChanged) {
await this.props.getCustomProfileAttributeValues(this.state.user.id);
}
} catch (err) {
console.error('SystemUserDetails-handleSubmit', err); // eslint-disable-line no-console
@ -253,8 +663,8 @@ export class SystemUserDetail extends PureComponent<Props, State> {
this.setState({
error: this.props.intl.formatMessage({id: 'admin.user_item.userUpdateFailed', defaultMessage: 'Failed to update user'}),
isSaving: false,
isSaveNeeded: false,
});
return; // Don't unblock navigation on error
}
this.props.setNavigationBlocked(false);
@ -368,38 +778,9 @@ export class SystemUserDetail extends PureComponent<Props, State> {
body={
<>
<span>{this.state.user?.position ?? ''}</span>
<label>
<FormattedMessage
id='admin.userManagement.userDetail.email'
defaultMessage='Email'
/>
<EmailIcon/>
<input
className='form-control'
type='text'
value={this.state.emailField}
onChange={this.handleEmailChange}
disabled={this.state.error !== null || this.state.isSaving}
/>
</label>
<label>
<FormattedMessage
id='admin.userManagement.userDetail.username'
defaultMessage='Username'
/>
<AtIcon/>
<span>{this.state.user?.username}</span>
</label>
<label>
<FormattedMessage
id='admin.userManagement.userDetail.authenticationMethod'
defaultMessage='Authentication Method'
/>
<SheidOutlineIcon/>
<span>{getUserAuthenticationTextField(this.props.intl, this.props.mfaEnabled, this.state.user)}</span>
</label>
{this.renderTwoColumnLayout()}
{Boolean(this.state.user?.auth_data && this.state.user?.auth_service) && (
<label>
<label className='auth-data-field'>
<FormattedMessage
id='admin.userManagement.userDetail.authData'
defaultMessage='Auth Data'
@ -546,11 +927,27 @@ export class SystemUserDetail extends PureComponent<Props, State> {
{/* Footer */}
<div className='admin-console-save'>
<SaveButton
saving={this.state.isSaving}
disabled={!this.state.isSaveNeeded || this.state.isLoading || this.state.error !== null || this.state.isSaving}
onClick={this.handleSubmit}
/>
<div className='admin-console-save-buttons'>
<SaveButton
saving={this.state.isSaving}
disabled={!this.state.isSaveNeeded || this.state.isLoading || this.state.isSaving}
onClick={this.handleSubmit}
/>
{this.state.isSaveNeeded && (
<button
type='button'
className='btn btn-tertiary'
onClick={this.handleCancel}
disabled={this.state.isSaving}
style={{marginLeft: '12px'}}
>
<FormattedMessage
id='admin.user_item.cancel'
defaultMessage='Cancel'
/>
</button>
)}
</div>
<div className='error-message'>
<FormError error={this.state.error}/>
</div>
@ -644,7 +1041,10 @@ export function getUserAuthenticationTextField(intl: IntlShape, mfaEnabled: Prop
if (mfaEnabled) {
if (user.mfa_active) {
authenticationTextField += ', ';
authenticationTextField += intl.formatMessage({
id: 'admin.userManagement.userDetail.separator',
defaultMessage: ', ',
});
authenticationTextField += intl.formatMessage({id: 'admin.userManagement.userDetail.mfa', defaultMessage: 'MFA'});
}
}

View file

@ -3112,10 +3112,12 @@
"admin.user_grid.shared_member": "Shared Member",
"admin.user_grid.system_admin": "System Admin",
"admin.user_grid.team_admin": "Team Admin",
"admin.user_item.cancel": "Cancel",
"admin.user_item.deactivate": "Deactivate",
"admin.user_item.email_title": "<strong>Email:</strong> {email}",
"admin.user_item.guest": "Guest",
"admin.user_item.invalidEmail": "Invalid email address",
"admin.user_item.invalidUrl": "Invalid URL",
"admin.user_item.makeActive": "Activate",
"admin.user_item.makeMember": "Make Team Member",
"admin.user_item.makeTeamAdmin": "Make Team Admin",
@ -3132,6 +3134,7 @@
"admin.user_item.sysAdmin": "System Admin",
"admin.user_item.teamAdmin": "Team Admin",
"admin.user_item.teamMember": "Team Member",
"admin.user_item.unknownError": "Unknown error",
"admin.user_item.userActivateFailed": "Failed to activate user",
"admin.user_item.userDeactivateFailed": "Failed to deactivate user",
"admin.user_item.userMFARemoveFailed": "Failed to remove user's MFA",
@ -3139,12 +3142,21 @@
"admin.user_item.userUpdateFailed": "Failed to update user",
"admin.user_settings.policy_list.no_policies_found": "No policies found",
"admin.user_settings.policy_list.search_policy_errored": "Something went wrong. Try again",
"admin.user.selectOptions": "Select options...",
"admin.userManagement.userDetail.addTeam": "Add Team",
"admin.userManagement.userDetail.arrayValueSeparator": ", ",
"admin.userManagement.userDetail.authData": "Auth Data",
"admin.userManagement.userDetail.authenticationMethod": "Authentication Method",
"admin.userManagement.userDetail.cpaField": "{fieldName}",
"admin.userManagement.userDetail.email": "Email",
"admin.userManagement.userDetail.ldap": "AD/LDAP: {propertyName}",
"admin.userManagement.userDetail.mfa": "MFA",
"admin.userManagement.userDetail.noOptions": "No options available",
"admin.userManagement.userDetail.notFound": "User not found",
"admin.userManagement.userDetail.saml": "SAML: {propertyName}",
"admin.userManagement.userDetail.selectOption": "Select an option",
"admin.userManagement.userDetail.separator": ", ",
"admin.userManagement.userDetail.syncedWith": "Synced with: {source}",
"admin.userManagement.userDetail.teamsSubtitle": "Teams to which this user belongs",
"admin.userManagement.userDetail.teamsTitle": "Team Membership",
"admin.userManagement.userDetail.userId": "User ID: {userId}",

View file

@ -1716,7 +1716,7 @@ describe('Actions.Users', () => {
const state = store.getState();
const currentUser = state.entities.users.profiles[state.entities.users.currentUserId];
nock(Client4.getCustomProfileAttributeValuesRoute()).
nock(Client4.getUserRoute(currentUser.id) + '/custom_profile_attributes').
patch('').
query(true).
reply(200, {

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import type {AnyAction} from 'redux';
import {batchActions} from 'redux-batched-actions';
@ -1005,11 +1007,23 @@ export function saveCustomProfileAttribute(userID: string, attributeID: string,
return async (dispatch) => {
try {
const values = {[attributeID]: attributeValue || ''};
const data = await Client4.updateCustomProfileAttributeValues(values);
const data = await Client4.updateUserCustomProfileAttributesValues(userID, values);
return {data};
} catch (error) {
dispatch(logError(error));
return {error};
// Extract user-friendly error message from server response
let errorMessage = 'Failed to update custom profile attribute';
if (error && typeof error === 'object' && 'message' in error && error.message) {
errorMessage = error.message;
}
const serverError = {
...error,
message: errorMessage,
};
dispatch(logError(serverError));
return {error: serverError};
}
};
}

View file

@ -3,6 +3,8 @@
import shallowEqual from 'shallow-equals';
import type {FieldValueType} from '@mattermost/types/properties';
import {createSelectorCreator, defaultMemoize} from 'mattermost-redux/selectors/create_selector';
// eslint-disable-next-line @typescript-eslint/ban-types
@ -109,3 +111,8 @@ export function isEmail(email: string): boolean {
// this prevents <Outlook Style> outlook.style@domain.com addresses and multiple comma-separated addresses from being accepted
return (/^[^ ,@]+@[^ ,@]+$/).test(email);
}
// maps Custom Profile Attribute value types to appropriate HTML schemes (only different for phone -> tel)
export function getInputTypeFromValueType(valueType?: FieldValueType): string {
return valueType === 'phone' ? 'tel' : String(valueType);
}

View file

@ -2172,6 +2172,13 @@ export default class Client4 {
);
};
updateUserCustomProfileAttributesValues = (userID: string, attributeValues: Record<string, string | string[]>) => {
return this.doFetch<Record<string, string | string[]>>(
`${this.getUserRoute(userID)}/custom_profile_attributes`,
{method: 'PATCH', body: JSON.stringify(attributeValues)},
);
};
getUserCustomProfileAttributesValues = async (userID: string) => {
const data = await this.doFetch<Record<string, string>>(
`${this.getUserRoute(userID)}/custom_profile_attributes`,