diff --git a/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.test.tsx b/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.test.tsx index c32230de99..77f10dbc21 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.test.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.test.tsx @@ -105,6 +105,9 @@ describe('components/ProfilePopoverCustomAttributes', () => { url_attribute_id: urlAttribute, select_attribute_id: selectAttribute, }, + license: { + Cloud: 'false', + }, }, users: { profiles: { @@ -137,6 +140,12 @@ describe('components/ProfilePopoverCustomAttributes', () => { expect(screen.getByText('text value')).toBeInTheDocument(); expect(screen.getByText('+1 (555) 123-4567')).toBeInTheDocument(); expect(screen.getByText('https://example.com')).toBeInTheDocument(); + + // URL attribute should be rendered as a link + const urlLink = screen.getByRole('link', {name: 'https://example.com'}); + expect(urlLink).toBeInTheDocument(); + expect(urlLink).toHaveAttribute('href', 'https://example.com'); + expect(screen.getByText('Option 1')).toBeInTheDocument(); }); diff --git a/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx b/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx index e27c38f442..599328340f 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover_custom_attributes.tsx @@ -47,6 +47,25 @@ const ProfilePopoverCustomAttributes = ({ if (!hasValue && visibility === 'when_set') { return null; + } else if (visibility === 'when_set' && (attribute.type === 'multiselect' || attribute.type === 'select')) { + const attributeValue = userProfile.custom_profile_attributes[attribute.id]; + + // make sure attribute contains legitimate values + if (Array.isArray(attributeValue)) { + // Handle multiselect + const options = attributeValue.map((value) => { + return attribute.attrs.options?.find((o) => o.id === value); + }).filter((o) => o != null); + if (options.length === 0) { + return null; + } + } else { + // Handle single select + const option = attribute.attrs.options?.find((o) => o.id === attributeValue); + if (option === undefined) { + return null; + } + } } const valueType = (attribute.attrs?.value_type as UserPropertyValueType) || ''; diff --git a/webapp/channels/src/components/profile_popover/profile_popover_url.test.tsx b/webapp/channels/src/components/profile_popover/profile_popover_url.test.tsx index cf1fa315ff..0eab2c623d 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover_url.test.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover_url.test.tsx @@ -91,4 +91,14 @@ describe('components/ProfilePopoverUrl', () => { expect(container).toBeInTheDocument(); expect(screen.getByRole('link')).toHaveTextContent(url); }); + + test('should render url with ExternalLink component', () => { + renderWithContext(); + + const url = 'https://example.com'; + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', url); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); }); diff --git a/webapp/channels/src/components/profile_popover/profile_popover_url.tsx b/webapp/channels/src/components/profile_popover/profile_popover_url.tsx index 98690c222f..a806d74905 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover_url.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover_url.tsx @@ -6,6 +6,8 @@ import React from 'react'; import type {UserPropertyField} from '@mattermost/types/properties'; import type {UserProfile} from '@mattermost/types/users'; +import ExternalLink from 'components/external_link'; + type Props = { attribute: UserPropertyField; userProfile: UserProfile; @@ -28,11 +30,12 @@ const ProfilePopoverUrl = ({attribute, userProfile}: Props) => { aria-hidden='true' data-testid='url-icon' /> - {url} - + ); }; diff --git a/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx b/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx index 19d5b13033..fe1dec9bf0 100644 --- a/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx +++ b/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx @@ -506,6 +506,174 @@ describe('components/user_settings/general/UserSettingsGeneral', () => { expect(props.actions.saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', ''); }); + test('should handle select with removed options', async () => { + const saveCustomProfileAttribute = jest.fn().mockResolvedValue({}); + const selectAttribute: UserPropertyField = { + ...customProfileAttribute, + type: 'select', + attrs: { + value_type: '', + visibility: 'when_set', + sort_order: 0, + options: [ + {id: 'opt1', name: 'Option 1', color: ''}, + + // opt2 has been removed from options + ], + }, + }; + + // User has a value for an option that no longer exists + const testUser = {...user, custom_profile_attributes: {field1: 'opt2'}}; + const props = { + ...requiredProps, + enableCustomProfileAttributes: true, + customProfileAttributeFields: [selectAttribute], + user: testUser, + activeSection: '', + actions: { + ...requiredProps.actions, + saveCustomProfileAttribute, + }, + }; + + renderWithContext(); + + // Should not display any value since the option no longer exists + expect(screen.queryByText('Option 2')).not.toBeInTheDocument(); + expect(await screen.findByText('Click \'Edit\' to add your custom attribute')).toBeInTheDocument(); + }); + + test('should handle multiselect with removed options', async () => { + const saveCustomProfileAttribute = jest.fn().mockResolvedValue({}); + const multiselectAttribute: UserPropertyField = { + ...customProfileAttribute, + type: 'multiselect', + attrs: { + value_type: '', + visibility: 'when_set', + sort_order: 0, + options: [ + {id: 'opt1', name: 'Option 1', color: ''}, + + // opt2 and opt3 have been removed from options + ], + }, + }; + + // User has values for options that no longer exist + const testUser = {...user, custom_profile_attributes: {field1: ['opt1', 'opt2', 'opt3']}}; + const props = { + ...requiredProps, + enableCustomProfileAttributes: true, + customProfileAttributeFields: [multiselectAttribute], + user: testUser, + activeSection: '', + actions: { + ...requiredProps.actions, + saveCustomProfileAttribute, + }, + }; + + renderWithContext(); + + // Should only display the option that still exists + expect(await screen.findByText('Option 1')).toBeInTheDocument(); + expect(screen.queryByText('Option 2')).not.toBeInTheDocument(); + expect(screen.queryByText('Option 3')).not.toBeInTheDocument(); + }); + + test('should handle editing select with removed options', async () => { + const saveCustomProfileAttribute = jest.fn().mockResolvedValue({}); + const selectAttribute: UserPropertyField = { + ...customProfileAttribute, + type: 'select', + attrs: { + value_type: '', + visibility: 'when_set', + sort_order: 0, + options: [ + {id: 'opt1', name: 'Option 1', color: ''}, + + // opt2 has been removed from options + ], + }, + }; + + // User has a value for an option that no longer exists + const testUser = {...user, custom_profile_attributes: {field1: 'opt2'}}; + const props = { + ...requiredProps, + enableCustomProfileAttributes: true, + customProfileAttributeFields: [selectAttribute], + user: testUser, + activeSection: 'customAttribute_field1', + actions: { + ...requiredProps.actions, + saveCustomProfileAttribute, + }, + }; + + renderWithContext(); + + // Should show empty select since the option no longer exists + expect(await screen.findByText('Select')).toBeInTheDocument(); + + // Select a valid option and save + userEvent.click(screen.getByText('Select')); + userEvent.click(await screen.findByText('Option 1')); + userEvent.click(screen.getByRole('button', {name: 'Save'})); + + expect(saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', 'opt1'); + }); + + test('should handle editing multiselect with removed options', async () => { + const saveCustomProfileAttribute = jest.fn().mockResolvedValue({}); + const multiselectAttribute: UserPropertyField = { + ...customProfileAttribute, + type: 'multiselect', + attrs: { + value_type: '', + visibility: 'when_set', + sort_order: 0, + options: [ + {id: 'opt1', name: 'Option 1', color: ''}, + {id: 'opt3', name: 'Option 3', color: ''}, + + // opt2 has been removed from options + ], + }, + }; + + // User has values including one for an option that no longer exists + const testUser = {...user, custom_profile_attributes: {field1: ['opt1', 'opt2']}}; + const props = { + ...requiredProps, + enableCustomProfileAttributes: true, + customProfileAttributeFields: [multiselectAttribute], + user: testUser, + activeSection: 'customAttribute_field1', + actions: { + ...requiredProps.actions, + saveCustomProfileAttribute, + }, + }; + + renderWithContext(); + + // Should only show the valid option + expect(await screen.findByText('Option 1')).toBeInTheDocument(); + expect(screen.queryByText('Option 2')).not.toBeInTheDocument(); + + // Add another valid option and save + userEvent.click(await screen.findByText('Option 1')); + userEvent.click(await screen.findByText('Option 3')); + userEvent.click(screen.getByRole('button', {name: 'Save'})); + + // Should save with only the valid options + expect(saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', ['opt1', 'opt3']); + }); + test('should not show custom attribute input field when LDAP attribute is set', async () => { const props = { ...requiredProps, @@ -573,4 +741,78 @@ describe('components/user_settings/general/UserSettingsGeneral', () => { expect(await screen.getByRole('button', {name: 'Save'})).toBeInTheDocument(); expect(screen.queryByRole('textbox', {name: customProfileAttribute.name})).toBeInTheDocument(); }); + + test('should validate URL custom attribute field value', async () => { + const urlAttribute: UserPropertyField = { + ...customProfileAttribute, + attrs: { + ...customProfileAttribute.attrs, + value_type: 'url', + }, + }; + + const saveCustomProfileAttribute = jest.fn().mockResolvedValue({}); + const props = { + ...requiredProps, + enableCustomProfileAttributes: true, + customProfileAttributeFields: [urlAttribute], + user: {...user}, + activeSection: 'customAttribute_field1', + actions: { + ...requiredProps.actions, + saveCustomProfileAttribute, + }, + }; + + renderWithContext(); + + userEvent.type(screen.getByRole('textbox', {name: urlAttribute.name}), 'ftp://invalid-scheme'); + userEvent.click(screen.getByRole('button', {name: 'Save'})); + + expect(await screen.findByText('Please enter a valid url.')).toBeInTheDocument(); + expect(saveCustomProfileAttribute).not.toHaveBeenCalled(); + + userEvent.clear(screen.getByRole('textbox', {name: urlAttribute.name})); + userEvent.type(screen.getByRole('textbox', {name: urlAttribute.name}), 'example.com'); + userEvent.click(screen.getByRole('button', {name: 'Save'})); + + expect(saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', 'http://example.com'); + }); + + test('should validate email custom attribute field value', async () => { + const emailAttribute: UserPropertyField = { + ...customProfileAttribute, + attrs: { + ...customProfileAttribute.attrs, + value_type: 'email', + }, + }; + + const saveCustomProfileAttribute = jest.fn().mockResolvedValue({}); + const props = { + ...requiredProps, + enableCustomProfileAttributes: true, + customProfileAttributeFields: [emailAttribute], + user: {...user}, + activeSection: 'customAttribute_field1', + actions: { + ...requiredProps.actions, + saveCustomProfileAttribute, + }, + }; + + renderWithContext(); + + userEvent.type(screen.getByRole('textbox', {name: emailAttribute.name}), 'invalid-email'); + userEvent.click(screen.getByRole('button', {name: 'Save'})); + + expect(await screen.findByText('Please enter a valid email address.')).toBeInTheDocument(); + expect(saveCustomProfileAttribute).not.toHaveBeenCalled(); + + userEvent.clear(screen.getByRole('textbox', {name: emailAttribute.name})); + userEvent.type(screen.getByRole('textbox', {name: emailAttribute.name}), 'test@example.com'); + userEvent.click(screen.getByRole('button', {name: 'Save'})); + + expect(saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', 'test@example.com'); + }); }); diff --git a/webapp/channels/src/components/user_settings/general/user_settings_general.tsx b/webapp/channels/src/components/user_settings/general/user_settings_general.tsx index 857fc30841..b183f11433 100644 --- a/webapp/channels/src/components/user_settings/general/user_settings_general.tsx +++ b/webapp/channels/src/components/user_settings/general/user_settings_general.tsx @@ -459,9 +459,17 @@ export class UserSettingsGeneralTab extends PureComponent { } } if (attributeField.attrs.value_type === 'url') { - if (attributeValue !== '' && !validHttpUrl(attributeValue)) { - this.setState({clientError: formatMessage(holders.validUrl), emailError: '', serverError: ''}); - return; + if (attributeValue !== '') { + const validURL = validHttpUrl(attributeValue); + if (!validURL) { + this.setState({clientError: formatMessage(holders.validUrl), emailError: '', serverError: ''}); + return; + } + let validLink = validURL.toString(); + if (validLink.endsWith('/')) { + validLink = validLink.slice(0, -1); + } + attributeValue = validLink; } } } @@ -1419,13 +1427,19 @@ export class UserSettingsGeneralTab extends PureComponent { if (Array.isArray(attributeValue)) { return attributeValue.map((value) => { const option = attribOptions.find((o) => o.id === value); - return {label: option?.name, value: option?.id}; - }); + if (option) { + return {label: option?.name, value: option?.id}; + } + return null; + }).filter((value) => value != null); } // Handle single select const option = attribOptions.find((o) => o.id === attributeValue); - return {label: option?.name, value: option?.id}; + if (option) { + return {label: option?.name, value: option?.id}; + } + return ''; } return attributeValue as string; @@ -1535,12 +1549,14 @@ export class UserSettingsGeneralTab extends PureComponent { let describe: JSX.Element|string = ''; if (this.props.user.custom_profile_attributes?.[attribute.id]) { const attributeValue = getDisplayValue(this.props.user.custom_profile_attributes?.[attribute.id]); - if (typeof attributeValue === 'string') { - describe = attributeValue; - } else if (Array.isArray(attributeValue) && attributeValue.length > 0) { - describe = attrib.label)}/>; - } else if (!Array.isArray(attributeValue) && Object.hasOwn(attributeValue, 'label')) { - describe = attributeValue.label || ''; + if (attributeValue) { + if (typeof attributeValue === 'string') { + describe = attributeValue; + } else if (Array.isArray(attributeValue) && attributeValue.length > 0) { + describe = attrib?.label || null)}/>; + } else if (!Array.isArray(attributeValue) && Object.hasOwn(attributeValue, 'label')) { + describe = attributeValue.label || ''; + } } } if (!describe) {