Show roles in expandable list (#65577)

This commit is contained in:
Grzegorz Zdunek
2026-04-10 09:34:49 +00:00
committed by GitHub
parent aa1a175e39
commit 4ada208adc
6 changed files with 200 additions and 25 deletions
@@ -40,8 +40,27 @@ interface StoryProps {
activeClusterExpired: boolean;
deviceTrust: 'enrolled' | 'required-not-enrolled' | 'not-enrolled';
showProfileErrors: boolean;
roles: string[];
}
function withUuidSuffix(roles: string[]): string[] {
return roles.map(role => `${role}-${crypto.randomUUID().split('-').at(0)}`);
}
const baseRoles = [
'circle-mark-app-access',
'grafana-lite-app-access',
'grafana-gold-app-access',
'release-lion-app-access',
'release-fox-app-access',
'sales-center-lorem-app-access',
'sales-center-ipsum-db-access',
'sales-center-shop-app-access',
'sales-center-floor-db-access',
];
const roles = withUuidSuffix(baseRoles);
const meta: Meta<StoryProps> = {
title: 'Teleterm/Identity',
component: props => {
@@ -76,6 +95,7 @@ const meta: Meta<StoryProps> = {
? new Date()
: new Date(Date.now() + 24 * 60 * 60 * 1000)
),
roles: props.roles || clusters[0].loggedInUser.roles,
isDeviceTrusted: props.deviceTrust === 'enrolled',
trustedDeviceRequirement:
props.deviceTrust === 'required-not-enrolled'
@@ -130,17 +150,7 @@ const clusterOrange = makeRootCluster({
name: 'orange-psv-eindhoven-eredivisie-production-lorem-ipsum',
loggedInUser: makeLoggedInUser({
name: 'ruud-van-nistelrooy-van-der-sar',
roles: [
'circle-mark-app-access',
'grafana-lite-app-access',
'grafana-gold-app-access',
'release-lion-app-access',
'release-fox-app-access',
'sales-center-lorem-app-access',
'sales-center-ipsum-db-access',
'sales-center-shop-app-access',
'sales-center-floor-db-access',
],
roles,
}),
uri: '/clusters/orange',
});
@@ -230,6 +240,13 @@ export const OneClusterWithActiveCluster: StoryObj<StoryProps> = {
},
};
export const ManyRoles: StoryObj<StoryProps> = {
args: {
clusters: ['violet'],
roles: Array.from({ length: 30 }, () => withUuidSuffix(baseRoles)).flat(),
},
};
export const ManyClustersWithNoActiveCluster: StoryObj<StoryProps> = {
args: {
clusters: ['orange', 'green', 'violet'],
@@ -90,3 +90,37 @@ test.each([
await testCase.expect();
});
test('roles list remembers it was expanded', async () => {
const appContext = new MockAppContext();
appContext.addRootCluster(
makeRootCluster({
loggedInUser: makeLoggedInUser({ roles: ['requests-reviewer'] }),
})
);
render(
<MockAppContextProvider appContext={appContext}>
<IdentityContainer />
</MockAppContextProvider>
);
await toggleProfiles();
const roleItemBeforeExpand = await screen.findByText('requests-reviewer');
expect(roleItemBeforeExpand).not.toBeVisible();
await userEvent.click(await screen.findByText(/Roles/));
expect(await screen.findByText('requests-reviewer')).toBeVisible();
// Close and open again.
await toggleProfiles();
expect(screen.queryByText('requests-reviewer')).not.toBeInTheDocument();
await toggleProfiles();
expect(await screen.findByText('requests-reviewer')).toBeVisible();
});
async function toggleProfiles() {
await userEvent.click(await screen.findByTitle(/Open Profiles/));
}
@@ -109,6 +109,7 @@ export function IdentityContainer() {
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
onClose={() => setOpen(false)}
popoverCss={() => `max-width: min(450px, 90%)`}
updatePositionOnChildResize
>
<Container>
{activeRootCluster && (
@@ -20,7 +20,7 @@ import { formatDistanceToNowStrict, isPast } from 'date-fns';
import { JSX } from 'react';
import styled from 'styled-components';
import { ButtonText, Flex, Label, P3, Stack } from 'design';
import { ButtonText, Flex, P3, Stack } from 'design';
import {
Clock,
Logout,
@@ -33,6 +33,7 @@ import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb';
import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { ProfileStatusError } from 'teleterm/ui/components/ProfileStatusError';
import { usePersistedState } from 'teleterm/ui/hooks/usePersistedState';
import { WorkspaceColor } from 'teleterm/ui/services/workspacesService';
import { DeviceTrustStatus } from 'teleterm/ui/TopBar/Identity/Identity';
import { RootClusterUri, routing } from 'teleterm/ui/uri';
@@ -44,6 +45,7 @@ import {
IdentityListItem,
TitleAndSubtitle,
} from './IdentityListItem';
import { Roles } from './Roles';
export function ActiveCluster(props: {
activeCluster: Cluster | undefined;
@@ -57,6 +59,11 @@ export function ActiveCluster(props: {
const validUntil =
props.activeCluster.loggedInUser?.validUntil &&
Timestamp.toDate(props.activeCluster.loggedInUser.validUntil);
const roles = props.activeCluster.loggedInUser?.roles || [];
const [isRolesExpanded, setIsRolesExpanded] = usePersistedState(
'showRolesExpanded',
false
);
return (
<>
@@ -102,20 +109,12 @@ export function ActiveCluster(props: {
`}
/>
)}
<Flex flexWrap="wrap" gap={1} mt={1}>
{props.activeCluster.loggedInUser?.roles.map(role => (
<Label
css={`
line-height: 20px;
`}
key={role}
kind="secondary"
>
{role}
</Label>
))}
</Flex>
<Stack gap={0}>
<Roles
roles={roles}
expanded={isRolesExpanded}
setExpanded={setIsRolesExpanded}
/>
{validUntil && (
<Flex gap={1} color="text.slightlyMuted">
<Clock size="small" />
@@ -0,0 +1,122 @@
/**
* 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 styled from 'styled-components';
import Flex, { Stack } from 'design/Flex';
import { ChevronDown } from 'design/Icon';
import { P3 } from 'design/Text';
export function Roles(props: {
roles: string[];
expanded: boolean;
setExpanded(expanded: boolean): void;
}) {
return (
<Details
open={props.expanded}
onToggle={event => props.setExpanded(event.currentTarget.open)}
>
<Summary>
<Flex gap={1}>
<Chevron size="small" />
Roles ({props.roles.length})
</Flex>
</Summary>
<Expandable>
{props.roles.length ? (
<List>
{props.roles.map(role => (
<li key={role}>{role}</li>
))}
</List>
) : (
<P3 ml={1}>No roles.</P3>
)}
</Expandable>
</Details>
);
}
const Details = styled.details`
${props => props.theme.typography.body3};
color: ${props => props.theme.colors.text.slightlyMuted};
width: 100%;
&::details-content {
transition:
height 0.2s ease,
content-visibility 0.2s ease allow-discrete;
height: 0;
overflow: clip;
}
interpolate-size: allow-keywords;
&[open]::details-content {
height: auto;
}
`;
const Chevron = styled(ChevronDown)`
transition: transform 0.2s ease;
${Details}[open] & {
transform: rotate(180deg);
}
`;
const Summary = styled.summary`
cursor: pointer;
user-select: none;
width: 100%;
list-style: none;
border-radius: ${props => props.theme.space[1]}px;
&:hover {
color: ${props => props.theme.colors.text.main};
}
&:focus-visible {
outline: 2px solid ${props => props.theme.colors.text.slightlyMuted};
}
`;
const Expandable = styled(Stack).attrs({ pl: 3 })`
position: relative;
&::before {
content: '';
position: absolute;
left: ${props => props.theme.space[2]}px;
top: ${props => props.theme.space[1]}px;
bottom: ${props => props.theme.space[1]}px;
width: 2px;
background: ${({ theme }) => theme.colors.interactive.tonal.neutral[0]};
}
`;
const List = styled.ul`
box-sizing: border-box;
margin: 0;
padding: 0 0 0 ${props => props.theme.space[3]}px;
min-height: 0;
width: 100%;
overflow-y: auto;
max-height: 30vh;
`;
@@ -66,6 +66,8 @@ export interface StatePersistenceState {
};
/** Shows a banner above the cluster list to notify that a new tsh home dir is used. */
showTshHomeMigrationBanner: boolean;
/** Remembers whether the roles section in the identity popover is expanded. */
showRolesExpanded?: boolean;
}
// Before adding new methods to this service, consider using usePersistedState instead.