mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-19 01:58:44 +08:00
Surface User Display Names in the Web Users Page and User Details (#67406)
* Surface user display names in web Users page and user details * Refactor user authentication type display and update related tests * reduce code churn * add 16px back * Refactor UserDisplayName component for improved layout and update related tests * Refactor UserDetails tests to improve rendering checks and remove unnecessary assertions
This commit is contained in:
@@ -17,16 +17,16 @@
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Flex, Text } from 'design';
|
||||
|
||||
import { UserDisplayName } from './UserDisplayName';
|
||||
|
||||
const meta: Meta<typeof UserDisplayName> = {
|
||||
const meta = {
|
||||
title: 'Shared/UserDisplayName',
|
||||
component: UserDisplayName,
|
||||
component: Wrapper,
|
||||
args: {
|
||||
username: 'alice@example.com',
|
||||
primaryText: 'Alice Jones',
|
||||
@@ -46,11 +46,15 @@ const meta: Meta<typeof UserDisplayName> = {
|
||||
</Flex>
|
||||
),
|
||||
],
|
||||
};
|
||||
} satisfies Meta<typeof Wrapper>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof UserDisplayName>;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function Wrapper(props: ComponentProps<typeof UserDisplayName>) {
|
||||
return <UserDisplayName {...props} />;
|
||||
}
|
||||
|
||||
export const Playground: Story = {};
|
||||
|
||||
@@ -64,7 +68,7 @@ export const LayoutVariants: Story = {
|
||||
>
|
||||
<LayoutExample
|
||||
value="inline"
|
||||
description="Primary, secondary, and username stay on one line."
|
||||
description="Username and secondary render inside the parenthetical group."
|
||||
>
|
||||
<UserDisplayName
|
||||
username="alice@example.com"
|
||||
@@ -75,7 +79,7 @@ export const LayoutVariants: Story = {
|
||||
</LayoutExample>
|
||||
<LayoutExample
|
||||
value="stacked"
|
||||
description="Primary stays first, with supporting values below it."
|
||||
description="Username and secondary share the supporting line."
|
||||
>
|
||||
<UserDisplayName
|
||||
username="alice@example.com"
|
||||
@@ -109,7 +113,7 @@ export const LayoutVariantsWithoutSecondary: Story = {
|
||||
>
|
||||
<LayoutExample
|
||||
value="inline"
|
||||
description="Primary and username stay on one line."
|
||||
description="Username renders inside the parenthetical group."
|
||||
>
|
||||
<UserDisplayName
|
||||
username="alice@example.com"
|
||||
@@ -119,7 +123,7 @@ export const LayoutVariantsWithoutSecondary: Story = {
|
||||
</LayoutExample>
|
||||
<LayoutExample
|
||||
value="stacked"
|
||||
description="Primary stays first, with username below it."
|
||||
description="Username renders on the supporting line."
|
||||
>
|
||||
<UserDisplayName
|
||||
username="alice@example.com"
|
||||
@@ -211,7 +215,7 @@ export const LongValues: Story = {
|
||||
<Flex alignItems="stretch" flexDirection="column" gap={3} width="240px">
|
||||
<LayoutExample
|
||||
value="inline"
|
||||
description="Long values are truncated within a narrow container."
|
||||
description="Grouped values are truncated within a narrow container."
|
||||
>
|
||||
<UserDisplayName
|
||||
username="alice.jones.engineering@very-long-example-domain.com"
|
||||
@@ -222,7 +226,7 @@ export const LongValues: Story = {
|
||||
</LayoutExample>
|
||||
<LayoutExample
|
||||
value="stacked"
|
||||
description="Each stacked line truncates independently."
|
||||
description="Each stacked value line truncates independently."
|
||||
>
|
||||
<UserDisplayName
|
||||
username="alice.jones.engineering@very-long-example-domain.com"
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('UserDisplayName', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('formats inline username with delimiters', () => {
|
||||
it('formats inline supporting values with delimiters', () => {
|
||||
render(
|
||||
<UserDisplayName
|
||||
username={username}
|
||||
@@ -124,17 +124,25 @@ describe('UserDisplayName', () => {
|
||||
|
||||
const primaryLine = screen.getByText('Alice Jones')
|
||||
.parentElement as HTMLElement;
|
||||
expect(within(primaryLine).getByText('Engineering')).toBeInTheDocument();
|
||||
const inlineUsername = within(primaryLine).getByText(username);
|
||||
expect(inlineUsername).toHaveStyleRule('content', "'('", {
|
||||
const inlineSupportingValues = inlineUsername.parentElement as HTMLElement;
|
||||
const inlineSecondary = within(inlineSupportingValues).getByText(
|
||||
'Engineering'
|
||||
);
|
||||
|
||||
expect(primaryLine).toContainElement(inlineSupportingValues);
|
||||
expect(inlineSupportingValues).toHaveStyleRule('content', "'('", {
|
||||
modifier: '::before',
|
||||
});
|
||||
expect(inlineUsername).toHaveStyleRule('content', "')'", {
|
||||
expect(inlineSupportingValues).toHaveStyleRule('content', "')'", {
|
||||
modifier: '::after',
|
||||
});
|
||||
expect(inlineSecondary).toHaveStyleRule('content', "'•'", {
|
||||
modifier: '::before',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders stacked supporting values outside the primary line', () => {
|
||||
it('renders stacked supporting values together below the primary line', () => {
|
||||
render(
|
||||
<UserDisplayName
|
||||
username={username}
|
||||
@@ -150,8 +158,33 @@ describe('UserDisplayName', () => {
|
||||
within(primaryLine).queryByText('Engineering')
|
||||
).not.toBeInTheDocument();
|
||||
expect(within(primaryLine).queryByText(username)).not.toBeInTheDocument();
|
||||
|
||||
const supportingLine = screen.getByText(username)
|
||||
.parentElement as HTMLElement;
|
||||
const secondary = within(supportingLine).getByText('Engineering');
|
||||
|
||||
expect(supportingLine).toContainElement(screen.getByText(username));
|
||||
expect(secondary).toHaveStyleRule('content', "'•'", {
|
||||
modifier: '::before',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not repeat the username when primary text is absent', () => {
|
||||
render(
|
||||
<UserDisplayName
|
||||
username={username}
|
||||
secondaryText="Engineering"
|
||||
layout="stacked"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryAllByText(username)).toHaveLength(1);
|
||||
|
||||
const primaryLine = screen.getByText(username).parentElement as HTMLElement;
|
||||
expect(
|
||||
within(primaryLine).queryByText('Engineering')
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Engineering')).toBeInTheDocument();
|
||||
expect(screen.getByText(username)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to tooltip layout', async () => {
|
||||
@@ -173,6 +206,22 @@ describe('UserDisplayName', () => {
|
||||
expect(within(tooltip).getByText(username)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('anchors tooltip layout to the primary text', () => {
|
||||
render(
|
||||
<UserDisplayName
|
||||
username={username}
|
||||
primaryText="Alice Jones"
|
||||
secondaryText="Engineering"
|
||||
layout="tooltip"
|
||||
/>
|
||||
);
|
||||
|
||||
const tooltipTrigger = screen.getByLabelText(
|
||||
'Alice Jones, Engineering, username alice@example.com'
|
||||
);
|
||||
expect(tooltipTrigger).toBe(screen.getByText('Alice Jones'));
|
||||
});
|
||||
|
||||
function getTooltipAriaLabel(
|
||||
primary: string,
|
||||
secondary: string | null | undefined,
|
||||
|
||||
@@ -39,29 +39,46 @@ export function UserDisplayName({
|
||||
const displayPrimary = normalizeText(primaryText);
|
||||
const displaySecondary = normalizeText(secondaryText);
|
||||
const primary = displayPrimary || username;
|
||||
const tooltipLabel = getTooltipAriaLabel(primary, displaySecondary, username);
|
||||
|
||||
const primaryValue = <PrimaryValue title={primary}>{primary}</PrimaryValue>;
|
||||
const primaryValue = (ariaLabel?: string) => (
|
||||
<PrimaryValue title={primary} aria-label={ariaLabel}>
|
||||
{primary}
|
||||
</PrimaryValue>
|
||||
);
|
||||
|
||||
const secondaryValue = displaySecondary && (
|
||||
<SecondaryValue title={displaySecondary}>{displaySecondary}</SecondaryValue>
|
||||
);
|
||||
|
||||
const usernameValue =
|
||||
displayPrimary &&
|
||||
(layout === 'inline' ? (
|
||||
<InlineUsernameValue title={username}>{username}</InlineUsernameValue>
|
||||
) : (
|
||||
const separatedSecondaryValue = displaySecondary && (
|
||||
<SeparatedSecondaryValue title={displaySecondary}>
|
||||
{displaySecondary}
|
||||
</SeparatedSecondaryValue>
|
||||
);
|
||||
|
||||
const supportingValues = displayPrimary ? (
|
||||
<>
|
||||
<UsernameValue title={username}>{username}</UsernameValue>
|
||||
));
|
||||
{separatedSecondaryValue}
|
||||
</>
|
||||
) : (
|
||||
secondaryValue
|
||||
);
|
||||
|
||||
switch (layout) {
|
||||
case 'inline':
|
||||
return (
|
||||
<Root className={className}>
|
||||
<DisplayLine>
|
||||
{primaryValue}
|
||||
{secondaryValue}
|
||||
{usernameValue}
|
||||
{primaryValue()}
|
||||
{displayPrimary ? (
|
||||
<InlineSupportingValues>
|
||||
{supportingValues}
|
||||
</InlineSupportingValues>
|
||||
) : (
|
||||
supportingValues
|
||||
)}
|
||||
</DisplayLine>
|
||||
</Root>
|
||||
);
|
||||
@@ -69,9 +86,12 @@ export function UserDisplayName({
|
||||
case 'stacked':
|
||||
return (
|
||||
<Root className={className}>
|
||||
<DisplayLine>{primaryValue}</DisplayLine>
|
||||
{secondaryValue}
|
||||
{usernameValue}
|
||||
<DisplayLine>{primaryValue()}</DisplayLine>
|
||||
{displayPrimary ? (
|
||||
<SupportingLine>{supportingValues}</SupportingLine>
|
||||
) : (
|
||||
supportingValues
|
||||
)}
|
||||
</Root>
|
||||
);
|
||||
|
||||
@@ -79,19 +99,13 @@ export function UserDisplayName({
|
||||
return (
|
||||
<Root className={className}>
|
||||
{displayPrimary ? (
|
||||
<HoverTooltip tipContent={username}>
|
||||
<DisplayLine
|
||||
aria-label={getTooltipAriaLabel(
|
||||
primary,
|
||||
displaySecondary,
|
||||
username
|
||||
)}
|
||||
>
|
||||
{primaryValue}
|
||||
</DisplayLine>
|
||||
</HoverTooltip>
|
||||
<DisplayLine>
|
||||
<HoverTooltip tipContent={username}>
|
||||
{primaryValue(tooltipLabel)}
|
||||
</HoverTooltip>
|
||||
</DisplayLine>
|
||||
) : (
|
||||
<DisplayLine>{primaryValue}</DisplayLine>
|
||||
<DisplayLine>{primaryValue()}</DisplayLine>
|
||||
)}
|
||||
{secondaryValue}
|
||||
</Root>
|
||||
@@ -150,6 +164,12 @@ const DisplayLine = styled.span`
|
||||
gap: ${props => props.theme.space[1]}px;
|
||||
`;
|
||||
|
||||
const SupportingLine = styled.span`
|
||||
${containedContent}
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
`;
|
||||
|
||||
const PrimaryValue = styled(singleLineText).attrs({
|
||||
typography: 'body2',
|
||||
})``;
|
||||
@@ -164,11 +184,23 @@ const SecondaryValue = styled(singleLineText).attrs({
|
||||
typography: 'body3',
|
||||
})``;
|
||||
|
||||
// The parentheses are decorative wrappers around the inline username — using
|
||||
// `::before/::after` keeps them out of the React text content so they don't
|
||||
// appear in `textContent`, snapshots, or the accessibility tree, and lets us
|
||||
// style them independently from the value itself.
|
||||
const InlineUsernameValue = styled(UsernameValue)`
|
||||
const SeparatedSecondaryValue = styled(SecondaryValue)`
|
||||
&::before {
|
||||
content: '•';
|
||||
margin: 0 ${props => props.theme.space[1]}px;
|
||||
}
|
||||
`;
|
||||
|
||||
// Decorative delimiters stay out of textContent
|
||||
const InlineSupportingValues = styled(Text).attrs({
|
||||
as: 'span',
|
||||
color: 'text.muted',
|
||||
typography: 'body3',
|
||||
})`
|
||||
${containedContent}
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
|
||||
&::before {
|
||||
content: '(';
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ export type UserDetailsStoryProps = {
|
||||
userType: UserDetailsAuthType;
|
||||
isBot: boolean;
|
||||
userName: string;
|
||||
displayPrimary?: string;
|
||||
displaySecondary?: string;
|
||||
rolesCount: number;
|
||||
traitsCount: number;
|
||||
};
|
||||
@@ -83,6 +85,12 @@ const meta: Meta<UserDetailsStoryProps> = {
|
||||
userName: {
|
||||
control: { type: 'text' },
|
||||
},
|
||||
displayPrimary: {
|
||||
control: { type: 'text' },
|
||||
},
|
||||
displaySecondary: {
|
||||
control: { type: 'text' },
|
||||
},
|
||||
rolesCount: {
|
||||
control: { type: 'select' },
|
||||
options: [0, 5, 16, 128],
|
||||
@@ -96,6 +104,8 @@ const meta: Meta<UserDetailsStoryProps> = {
|
||||
userType: 'local' as const,
|
||||
isBot: false,
|
||||
userName: 'john.the.user',
|
||||
displayPrimary: 'John The User',
|
||||
displaySecondary: 'john.the.user@example.com',
|
||||
rolesCount: 16,
|
||||
traitsCount: 5,
|
||||
},
|
||||
@@ -227,6 +237,8 @@ export function createMockUser(props: UserDetailsStoryProps): User {
|
||||
|
||||
return {
|
||||
name: props.userName,
|
||||
displayPrimary: props.displayPrimary,
|
||||
displaySecondary: props.displaySecondary,
|
||||
authType: config.authType,
|
||||
origin: config.origin,
|
||||
isBot: props.isBot,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2026 Gravitational, Inc.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { render, screen, within } from 'design/utils/testing';
|
||||
|
||||
import { User } from 'teleport/services/user';
|
||||
|
||||
import { UserDetails, UserDetailsTitle } from './UserDetails';
|
||||
|
||||
jest.mock('teleport/lib/locks/useResourceLock', () => ({
|
||||
useResourceLock: () => ({ isLocked: false, isLoading: false }),
|
||||
}));
|
||||
|
||||
describe('UserDetails display names', () => {
|
||||
it('renders display values in the title and keeps the username field canonical', () => {
|
||||
const user: User = {
|
||||
name: 'alice',
|
||||
roles: ['access'],
|
||||
authType: 'local',
|
||||
isLocal: true,
|
||||
displayPrimary: 'Alice Jones',
|
||||
displaySecondary: 'alice@example.com',
|
||||
};
|
||||
|
||||
const { unmount } = render(<UserDetailsTitle user={user} />);
|
||||
|
||||
expect(screen.getByText('Alice Jones')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
|
||||
render(<UserDetails user={user} sections={[]} />);
|
||||
|
||||
const usernameField = screen.getByText('Username')
|
||||
.parentElement as HTMLElement;
|
||||
expect(within(usernameField).getByText('alice')).toBeInTheDocument();
|
||||
// Auth Type is in the body, with the resource icon in front.
|
||||
const authTypeField = screen.getByText('Auth Type')
|
||||
.parentElement as HTMLElement;
|
||||
expect(within(authTypeField).getByText('local')).toBeInTheDocument();
|
||||
expect(
|
||||
within(authTypeField).getByTestId('res-icon-server')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders secondary-only users with username as the primary value', () => {
|
||||
const user: User = {
|
||||
name: 'alice',
|
||||
roles: ['access'],
|
||||
authType: 'local',
|
||||
isLocal: true,
|
||||
displaySecondary: 'alice@example.com',
|
||||
};
|
||||
|
||||
render(<UserDetailsTitle user={user} />);
|
||||
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders username-only users without duplicating the username', () => {
|
||||
const user: User = {
|
||||
name: 'bot-user',
|
||||
roles: ['bot-user'],
|
||||
authType: 'local',
|
||||
isLocal: true,
|
||||
isBot: true,
|
||||
};
|
||||
|
||||
render(<UserDetailsTitle user={user} />);
|
||||
|
||||
expect(screen.queryAllByText('bot-user')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
InfoParagraph,
|
||||
InfoTitle,
|
||||
} from 'shared/components/SlidingSidePanel/InfoGuide';
|
||||
import { UserDisplayName } from 'shared/components/UserDisplayName';
|
||||
|
||||
import cfg from 'teleport/config';
|
||||
import { useResourceLock } from 'teleport/lib/locks/useResourceLock';
|
||||
@@ -86,9 +87,19 @@ export function UserDetails({
|
||||
</UserDetailField>
|
||||
<UserDetailField>
|
||||
<Text fontWeight="medium">Auth Type</Text>
|
||||
<Text color="text.muted" style={{ textTransform: 'capitalize' }}>
|
||||
{renderAuthType(user).text}
|
||||
</Text>
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<ResourceIcon
|
||||
name={renderAuthType(user).icon}
|
||||
width="16px"
|
||||
height="16px"
|
||||
/>
|
||||
<Text
|
||||
color="text.muted"
|
||||
style={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
{renderAuthType(user).text}
|
||||
</Text>
|
||||
</Flex>
|
||||
</UserDetailField>
|
||||
<UserDetailField>
|
||||
<Text fontWeight="medium">Status</Text>
|
||||
@@ -196,8 +207,6 @@ export function UserDetailsTitle({
|
||||
onDelete,
|
||||
panelWidth = 480,
|
||||
}: UserDetailsTitleProps) {
|
||||
const { text: authType, icon } = renderAuthType(user);
|
||||
|
||||
// needed to fill InfoGuidePanel for UserDetailsActions
|
||||
const containerWidth = panelWidth - 80;
|
||||
const userIconSize = 48;
|
||||
@@ -215,21 +224,12 @@ export function UserDetailsTitle({
|
||||
<Icons.User size={userIconSize} />
|
||||
)}
|
||||
<Box maxWidth={containerWidth - 96}>
|
||||
<Text fontSize={3} fontWeight="bold" title={user.name}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<ResourceIcon name={icon} width="16px" height="16px" />
|
||||
<Text
|
||||
fontSize={2}
|
||||
color="text.muted"
|
||||
fontWeight="normal"
|
||||
style={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
{authType}
|
||||
{user.isBot ? ' (Bot)' : ''}
|
||||
</Text>
|
||||
</Flex>
|
||||
<UserDisplayName
|
||||
username={user.name}
|
||||
primaryText={user.displayPrimary}
|
||||
secondaryText={user.displaySecondary}
|
||||
layout="stacked"
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
<UserDetailsActions
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useTheme } from 'styled-components';
|
||||
import Table, { Cell, LabelCell } from 'design/DataTable';
|
||||
import { MenuButton, MenuItem } from 'shared/components/MenuAction';
|
||||
import { SearchPanel } from 'shared/components/Search';
|
||||
import { UserDisplayName } from 'shared/components/UserDisplayName';
|
||||
|
||||
import { SeversidePagination } from 'teleport/components/hooks/useServersidePagination';
|
||||
import { Access, User, UserOrigin } from 'teleport/services/user';
|
||||
@@ -77,6 +78,16 @@ export default function UserList({
|
||||
{
|
||||
key: 'name',
|
||||
headerText: 'Name',
|
||||
render: (user: User) => (
|
||||
<Cell style={{ minWidth: '320px', maxWidth: '480px' }}>
|
||||
<UserDisplayName
|
||||
username={user.name}
|
||||
primaryText={user.displayPrimary}
|
||||
secondaryText={user.displaySecondary}
|
||||
layout="stacked"
|
||||
/>
|
||||
</Cell>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'roles',
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import type { StoryObj } from '@storybook/react-vite';
|
||||
import { delay } from 'msw';
|
||||
|
||||
import { ContentMinWidth } from 'teleport/Main/Main';
|
||||
import { TeleportProviderBasic } from 'teleport/mocks/providers';
|
||||
import {
|
||||
errorGetUsers,
|
||||
@@ -35,14 +36,17 @@ export default {
|
||||
const users = [
|
||||
{
|
||||
name: 'cikar@egaposci.me',
|
||||
displayPrimary: 'Cikar Egaposci',
|
||||
displaySecondary: 'cikar@egaposci.me',
|
||||
roles: ['admin'],
|
||||
authType: 'teleport local user',
|
||||
authType: 'local user',
|
||||
isLocal: true,
|
||||
},
|
||||
{
|
||||
name: 'hi@nen.pa',
|
||||
displaySecondary: 'hi@nen.pa',
|
||||
roles: ['ruhh', 'admin'],
|
||||
authType: 'teleport local user',
|
||||
authType: 'local user',
|
||||
isLocal: true,
|
||||
},
|
||||
{
|
||||
@@ -66,13 +70,13 @@ const users = [
|
||||
{
|
||||
name: 'azesotil@jevig.org',
|
||||
roles: ['tugu'],
|
||||
authType: 'teleport local user',
|
||||
authType: 'local user',
|
||||
isLocal: true,
|
||||
},
|
||||
{
|
||||
name: 'bot-little-robot',
|
||||
roles: ['bot-little-robot'],
|
||||
authType: 'teleport local user',
|
||||
authType: 'local user',
|
||||
isLocal: true,
|
||||
isBot: true,
|
||||
},
|
||||
@@ -85,11 +89,7 @@ export const Loaded: StoryObj = {
|
||||
},
|
||||
},
|
||||
render() {
|
||||
return (
|
||||
<TeleportProviderBasic>
|
||||
<Users {...sample} />
|
||||
</TeleportProviderBasic>
|
||||
);
|
||||
return renderUsers(sample);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -100,11 +100,7 @@ export const UsersNotEqualMauNotice: StoryObj = {
|
||||
},
|
||||
},
|
||||
render() {
|
||||
return (
|
||||
<TeleportProviderBasic>
|
||||
<Users {...sample} showMauInfo={true} />
|
||||
</TeleportProviderBasic>
|
||||
);
|
||||
return renderUsers({ ...sample, showMauInfo: true });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -115,11 +111,7 @@ export const Processing: StoryObj = {
|
||||
},
|
||||
},
|
||||
render() {
|
||||
return (
|
||||
<TeleportProviderBasic>
|
||||
<Users {...sample} />
|
||||
</TeleportProviderBasic>
|
||||
);
|
||||
return renderUsers(sample);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -130,14 +122,20 @@ export const Failed: StoryObj = {
|
||||
},
|
||||
},
|
||||
render() {
|
||||
return (
|
||||
<TeleportProviderBasic>
|
||||
<Users {...sample} />
|
||||
</TeleportProviderBasic>
|
||||
);
|
||||
return renderUsers(sample);
|
||||
},
|
||||
};
|
||||
|
||||
function renderUsers(props) {
|
||||
return (
|
||||
<TeleportProviderBasic>
|
||||
<ContentMinWidth>
|
||||
<Users {...props} />
|
||||
</ContentMinWidth>
|
||||
</TeleportProviderBasic>
|
||||
);
|
||||
}
|
||||
|
||||
const sample = {
|
||||
attempt: {
|
||||
isProcessing: false,
|
||||
|
||||
@@ -20,12 +20,24 @@ import { User } from './types';
|
||||
|
||||
export default function makeUser(json: any): User {
|
||||
json = json || {};
|
||||
const { name, roles, authType, origin, traits = {}, allTraits, isBot } = json;
|
||||
const {
|
||||
name,
|
||||
roles,
|
||||
authType,
|
||||
origin,
|
||||
traits = {},
|
||||
allTraits,
|
||||
isBot,
|
||||
displayPrimary,
|
||||
displaySecondary,
|
||||
} = json;
|
||||
|
||||
return {
|
||||
name,
|
||||
displayPrimary,
|
||||
displaySecondary,
|
||||
roles: roles ? roles.sort() : [],
|
||||
authType: authType === 'local' ? 'teleport local user' : authType,
|
||||
authType: authType === 'local' ? 'local user' : authType,
|
||||
isLocal: authType === 'local',
|
||||
isBot,
|
||||
origin: origin ? origin : '',
|
||||
|
||||
@@ -140,6 +140,10 @@ export type UserOrigin = 'okta' | 'saml' | 'scim';
|
||||
export interface User {
|
||||
// name is the teleport username.
|
||||
name: string;
|
||||
// displayPrimary is the human-readable name resolved server-side.
|
||||
displayPrimary?: string;
|
||||
// displaySecondary is supporting display context resolved server-side.
|
||||
displaySecondary?: string;
|
||||
// roles is the list of roles user is assigned to.
|
||||
roles: string[];
|
||||
// authType describes how the user authenticated
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import cfg from 'teleport/config';
|
||||
import api from 'teleport/services/api';
|
||||
|
||||
import { makeTraits } from './makeUser';
|
||||
import makeUser, { makeTraits } from './makeUser';
|
||||
import { Acl, ExcludeUserField, PasswordState, User } from './types';
|
||||
import user from './user';
|
||||
|
||||
@@ -428,6 +428,8 @@ test('fetch users, null response values gives empty array', async () => {
|
||||
expect(response).toStrictEqual([
|
||||
{
|
||||
authType: '',
|
||||
displayPrimary: undefined,
|
||||
displaySecondary: undefined,
|
||||
isBot: undefined,
|
||||
isLocal: false,
|
||||
name: '',
|
||||
@@ -447,6 +449,43 @@ test('fetch users, null response values gives empty array', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('makeUser maps display name fields when present', () => {
|
||||
expect(
|
||||
makeUser({
|
||||
name: 'alice',
|
||||
roles: ['access'],
|
||||
displayPrimary: 'Alice Jones',
|
||||
displaySecondary: 'alice@example.com',
|
||||
})
|
||||
).toMatchObject({
|
||||
name: 'alice',
|
||||
displayPrimary: 'Alice Jones',
|
||||
displaySecondary: 'alice@example.com',
|
||||
});
|
||||
|
||||
expect(makeUser({ name: 'bob', roles: [] })).toMatchObject({
|
||||
displayPrimary: undefined,
|
||||
displaySecondary: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('makeUser labels local users as "local user"', () => {
|
||||
expect(
|
||||
makeUser({ name: 'alice', roles: [], authType: 'local' })
|
||||
).toMatchObject({
|
||||
authType: 'local user',
|
||||
isLocal: true,
|
||||
});
|
||||
|
||||
// Non-local auth types pass through unchanged.
|
||||
expect(
|
||||
makeUser({ name: 'bob', roles: [], authType: 'github' })
|
||||
).toMatchObject({
|
||||
authType: 'github',
|
||||
isLocal: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('createResetPasswordToken', async () => {
|
||||
// Test null response.
|
||||
jest.spyOn(api, 'post').mockResolvedValue(null);
|
||||
|
||||
Reference in New Issue
Block a user