diff --git a/x-pack/plugins/cloud/public/user_menu_links.ts b/x-pack/plugins/cloud/public/user_menu_links.ts index 52de4c68e512d..e662d51500333 100644 --- a/x-pack/plugins/cloud/public/user_menu_links.ts +++ b/x-pack/plugins/cloud/public/user_menu_links.ts @@ -16,11 +16,12 @@ export const createUserMenuLinks = (config: CloudConfigType): UserMenuLink[] => if (resetPasswordUrl) { userMenuLinks.push({ label: i18n.translate('xpack.cloud.userMenuLinks.profileLinkText', { - defaultMessage: 'Cloud profile', + defaultMessage: 'Profile', }), - iconType: 'logoCloud', + iconType: 'user', href: resetPasswordUrl, order: 100, + setAsProfile: true, }); } diff --git a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx index d45ba91bb35fb..4e9f1a6692eb9 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx @@ -62,7 +62,7 @@ describe('<AccountManagementPage>', () => { }); expect(wrapper.find('EuiText[data-test-subj="userDisplayName"]').text()).toEqual( - user.full_name + `Settings for ${user.full_name}` ); expect(wrapper.find('[data-test-subj="username"]').text()).toEqual(user.username); expect(wrapper.find('[data-test-subj="email"]').text()).toEqual(user.email); @@ -83,7 +83,9 @@ describe('<AccountManagementPage>', () => { wrapper.update(); }); - expect(wrapper.find('EuiText[data-test-subj="userDisplayName"]').text()).toEqual(user.username); + expect(wrapper.find('EuiText[data-test-subj="userDisplayName"]').text()).toEqual( + `Settings for ${user.username}` + ); }); it(`displays a placeholder when no email address is provided`, async () => { diff --git a/x-pack/plugins/security/public/account_management/account_management_page.tsx b/x-pack/plugins/security/public/account_management/account_management_page.tsx index c8fe80e254a46..60f48c01a6ff7 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.tsx @@ -9,6 +9,7 @@ import { EuiPage, EuiPageBody, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui import React, { useEffect, useState } from 'react'; import ReactDOM from 'react-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; import type { PublicMethodsOf } from '@kbn/utility-types'; import type { CoreStart, NotificationsStart } from 'src/core/public'; @@ -40,7 +41,13 @@ export const AccountManagementPage = ({ userAPIClient, authc, notifications }: P <EuiPageBody restrictWidth> <EuiPanel> <EuiText data-test-subj={'userDisplayName'}> - <h1>{getUserDisplayName(currentUser)}</h1> + <h1> + <FormattedMessage + id="xpack.security.account.pageTitle" + defaultMessage="Settings for {strongUsername}" + values={{ strongUsername: <strong>{getUserDisplayName(currentUser)}</strong> }} + /> + </h1> </EuiText> <EuiSpacer size="xl" /> diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index bd338109a4460..f2d3fcd6ab3ca 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiHeaderSectionItemButton, EuiPopover } from '@elastic/eui'; +import { EuiContextMenuItem, EuiHeaderSectionItemButton, EuiPopover } from '@elastic/eui'; import React from 'react'; import { BehaviorSubject } from 'rxjs'; @@ -181,4 +181,58 @@ describe('SecurityNavControl', () => { expect(findTestSubject(wrapper, 'logoutLink').text()).toBe('Log in'); }); + + it('properly renders without a custom profile link.', async () => { + const props = { + user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), + editProfileUrl: '', + logoutUrl: '', + userMenuLinks$: new BehaviorSubject([ + { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, + { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2 }, + ]), + }; + + const wrapper = mountWithIntl(<SecurityNavControl {...props} />); + await nextTick(); + wrapper.update(); + + expect(wrapper.find(EuiContextMenuItem).map((node) => node.text())).toEqual([]); + + wrapper.find(EuiHeaderSectionItemButton).simulate('click'); + + expect(wrapper.find(EuiContextMenuItem).map((node) => node.text())).toEqual([ + 'Profile', + 'link1', + 'link2', + 'Log out', + ]); + }); + + it('properly renders with a custom profile link.', async () => { + const props = { + user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), + editProfileUrl: '', + logoutUrl: '', + userMenuLinks$: new BehaviorSubject([ + { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, + { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2, setAsProfile: true }, + ]), + }; + + const wrapper = mountWithIntl(<SecurityNavControl {...props} />); + await nextTick(); + wrapper.update(); + + expect(wrapper.find(EuiContextMenuItem).map((node) => node.text())).toEqual([]); + + wrapper.find(EuiHeaderSectionItemButton).simulate('click'); + + expect(wrapper.find(EuiContextMenuItem).map((node) => node.text())).toEqual([ + 'link1', + 'link2', + 'Preferences', + 'Log out', + ]); + }); }); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index c7649494bb810..546d6cf5a6bba 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -30,6 +30,7 @@ export interface UserMenuLink { iconType: IconType; href: string; order?: number; + setAsProfile?: boolean; } interface Props { @@ -123,35 +124,39 @@ export class SecurityNavControl extends Component<Props, State> { const isAnonymousUser = authenticatedUser?.authentication_provider.type === 'anonymous'; const items: EuiContextMenuPanelItemDescriptor[] = []; + if (userMenuLinks.length) { + const userMenuLinkMenuItems = userMenuLinks + .sort(({ order: orderA = Infinity }, { order: orderB = Infinity }) => orderA - orderB) + .map(({ label, iconType, href }: UserMenuLink) => ({ + name: <EuiText>{label}</EuiText>, + icon: <EuiIcon type={iconType} size="m" />, + href, + 'data-test-subj': `userMenuLink__${label}`, + })); + items.push(...userMenuLinkMenuItems); + } + if (!isAnonymousUser) { + const hasCustomProfileLinks = userMenuLinks.some(({ setAsProfile }) => setAsProfile === true); const profileMenuItem = { name: ( <FormattedMessage id="xpack.security.navControlComponent.editProfileLinkText" - defaultMessage="Profile" + defaultMessage="{profileOverridden, select, true{Preferences} other{Profile}}" + values={{ profileOverridden: hasCustomProfileLinks }} /> ), - icon: <EuiIcon type="user" size="m" />, + icon: <EuiIcon type={hasCustomProfileLinks ? 'controlsHorizontal' : 'user'} size="m" />, href: editProfileUrl, 'data-test-subj': 'profileLink', }; - items.push(profileMenuItem); - } - if (userMenuLinks.length) { - const userMenuLinkMenuItems = userMenuLinks - .sort(({ order: orderA = Infinity }, { order: orderB = Infinity }) => orderA - orderB) - .map(({ label, iconType, href }: UserMenuLink) => ({ - name: <EuiText>{label}</EuiText>, - icon: <EuiIcon type={iconType} size="m" />, - href, - 'data-test-subj': `userMenuLink__${label}`, - })); - - items.push(...userMenuLinkMenuItems, { - isSeparator: true, - key: 'securityNavControlComponent__userMenuLinksSeparator', - }); + // Set this as the first link if there is no user-defined profile link + if (!hasCustomProfileLinks) { + items.unshift(profileMenuItem); + } else { + items.push(profileMenuItem); + } } const logoutMenuItem = { diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts index 72a1a6f5817a5..035177d78c9c6 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts @@ -178,16 +178,19 @@ describe('SecurityNavControlService', () => { }); describe(`#start`, () => { - it('should return functions to register and retrieve user menu links', () => { - const license$ = new BehaviorSubject<ILicense>(validLicense); + let navControlService: SecurityNavControlService; + beforeEach(() => { + const license$ = new BehaviorSubject<ILicense>({} as ILicense); - const navControlService = new SecurityNavControlService(); + navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', }); + }); + it('should return functions to register and retrieve user menu links', () => { const coreStart = coreMock.createStart(); const navControlServiceStart = navControlService.start({ core: coreStart }); expect(navControlServiceStart).toHaveProperty('getUserMenuLinks$'); @@ -195,15 +198,6 @@ describe('SecurityNavControlService', () => { }); it('should register custom user menu links to be displayed in the nav controls', (done) => { - const license$ = new BehaviorSubject<ILicense>(validLicense); - - const navControlService = new SecurityNavControlService(); - navControlService.setup({ - securityLicense: new SecurityLicenseService().setup({ license$ }).license, - authc: securityMock.createSetup().authc, - logoutUrl: '/some/logout/url', - }); - const coreStart = coreMock.createStart(); const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ core: coreStart }); const userMenuLinks$ = getUserMenuLinks$(); @@ -231,15 +225,6 @@ describe('SecurityNavControlService', () => { }); it('should retrieve user menu links sorted by order', (done) => { - const license$ = new BehaviorSubject<ILicense>(validLicense); - - const navControlService = new SecurityNavControlService(); - navControlService.setup({ - securityLicense: new SecurityLicenseService().setup({ license$ }).license, - authc: securityMock.createSetup().authc, - logoutUrl: '/some/logout/url', - }); - const coreStart = coreMock.createStart(); const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ core: coreStart }); const userMenuLinks$ = getUserMenuLinks$(); @@ -305,5 +290,79 @@ describe('SecurityNavControlService', () => { done(); }); }); + + it('should allow adding a custom profile link', () => { + const coreStart = coreMock.createStart(); + const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ core: coreStart }); + const userMenuLinks$ = getUserMenuLinks$(); + + addUserMenuLinks([ + { label: 'link3', href: 'path-to-link3', iconType: 'empty', order: 3 }, + { label: 'link1', href: 'path-to-link1', iconType: 'empty', order: 1, setAsProfile: true }, + ]); + + const onUserMenuLinksHandler = jest.fn(); + userMenuLinks$.subscribe(onUserMenuLinksHandler); + + expect(onUserMenuLinksHandler).toHaveBeenCalledTimes(1); + expect(onUserMenuLinksHandler).toHaveBeenCalledWith([ + { label: 'link1', href: 'path-to-link1', iconType: 'empty', order: 1, setAsProfile: true }, + { label: 'link3', href: 'path-to-link3', iconType: 'empty', order: 3 }, + ]); + }); + + it('should not allow adding more than one custom profile link', () => { + const coreStart = coreMock.createStart(); + const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ core: coreStart }); + const userMenuLinks$ = getUserMenuLinks$(); + + expect(() => { + addUserMenuLinks([ + { + label: 'link3', + href: 'path-to-link3', + iconType: 'empty', + order: 3, + setAsProfile: true, + }, + { + label: 'link1', + href: 'path-to-link1', + iconType: 'empty', + order: 1, + setAsProfile: true, + }, + ]); + }).toThrowErrorMatchingInlineSnapshot( + `"Only one custom profile link can be passed at a time (found 2)"` + ); + + // Adding a single custom profile link. + addUserMenuLinks([ + { label: 'link3', href: 'path-to-link3', iconType: 'empty', order: 3, setAsProfile: true }, + ]); + + expect(() => { + addUserMenuLinks([ + { + label: 'link1', + href: 'path-to-link1', + iconType: 'empty', + order: 1, + setAsProfile: true, + }, + ]); + }).toThrowErrorMatchingInlineSnapshot( + `"Only one custom profile link can be set. A custom profile link named link3 (path-to-link3) already exists"` + ); + + const onUserMenuLinksHandler = jest.fn(); + userMenuLinks$.subscribe(onUserMenuLinksHandler); + + expect(onUserMenuLinksHandler).toHaveBeenCalledTimes(1); + expect(onUserMenuLinksHandler).toHaveBeenCalledWith([ + { label: 'link3', href: 'path-to-link3', iconType: 'empty', order: 3, setAsProfile: true }, + ]); + }); }); }); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index fc9ba262a2026..7f3d93099704a 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -77,6 +77,23 @@ export class SecurityNavControlService { this.userMenuLinks$.pipe(map(this.sortUserMenuLinks), takeUntil(this.stop$)), addUserMenuLinks: (userMenuLinks: UserMenuLink[]) => { const currentLinks = this.userMenuLinks$.value; + const hasCustomProfileLink = currentLinks.find(({ setAsProfile }) => setAsProfile === true); + const passedCustomProfileLinkCount = userMenuLinks.filter( + ({ setAsProfile }) => setAsProfile === true + ).length; + + if (hasCustomProfileLink && passedCustomProfileLinkCount > 0) { + throw new Error( + `Only one custom profile link can be set. A custom profile link named ${hasCustomProfileLink.label} (${hasCustomProfileLink.href}) already exists` + ); + } + + if (passedCustomProfileLinkCount > 1) { + throw new Error( + `Only one custom profile link can be passed at a time (found ${passedCustomProfileLinkCount})` + ); + } + const newLinks = [...currentLinks, ...userMenuLinks]; this.userMenuLinks$.next(newLinks); }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0cbaf1a7921c2..9c638e8a846aa 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17947,7 +17947,6 @@ "xpack.security.management.users.usersTitle": "ユーザー", "xpack.security.management.usersTitle": "ユーザー", "xpack.security.navControlComponent.accountMenuAriaLabel": "アカウントメニュー", - "xpack.security.navControlComponent.editProfileLinkText": "プロフィール", "xpack.security.navControlComponent.loginLinkText": "ログイン", "xpack.security.navControlComponent.logoutLinkText": "ログアウト", "xpack.security.overwrittenSession.continueAsUserText": "{username} として続行", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f11aa3fc3da6e..e478489804acd 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -18197,7 +18197,6 @@ "xpack.security.management.users.usersTitle": "用户", "xpack.security.management.usersTitle": "用户", "xpack.security.navControlComponent.accountMenuAriaLabel": "帐户菜单", - "xpack.security.navControlComponent.editProfileLinkText": "配置文件", "xpack.security.navControlComponent.loginLinkText": "登录", "xpack.security.navControlComponent.logoutLinkText": "注销", "xpack.security.overwrittenSession.continueAsUserText": "作为 {username} 继续",