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) {