diff --git a/api/v4/source/teams.yaml b/api/v4/source/teams.yaml index 165ebf194ec..cf5ab8266cd 100644 --- a/api/v4/source/teams.yaml +++ b/api/v4/source/teams.yaml @@ -1405,14 +1405,58 @@ required: true schema: type: string + - name: graceful + in: query + description: When provided with a non-empty value, returns an array with both successful invites and errors instead of aborting on the first error. Required when using `profiles`. + required: false + schema: + type: boolean requestBody: content: application/json: schema: - type: array - items: - type: string - description: List of user's email + oneOf: + - type: array + items: + type: string + - type: object + required: + - emails + properties: + emails: + type: array + items: + type: string + description: List of user's email + channelIds: + type: array + items: + type: string + description: List of channel IDs to invite the users to + message: + type: string + description: Custom message included in the invitation email + profiles: + type: array + description: | + Profile fields to pre-set on the accounts created from these invitations. Each entry must reference an email present in `emails`. Requires a non-empty `graceful` query parameter, an Enterprise license, and locked profile fields to be enabled. + + __Minimum server version__: 11.11 + items: + type: object + required: + - email + - username + properties: + email: + type: string + username: + type: string + first_name: + type: string + last_name: + type: string + description: List of user's email, or an object with emails and invitation options required: true responses: "200": diff --git a/docs/main/administration-guide/configure/site-configuration-settings.mdx b/docs/main/administration-guide/configure/site-configuration-settings.mdx index 597f13e5205..3150bd2bbd3 100644 --- a/docs/main/administration-guide/configure/site-configuration-settings.mdx +++ b/docs/main/administration-guide/configure/site-configuration-settings.mdx @@ -758,6 +758,23 @@ Access the following configuration settings in the System Console by going to ** +### Lock profile fields for email users + + + + ++++ + + + + + + +

This setting controls whether users who sign in with email and password can change their own profile fields under Settings > Profile. System admins are always exempt and can edit any user's profile through the System Console or the API. Users authenticating through an external provider (AD/LDAP, SAML, or OAuth) aren't affected; their fields remain governed by the provider's attribute settings.

  • Don't lock profile fields: (Default) Users can change all of their profile fields. config.json option: "none".
  • Lock name and username: Users cannot change their first name, last name, or username. config.json option: "name_and_username".
  • Lock entire profile: Additionally locks the nickname, position, and profile picture. Email stays editable because it's the sign-in credential, protected by password re-entry and verification. config.json option: "all".

Empty first and last names can be filled in once by the user, so people who join through a team invite link or open server signup aren't left without a name. Once set, the name is locked.

When this setting is enabled, anyone with the Invite Users permission can pre-set the first name, last name, and username for each email invitation they send. The invited person sees the pre-set username on the account creation page and cannot change it. We recommend restricting the Invite Users permission through advanced permissions to people trusted to enter this information correctly.

For a display name convention of "First Last" across the workspace, combine this setting with Teammate Name Display set to Show first and last name and Lock Teammate Name Display set to true.

  • System Config path: Site Configuration > Users and Teams
  • config.json setting: TeamSettings > LockProfileFieldsForEmailUsers > none
  • Environment variable: MM_TEAMSETTINGS_LOCKPROFILEFIELDSFOREMAILUSERS
+ ### Allow users to view archived channels diff --git a/docs/main/end-user-guide/collaborate/invite-people.mdx b/docs/main/end-user-guide/collaborate/invite-people.mdx index 20535090136..429d4e7bc16 100644 --- a/docs/main/end-user-guide/collaborate/invite-people.mdx +++ b/docs/main/end-user-guide/collaborate/invite-people.mdx @@ -53,6 +53,7 @@ Anyone can invite people to Mattermost teams and channels, unless your system ad - An invite link can be used by anyone and doesn’t change unless it’s re-generated or revoked by a system admin or team admin via **Team Settings \> Access \> Invite Code**. - Your system admin must [enable email invitations](/administration-guide/configure/authentication-configuration-settings#enable-email-invitations) and configure [email](/administration-guide/configure/environment-configuration-settings#smtp) for Mattermost to send email-based invitations. - Invitation links sent by email expire after 48 hours and can only be used once. +- When profile fields are managed by your system admin, email invitations may include fields to preset the invitee's name and username. These values are applied during signup and become admin-managed. - Your system admin can [cancel all email invitations](/administration-guide/configure/authentication-configuration-settings#invalidate-pending-email-invites) that haven't yet been accepted within the System Console. diff --git a/docs/main/end-user-guide/preferences/manage-your-profile.mdx b/docs/main/end-user-guide/preferences/manage-your-profile.mdx index bbd9876723b..608f884b526 100644 --- a/docs/main/end-user-guide/preferences/manage-your-profile.mdx +++ b/docs/main/end-user-guide/preferences/manage-your-profile.mdx @@ -7,6 +7,8 @@ Select your profile picture and select **Profile** to manage the details of your Your Mattermost system admin may [define custom user profile fields](/administration-guide/manage/admin/user-attributes) that you can personalize. Additionally, some of your profile information may be pulled from another source, which means you won't be able to modify it in Mattermost. Contact your Mattermost system admin for assistance. +Your system admin may also manage profile fields for accounts that sign in with email and password. Empty first and last names can each be entered once and become locked after you save them. Other managed fields remain locked, while your email address remains editable. +
diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_invitation_ui_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_invitation_ui_spec.ts index 0797fa34f35..0719fa9b6cf 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_invitation_ui_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_invitation_ui_spec.ts @@ -151,7 +151,7 @@ describe('Guest Account - Guest User Invitation Flow', () => { }); // # Close the Modal - cy.get('#closeIcon').should('be.visible').click(); + cy.findByTestId('invitationModal').findByRole('button', {name: 'Close'}).should('be.visible').click(); // # Enable Guest Accounts // # Disable Email Invitations diff --git a/e2e-tests/playwright/lib/README.md b/e2e-tests/playwright/lib/README.md index 8cd999cb038..61056aa2eba 100644 --- a/e2e-tests/playwright/lib/README.md +++ b/e2e-tests/playwright/lib/README.md @@ -102,6 +102,7 @@ All environment variables are optional with sensible defaults. | `PW_ADMIN_EMAIL` | Admin email | `sysadmin@sample.mattermost.com` | | `PW_ENSURE_PLUGINS_INSTALLED` | Comma-separated list of plugins to install | `[]` | | `PW_RESET_BEFORE_TEST` | Reset server before test | `false` | +| `PW_SMTP_URL` | Inbucket HTTP API URL | `http://localhost:9001` | #### High Availability Cluster Settings diff --git a/e2e-tests/playwright/lib/src/index.ts b/e2e-tests/playwright/lib/src/index.ts index 7a1e1999547..6f340157616 100644 --- a/e2e-tests/playwright/lib/src/index.ts +++ b/e2e-tests/playwright/lib/src/index.ts @@ -16,10 +16,13 @@ export { getAdminClient, mergeWithOnPremServerConfig, getOnPremServerConfig, + getRecentEmail, + extractEmailLink, isWebhookTestServerReachable, setupWebhookTestServer, PlaywrightClient4, } from './server'; +export type {InbucketEmail} from './server'; export { ChannelsPage, diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index 0b857439822..897984134be 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -247,6 +247,7 @@ const defaultServerConfig: AdminConfig = { TeammateNameDisplay: 'username', ExperimentalEnableAutomaticReplies: false, LockTeammateNameDisplay: false, + LockProfileFieldsForEmailUsers: 'none', ExperimentalPrimaryTeam: '', ExperimentalDefaultChannels: [], }, diff --git a/e2e-tests/playwright/lib/src/server/email.ts b/e2e-tests/playwright/lib/src/server/email.ts new file mode 100644 index 00000000000..3491a3780d8 --- /dev/null +++ b/e2e-tests/playwright/lib/src/server/email.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {testConfig} from '@/test_config'; + +export type InbucketEmail = { + id: string; + to: string[]; + date: string; + subject: string; + body: { + text: string; + html: string; + }; +}; + +type InbucketEmailSummary = Pick; + +type GetRecentEmailOptions = { + receivedAfter?: Date; + timeout?: number; +}; + +const DEFAULT_EMAIL_TIMEOUT = 30_000; +const EMAIL_POLL_INTERVAL = 500; + +/** + * Returns the newest email addressed to the exact recipient, waiting for Inbucket + * when mail delivery is still in progress. + */ +export async function getRecentEmail( + recipient: string, + {receivedAfter, timeout = DEFAULT_EMAIL_TIMEOUT}: GetRecentEmailOptions = {}, +): Promise { + const mailbox = recipient.split('@')[0]; + const mailboxURL = `${testConfig.smtpURL}/api/v1/mailbox/${encodeURIComponent(mailbox)}`; + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + const email = await getNewestMatchingEmail(mailboxURL, recipient, receivedAfter); + if (email) { + return email; + } + await new Promise((resolve) => setTimeout(resolve, EMAIL_POLL_INTERVAL)); + } + + throw new Error(`Timed out waiting for email to ${recipient}`); +} + +export function extractEmailLink(email: InbucketEmail, pathname: string): string { + const escapedPathname = pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const link = email.body.text.match(new RegExp(`https?://[^\\s<>"')]+${escapedPathname}[^\\s<>"')]+`))?.[0]; + + if (!link) { + throw new Error(`Email to ${email.to.join(', ')} does not contain a link for ${pathname}`); + } + + return link.replaceAll('&', '&'); +} + +/** + * Extracts the bare email address from a mailbox `to` entry, which Inbucket + * reports as `addr@host`, ``, or `Display Name `. + */ +function parseEmailAddress(entry: string): string { + const angleBracketMatch = entry.match(/<([^<>]+)>\s*$/); + return (angleBracketMatch ? angleBracketMatch[1] : entry).trim().toLowerCase(); +} + +async function getNewestMatchingEmail( + mailboxURL: string, + recipient: string, + receivedAfter?: Date, +): Promise { + const response = await fetch(mailboxURL); + if (!response.ok) { + return undefined; + } + + const summaries = (await response.json()) as InbucketEmailSummary[]; + const normalizedRecipient = recipient.toLowerCase(); + const receivedAfterTime = receivedAfter?.getTime(); + const matchingSummaries = summaries.filter((summary) => { + const addressedToRecipient = summary.to.some((address) => parseEmailAddress(address) === normalizedRecipient); + const arrivedInTime = receivedAfterTime === undefined || new Date(summary.date).getTime() >= receivedAfterTime; + + return addressedToRecipient && arrivedInTime; + }); + + for (const summary of matchingSummaries.reverse()) { + const messageResponse = await fetch(`${mailboxURL}/${encodeURIComponent(summary.id)}`); + if (!messageResponse.ok) { + continue; + } + + return (await messageResponse.json()) as InbucketEmail; + } + + return undefined; +} diff --git a/e2e-tests/playwright/lib/src/server/index.ts b/e2e-tests/playwright/lib/src/server/index.ts index 42cba631735..b025c2127cd 100644 --- a/e2e-tests/playwright/lib/src/server/index.ts +++ b/e2e-tests/playwright/lib/src/server/index.ts @@ -9,6 +9,8 @@ export {initSetup, getAdminClient} from './init'; export {createRandomPost} from './post'; export {createNewTeam, createRandomTeam} from './team'; export {createNewUserProfile, createRandomUser, getDefaultAdminUser, isOutsideRemoteUserHour} from './user'; +export {extractEmailLink, getRecentEmail} from './email'; +export type {InbucketEmail} from './email'; export { enableAIBridgeTestMode, configureAIBridgeMock, diff --git a/e2e-tests/playwright/lib/src/test_config.ts b/e2e-tests/playwright/lib/src/test_config.ts index 1b16138abe0..210301d95e3 100644 --- a/e2e-tests/playwright/lib/src/test_config.ts +++ b/e2e-tests/playwright/lib/src/test_config.ts @@ -23,6 +23,7 @@ export class TestConfig { workers: number; snapshotEnabled: boolean; percyEnabled: boolean; + smtpURL: string; /** Base URL of the Cypress/Playwright webhook sidecar (`e2e-tests/cypress`: `npm run start:webhook`). */ webhookBaseUrl: string; @@ -51,6 +52,8 @@ export class TestConfig { // Visual tests this.snapshotEnabled = parseBool(process.env.PW_SNAPSHOT_ENABLE, false); this.percyEnabled = parseBool(process.env.PW_PERCY_ENABLE, false); + // Email + this.smtpURL = process.env.PW_SMTP_URL || 'http://localhost:9001'; this.webhookBaseUrl = process.env.PW_WEBHOOK_BASE_URL || 'http://localhost:3000'; } } diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/invite_people_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/invite_people_modal.ts index 1e59da687af..69f03e2d595 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/invite_people_modal.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/invite_people_modal.ts @@ -29,22 +29,35 @@ export default class InvitePeopleModal { await this.closeButton.click(); } - /** - * Types an email or username into the react-select invite input, - * waits for a selectable option to load, selects it, then clicks the invite button. - */ - async inviteByEmail(email: string) { + async addEmail(email: string) { await expect(this.inviteInput).toBeVisible(); await this.inviteInput.click(); await this.inviteInput.pressSequentially(email, {delay: 50}); - // Wait for react-select to finish loading and show a selectable option. - // Use a longer timeout (15 s) to tolerate slow email-validation responses in CI. const listbox = this.container.getByRole('listbox'); await expect(listbox.getByRole('option').first()).toBeVisible({timeout: 15000}); await this.inviteInput.press('Enter'); + await expect(this.inviteInput).toHaveValue(''); + } + + async submitInvites() { await expect(this.inviteButton).toBeEnabled(); await this.inviteButton.click(); } + + async inviteByEmail(email: string) { + await this.addEmail(email); + await this.submitInvites(); + } + + getProfileRow(email: string) { + const row = this.container.getByTestId(`MemberProfileInputs__row-${email.toLowerCase()}`); + return { + container: row, + firstNameInput: row.getByRole('textbox', {name: 'First name'}), + lastNameInput: row.getByRole('textbox', {name: 'Last name'}), + usernameInput: row.getByRole('textbox', {name: 'Username'}), + }; + } } diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/profile_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/profile_modal.ts index d3d92586d41..c0d2c1256b0 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/profile_modal.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/profile_modal.ts @@ -4,6 +4,8 @@ import type {Locator} from '@playwright/test'; import {expect} from '@playwright/test'; +export type ProfileSection = 'name' | 'username'; + export default class ProfileModal { readonly container: Locator; @@ -16,6 +18,11 @@ export default class ProfileModal { readonly closeButton; readonly saveButton; readonly cancelButton; + readonly managedByAdminMessage; + + readonly firstNameInput; + readonly lastNameInput; + readonly usernameInput; readonly sectionHeadings; constructor(container: Locator) { @@ -30,6 +37,13 @@ export default class ProfileModal { this.closeButton = container.getByRole('button', {name: 'Close'}); this.saveButton = container.getByRole('button', {name: 'Save'}); this.cancelButton = container.getByRole('button', {name: 'Cancel'}); + this.managedByAdminMessage = container.getByText( + 'This field is managed by your System Admin. Contact them to request a change.', + ); + + this.firstNameInput = container.getByRole('textbox', {name: 'First Name'}); + this.lastNameInput = container.getByRole('textbox', {name: 'Last Name'}); + this.usernameInput = container.getByRole('textbox', {name: 'Username'}); this.sectionHeadings = this.profileSettingsTab.container.getByTestId('section-min').getByRole('heading'); } @@ -60,6 +74,21 @@ export default class ProfileModal { await expect(this.container).not.toBeVisible(); } + getSectionEditButton(section: ProfileSection) { + return this.container.locator(`#${section}Edit`); + } + + async openSection(section: ProfileSection) { + const editButton = this.getSectionEditButton(section); + await expect(editButton).toBeVisible(); + await editButton.click(); + } + + async closeSection() { + await expect(this.cancelButton).toBeVisible(); + await this.cancelButton.click(); + } + getAttributeSection(label: string) { return this.profileSettingsTab.container.getByTestId('section-min').filter({hasText: label}); } diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/user_detail.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/user_detail.ts index 7c0c57f95ef..d33bcca4453 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/user_detail.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/user_detail.ts @@ -124,6 +124,8 @@ class AdminUserCard { // System field inputs (scoped via wrapping +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
{ await userEventInstance.type(usernameInput, 'newusername'); expect(defaultProps.setNavigationBlocked).toHaveBeenCalledWith(true); }); + + test.each([ + ['first name', 'Enter first name'], + ['last name', 'Enter last name'], + ])('should detect %s changes and enable save', async (_fieldName, placeholder) => { + const userEventInstance = userEvent.setup(); + const setNavigationBlocked = jest.fn(); + renderWithContext( + , + ); + + await waitForElementToBeRemoved(() => screen.queryAllByTestId('loadingSpinner')); + + const input = screen.getByPlaceholderText(placeholder); + await userEventInstance.clear(input); + await userEventInstance.type(input, 'New Name'); + + expect(screen.getByRole('button', {name: 'Save'})).toBeEnabled(); + expect(setNavigationBlocked).toHaveBeenCalledWith(true); + }); + }); + + describe('name editing', () => { + const nameUser = { + ...user, + first_name: 'Old First', + last_name: 'Old Last', + }; + + test('should show name changes, trim values, and patch the user on save', async () => { + const userEventInstance = userEvent.setup(); + const getNameUser = jest.fn().mockResolvedValue({data: nameUser, error: null}); + const patchUser = jest.fn().mockImplementation((updatedUser: UserProfile) => Promise.resolve({data: updatedUser, error: null})); + renderWithContext( + , + ); + + await waitForElementToBeRemoved(() => screen.queryAllByTestId('loadingSpinner')); + + const firstNameInput = screen.getByPlaceholderText('Enter first name'); + const lastNameInput = screen.getByPlaceholderText('Enter last name'); + await userEventInstance.clear(firstNameInput); + await userEventInstance.type(firstNameInput, ' New First '); + await userEventInstance.clear(lastNameInput); + await userEventInstance.type(lastNameInput, ' New Last '); + await userEventInstance.click(screen.getByRole('button', {name: 'Save'})); + + const changesList = await screen.findByTestId('changesList'); + expect(changesList).toHaveTextContent('First Name: Old First → New First'); + expect(changesList).toHaveTextContent('Last Name: Old Last → New Last'); + + await userEventInstance.click(screen.getByRole('button', {name: 'Save Changes'})); + + await waitFor(() => { + expect(patchUser).toHaveBeenCalledWith(expect.objectContaining({ + first_name: 'New First', + last_name: 'New Last', + })); + }); + }); + + test('should translate empty values in the name change summary', async () => { + const userEventInstance = userEvent.setup(); + const getNameUser = jest.fn().mockResolvedValue({data: nameUser, error: null}); + renderWithContext( + , + {}, + { + intlMessages: { + 'admin.userDetail.saveChangesModal.empty': '(translated empty)', + }, + }, + ); + + await waitForElementToBeRemoved(() => screen.queryAllByTestId('loadingSpinner')); + + await userEventInstance.clear(screen.getByPlaceholderText('Enter first name')); + await userEventInstance.click(screen.getByRole('button', {name: 'Save'})); + + expect(await screen.findByTestId('changesList')).toHaveTextContent('First Name: Old First → (translated empty)'); + }); + + test('should reset first and last names on cancel', async () => { + const userEventInstance = userEvent.setup(); + const getNameUser = jest.fn().mockResolvedValue({data: nameUser, error: null}); + renderWithContext( + , + ); + + await waitForElementToBeRemoved(() => screen.queryAllByTestId('loadingSpinner')); + + const firstNameInput = screen.getByPlaceholderText('Enter first name'); + const lastNameInput = screen.getByPlaceholderText('Enter last name'); + await userEventInstance.clear(firstNameInput); + await userEventInstance.type(firstNameInput, 'New First'); + await userEventInstance.clear(lastNameInput); + await userEventInstance.type(lastNameInput, 'New Last'); + await userEventInstance.click(screen.getByRole('button', {name: 'Cancel'})); + + expect(firstNameInput).toHaveValue('Old First'); + expect(lastNameInput).toHaveValue('Old Last'); + expect(screen.getByRole('button', {name: 'Save'})).toBeDisabled(); + }); }); describe('email validation', () => { diff --git a/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.tsx b/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.tsx index 4a09a9a3cf8..26278ad904a 100644 --- a/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.tsx +++ b/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.tsx @@ -301,6 +301,8 @@ export type State = { user?: UserProfile; usernameField: string; usernameError: string | null; + firstNameField: string; + lastNameField: string; emailField: string; emailError: string | null; authDataField: string; @@ -329,6 +331,8 @@ export class SystemUserDetail extends PureComponent { this.state = { usernameField: '', usernameError: null, + firstNameField: '', + lastNameField: '', emailField: '', emailError: null, authDataField: '', @@ -367,6 +371,8 @@ export class SystemUserDetail extends PureComponent { user: userResult.data, emailField: userResult.data.email, // Set emailField to the email of the user for editing purposes usernameField: userResult.data.username, + firstNameField: userResult.data.first_name, + lastNameField: userResult.data.last_name, authDataField: userResult.data.auth_data || '', customProfileAttributeValues: cpaValues, originalCpaValues: {...cpaValues}, // Deep copy for change tracking @@ -440,10 +446,18 @@ export class SystemUserDetail extends PureComponent { const emailChanged = state.emailField !== state.user.email; const usernameChanged = state.usernameField !== state.user.username; + const nameChanged = this.hasNameChanges(state); const authDataChanged = state.authDataField !== (state.user.auth_data || ''); const cpaChanged = this.hasCpaChanges(state); - return emailChanged || usernameChanged || authDataChanged || cpaChanged; + return emailChanged || usernameChanged || nameChanged || authDataChanged || cpaChanged; + }; + + private hasNameChanges = (state: State = this.state): boolean => { + if (!state.user) { + return false; + } + return state.firstNameField !== state.user.first_name || state.lastNameField !== state.user.last_name; }; private hasCpaChanges = (state: State = this.state): boolean => { @@ -480,10 +494,17 @@ export class SystemUserDetail extends PureComponent { return currentValue !== originalValue; }; + private formatEmptyValue = (): string => { + return this.props.intl.formatMessage({ + id: 'admin.userDetail.saveChangesModal.empty', + defaultMessage: '(empty)', + }); + }; + // Resolves option IDs to display names for select/multiselect/rank CPA fields. private resolveOptionNames = (field: UserPropertyField, value: string | string[] | undefined): string => { if (!value) { - return '(empty)'; + return this.formatEmptyValue(); } const options = field.attrs?.options || []; @@ -496,7 +517,7 @@ export class SystemUserDetail extends PureComponent { // Multiselect: resolve each ID to its name if (value.length === 0) { - return '(empty)'; + return this.formatEmptyValue(); } const names = value.map((id) => { @@ -673,6 +694,28 @@ export class SystemUserDetail extends PureComponent { }); }; + handleFirstNameChange = (event: ChangeEvent) => { + if (!this.state.user) { + return; + } + + this.setState({ + firstNameField: event.target.value, + error: null, // Clear any errors when user starts editing + }); + }; + + handleLastNameChange = (event: ChangeEvent) => { + if (!this.state.user) { + return; + } + + this.setState({ + lastNameField: event.target.value, + error: null, // Clear any errors when user starts editing + }); + }; + handleAuthDataChange = (event: ChangeEvent) => { if (!this.state.user) { return; @@ -962,6 +1005,73 @@ export class SystemUserDetail extends PureComponent { , ); + const nameField = (fieldKey: 'firstNameField' | 'lastNameField', label: React.ReactNode, onChange: (event: ChangeEvent) => void, placeholder: string, maxLength: number) => ( + + ); + + fields.push(nameField( + 'firstNameField', + , + this.handleFirstNameChange, + this.props.intl.formatMessage({ + id: 'admin.userManagement.userDetail.firstName.input', + defaultMessage: 'Enter first name', + }), + Constants.MAX_FIRSTNAME_LENGTH, + )); + + fields.push(nameField( + 'lastNameField', + , + this.handleLastNameChange, + this.props.intl.formatMessage({ + id: 'admin.userManagement.userDetail.lastName.input', + defaultMessage: 'Enter last name', + }), + Constants.MAX_LASTNAME_LENGTH, + )); + fields.push(