From 4ada208adc8a9685aa00f35cc19b17d2d9be7ffa Mon Sep 17 00:00:00 2001 From: Grzegorz Zdunek Date: Fri, 10 Apr 2026 11:34:49 +0200 Subject: [PATCH] Show roles in expandable list (#65577) --- .../src/ui/TopBar/Identity/Identity.story.tsx | 39 ++++-- .../src/ui/TopBar/Identity/Identity.test.tsx | 34 +++++ .../src/ui/TopBar/Identity/Identity.tsx | 1 + .../Identity/IdentityList/IdentityList.tsx | 27 ++-- .../ui/TopBar/Identity/IdentityList/Roles.tsx | 122 ++++++++++++++++++ .../statePersistenceService.ts | 2 + 6 files changed, 200 insertions(+), 25 deletions(-) create mode 100644 web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/Roles.tsx diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/Identity.story.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/Identity.story.tsx index e2d88090d4d..45e6b8ee584 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/Identity.story.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/Identity.story.tsx @@ -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 = { title: 'Teleterm/Identity', component: props => { @@ -76,6 +95,7 @@ const meta: Meta = { ? 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 = { }, }; +export const ManyRoles: StoryObj = { + args: { + clusters: ['violet'], + roles: Array.from({ length: 30 }, () => withUuidSuffix(baseRoles)).flat(), + }, +}; + export const ManyClustersWithNoActiveCluster: StoryObj = { args: { clusters: ['orange', 'green', 'violet'], diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/Identity.test.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/Identity.test.tsx index 8c4e555bccf..06b6d44fad2 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/Identity.test.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/Identity.test.tsx @@ -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( + + + + ); + + 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/)); +} diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/Identity.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/Identity.tsx index 2a49f42af6d..800395ca333 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/Identity.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/Identity.tsx @@ -109,6 +109,7 @@ export function IdentityContainer() { transformOrigin={{ vertical: 'top', horizontal: 'right' }} onClose={() => setOpen(false)} popoverCss={() => `max-width: min(450px, 90%)`} + updatePositionOnChildResize > {activeRootCluster && ( diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/IdentityList.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/IdentityList.tsx index 9ab3029a1d3..afd93da7e7b 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/IdentityList.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/IdentityList.tsx @@ -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: { `} /> )} - - {props.activeCluster.loggedInUser?.roles.map(role => ( - - ))} - + {validUntil && ( diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/Roles.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/Roles.tsx new file mode 100644 index 00000000000..ef7a6173fa2 --- /dev/null +++ b/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/Roles.tsx @@ -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 . + */ + +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 ( +
props.setExpanded(event.currentTarget.open)} + > + + + + Roles ({props.roles.length}) + + + + {props.roles.length ? ( + + {props.roles.map(role => ( +
  • {role}
  • + ))} +
    + ) : ( + No roles. + )} +
    +
    + ); +} + +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; +`; diff --git a/web/packages/teleterm/src/ui/services/statePersistence/statePersistenceService.ts b/web/packages/teleterm/src/ui/services/statePersistence/statePersistenceService.ts index 6c59b2c3879..cc85ef028a4 100644 --- a/web/packages/teleterm/src/ui/services/statePersistence/statePersistenceService.ts +++ b/web/packages/teleterm/src/ui/services/statePersistence/statePersistenceService.ts @@ -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.