diff --git a/api/gen/proto/go/teleport/accesslist/v1/accesslist_service.pb.go b/api/gen/proto/go/teleport/accesslist/v1/accesslist_service.pb.go index 72fe3450774a9..3cc80e209b3f0 100644 --- a/api/gen/proto/go/teleport/accesslist/v1/accesslist_service.pb.go +++ b/api/gen/proto/go/teleport/accesslist/v1/accesslist_service.pb.go @@ -2624,6 +2624,8 @@ type ListUserAccessListsResponse struct { AccessLists []*AccessList `protobuf:"bytes,1,rep,name=access_lists,json=accessLists,proto3" json:"access_lists,omitempty"` // next_page_token is the next page token. NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + // total_count is the total number of access lists in all pages. + TotalCount int32 `protobuf:"varint,3,opt,name=total_count,json=totalCount,proto3" json:"total_count,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2672,6 +2674,13 @@ func (x *ListUserAccessListsResponse) GetNextPageToken() string { return "" } +func (x *ListUserAccessListsResponse) GetTotalCount() int32 { + if x != nil { + return x.TotalCount + } + return 0 +} + var File_teleport_accesslist_v1_accesslist_service_proto protoreflect.FileDescriptor const file_teleport_accesslist_v1_accesslist_service_proto_rawDesc = "" + @@ -2833,10 +2842,12 @@ const file_teleport_accesslist_v1_accesslist_service_proto_rawDesc = "" + "\busername\x18\x01 \x01(\tR\busername\x12\x1b\n" + "\tpage_size\x18\x02 \x01(\x05R\bpageSize\x12\x1d\n" + "\n" + - "page_token\x18\x03 \x01(\tR\tpageToken\"\x8c\x01\n" + + "page_token\x18\x03 \x01(\tR\tpageToken\"\xad\x01\n" + "\x1bListUserAccessListsResponse\x12E\n" + "\faccess_lists\x18\x01 \x03(\v2\".teleport.accesslist.v1.AccessListR\vaccessLists\x12&\n" + - "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken2\xc8\x1e\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12\x1f\n" + + "\vtotal_count\x18\x03 \x01(\x05R\n" + + "totalCount2\xc8\x1e\n" + "\x11AccessListService\x12o\n" + "\x0eGetAccessLists\x12-.teleport.accesslist.v1.GetAccessListsRequest\x1a..teleport.accesslist.v1.GetAccessListsResponse\x12w\n" + "\x0fListAccessLists\x12..teleport.accesslist.v1.ListAccessListsRequest\x1a/.teleport.accesslist.v1.ListAccessListsResponse\"\x03\x88\x02\x01\x12x\n" + diff --git a/api/proto/teleport/accesslist/v1/accesslist_service.proto b/api/proto/teleport/accesslist/v1/accesslist_service.proto index 55682c0ffa88d..e1c46d166e117 100644 --- a/api/proto/teleport/accesslist/v1/accesslist_service.proto +++ b/api/proto/teleport/accesslist/v1/accesslist_service.proto @@ -528,4 +528,6 @@ message ListUserAccessListsResponse { repeated AccessList access_lists = 1; // next_page_token is the next page token. string next_page_token = 2; + // total_count is the total number of access lists in all pages. + int32 total_count = 3; } diff --git a/web/packages/teleport/src/Users/UserDetails/UserDetails.story.tsx b/web/packages/teleport/src/Users/UserDetails/UserDetails.story.tsx new file mode 100644 index 0000000000000..fe5f487e5d2d2 --- /dev/null +++ b/web/packages/teleport/src/Users/UserDetails/UserDetails.story.tsx @@ -0,0 +1,272 @@ +/** + * Teleport + * Copyright (C) 2025 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 { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; + +import { SlidingSidePanel } from 'shared/components/SlidingSidePanel'; +import { InfoGuideContainer } from 'shared/components/SlidingSidePanel/InfoGuide'; + +import { ContextProvider } from 'teleport'; +import cfg from 'teleport/config'; +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { User } from 'teleport/services/user'; + +import { + UserDetails, + UserDetailsAuthType, + UserDetailsTitle, +} from './UserDetails'; + +export type UserDetailsStoryProps = { + userType: UserDetailsAuthType; + isBot: boolean; + userName: string; + rolesCount: number; + traitsCount: number; +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +const meta: Meta = { + title: 'Teleport/Users/UserDetails', + component: Story, + excludeStories: ['createMockUser'], + beforeEach: () => { + queryClient.clear(); + }, + decorators: [ + Story => { + const ctx = createTeleportContext(); + cfg.proxyCluster = 'localhost'; + + return ( + + + + + + ); + }, + ], + argTypes: { + userType: { + control: { type: 'select' }, + options: ['local', 'github', 'saml', 'oidc', 'okta', 'scim'], + }, + isBot: { + control: { type: 'boolean' }, + }, + userName: { + control: { type: 'text' }, + }, + rolesCount: { + control: { type: 'select' }, + options: [0, 5, 16, 128], + }, + traitsCount: { + control: { type: 'select' }, + options: [0, 5, 16, 128], + }, + }, + args: { + userType: 'local' as const, + isBot: false, + userName: 'john.the.user', + rolesCount: 16, + traitsCount: 5, + }, +}; + +export default meta; + +type Story = StoryObj; + +function generateRoles(count: number) { + if (!count || count < 0) return []; + + const adjectives = [ + 'readonly', + 'enterprise', + 'dev', + 'jit', + 'admin', + 'system', + 'remote', + 'staging', + 'prod', + 'temp', + ]; + const roleNouns = [ + 'auditor', + 'editor', + 'operator', + 'viewer', + 'manager', + 'analyst', + 'developer', + 'security', + 'support', + 'backup', + ]; + const baseRoles = ['access', 'auditor', 'editor']; + + const generateRole = (index: number) => { + const adjIndex = index % adjectives.length; + const nounIndex = index % roleNouns.length; + return `${adjectives[adjIndex]}-${roleNouns[nounIndex]}`; + }; + + return [ + ...baseRoles.slice(0, Math.min(count, baseRoles.length)), + ...Array.from({ length: Math.max(0, count - baseRoles.length) }, (_, i) => + generateRole(i) + ), + ]; +} + +function generateTraits(count: number) { + if (!count || count < 0) return undefined; + + const traitKeys = [ + 'logins', + 'databaseUsers', + 'databaseNames', + 'kubeUsers', + 'kubeGroups', + 'windowsLogins', + 'awsRoleArns', + ]; + + const traits: Record = {}; + const selectedKeys = traitKeys.slice(0, Math.min(count, traitKeys.length)); + + selectedKeys.forEach(key => { + const valueCount = 1 + Math.floor(Math.random() * 3); + traits[key] = Array.from( + { length: valueCount }, + (_, i) => `${key}-${i + 1}` + ); + }); + + return traits; +} + +function getUserConfig(userType: UserDetailsAuthType | 'local') { + switch (userType) { + case 'local': + return { + authType: 'local', + origin: undefined, + isLocal: true, + }; + case 'github': + return { + authType: 'github', + origin: undefined, + isLocal: false, + }; + case 'saml': + return { + authType: 'saml', + origin: undefined, + isLocal: false, + }; + case 'oidc': + return { + authType: 'oidc', + origin: undefined, + isLocal: false, + }; + case 'okta': + return { + authType: 'saml', + origin: 'okta' as const, + isLocal: false, + }; + case 'scim': + return { + authType: 'saml', + origin: 'scim' as const, + isLocal: false, + }; + default: + return { + authType: 'local', + origin: undefined, + isLocal: true, + }; + } +} + +export function createMockUser(props: UserDetailsStoryProps): User { + const config = getUserConfig(props.userType); + + return { + name: props.userName, + authType: config.authType, + origin: config.origin, + isBot: props.isBot, + isLocal: config.isLocal, + roles: generateRoles(props.rolesCount), + allTraits: generateTraits(props.traitsCount), + }; +} + +function Story(props: UserDetailsStoryProps) { + const user = createMockUser(props); + + return ( + + null} + title={} + > + + + + ); +} + +export const BasicUser: Story = { + parameters: { + msw: { + handlers: [ + http.get('/v2/webapi/sites/:clusterId/locks', () => { + return HttpResponse.json({ + items: [], + }); + }), + ], + }, + }, +}; diff --git a/web/packages/teleport/src/Users/UserDetails/UserDetails.tsx b/web/packages/teleport/src/Users/UserDetails/UserDetails.tsx new file mode 100644 index 0000000000000..41dd6ed2f8302 --- /dev/null +++ b/web/packages/teleport/src/Users/UserDetails/UserDetails.tsx @@ -0,0 +1,313 @@ +/** + * Teleport + * Copyright (C) 2025 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 { Link } from 'react-router-dom'; +import styled from 'styled-components'; + +import { Box, Button, Flex, Label, Text } from 'design'; +import * as Icons from 'design/Icon'; +import { MoreVert } from 'design/Icon'; +import { ResourceIcon, ResourceIconName } from 'design/ResourceIcon'; +import { MenuIcon, MenuItem } from 'shared/components/MenuAction'; +import { + InfoParagraph, + InfoTitle, +} from 'shared/components/SlidingSidePanel/InfoGuide'; + +import cfg from 'teleport/config'; +import { useResourceLock } from 'teleport/lib/locks/useResourceLock'; +import { User } from 'teleport/services/user'; + +import { UserRoles } from './UserRoles'; +import { UserTraits } from './UserTraits'; + +type AuthTypeInfo = { + text: string; + icon: ResourceIconName; +}; + +const authTypeMap: Record = { + github: { text: 'GitHub', icon: 'github' }, + oidc: { text: 'OIDC', icon: 'openid' }, + okta: { text: 'Okta', icon: 'okta' }, + scim: { text: 'SCIM', icon: 'scim' }, + saml: { text: 'SAML', icon: 'application' }, +}; + +export type UserDetailsAuthType = keyof typeof authTypeMap; + +export interface UserDetailsSectionProps { + user: User; + onEdit?: () => void; +} + +export interface UserDetailsProps { + user: User; + sections?: React.ComponentType[]; + onEdit?: () => void; +} + +export function UserDetails({ + user, + sections = [UserRoles, UserTraits], + onEdit, +}: UserDetailsProps) { + if (!user) return null; + + const { isLocked, isLoading: isLoadingLocks } = useResourceLock({ + targetKind: 'user', + targetName: user.name, + }); + + return ( + + + User details + + + + Username + {user.name} + + + Auth Type + + {renderAuthType(user).text} + + + + Status + + + + + {isLoadingLocks + ? 'Unknown' + : isLocked + ? 'Locked' + : 'Active'} + + + {isLocked && ( + + View Locks + + + )} + + + + + + + {sections.map((SectionComponent, index) => ( + + + + ))} + + ); +} + +const UserDetailsActions = ({ + user, + onEdit, + onReset, + onDelete, +}: { + user: User; + onEdit?: () => void; + onReset?: () => void; + onDelete?: () => void; +}) => { + if (!(onEdit || onDelete)) { + return null; + } + + if (user.isBot || !user.isLocal) { + return null; + } + + return ( + + {onEdit && Edit} + {onReset && Reset Authentication} + {onDelete && Delete} + + ); +}; + +export interface UserDetailsTitleProps { + user: User; + onEdit?: () => void; + onReset?: () => void; + onDelete?: () => void; + panelWidth?: number; +} + +export function UserDetailsTitle({ + user, + onEdit, + onReset, + onDelete, + panelWidth = 480, +}: UserDetailsTitleProps) { + const { text: authType, icon } = renderAuthType(user); + + // needed to fill InfoGuidePanel for UserDetailsActions + const containerWidth = panelWidth - 80; + const userIconSize = 48; + + return ( + + + {user.isBot ? ( + + ) : ( + + )} + + + {user.name} + + + + + {authType} + {user.isBot ? ' (Bot)' : ''} + + + + + + + ); +} + +function renderAuthType(user: User): AuthTypeInfo { + const key = + user.authType === 'saml' && user.origin ? user.origin : user.authType; + return authTypeMap[key] || { text: user.authType, icon: 'server' }; +} + +export const SectionTitle = styled(InfoTitle)` + min-height: ${props => props.theme.space[4]}px; +`; + +export const SectionParagraph = InfoParagraph; + +export const UserDetailsSection = styled(Box)` + border-bottom: 1px solid + ${props => props.theme.colors.interactive.tonal.neutral[0]}; + margin: -${props => props.theme.space[3]}px; + padding: ${props => props.theme.space[3]}px; + min-height: 116px; + + &:last-child { + border-bottom: none; + padding-bottom: 0; + } +`; + +export const UserDetailsGrid = styled.div` + display: grid; + grid-template-columns: 3fr 2fr; + gap: 12px; +`; + +export const UserDetailField = styled.div` + display: flex; + flex-direction: column; + gap: ${props => props.theme.space[1]}px; +`; + +export const StyledInfoParagraph = styled(InfoParagraph)` + border-bottom: 1px solid ${props => props.theme.colors.levels.sunken}; + + margin: -${props => props.theme.space[3]}px; + padding: ${props => props.theme.space[3]}px; + + &:last-child { + border-bottom: none; + padding-bottom: 0; + } +`; + +export const ClickableLabel = styled(Label)` + cursor: pointer; + background: ${props => props.theme.colors.interactive.tonal.informational[0]}; + color: ${props => props.theme.colors.text.slightlyMuted}; + + &:hover { + background: ${props => + props.theme.colors.interactive.tonal.informational[1]}; + color: ${props => props.theme.colors.text.main}; + } +`; + +export const ExpandableContainer = styled.div<{ isExpanded: boolean }>` + ${props => + props.isExpanded && + ` + max-height: 200px; + overflow-y: auto; + `} +`; diff --git a/web/packages/teleport/src/Users/UserDetails/UserRoles.tsx b/web/packages/teleport/src/Users/UserDetails/UserRoles.tsx new file mode 100644 index 0000000000000..0a0aeb3235864 --- /dev/null +++ b/web/packages/teleport/src/Users/UserDetails/UserRoles.tsx @@ -0,0 +1,99 @@ +/** + * Teleport + * Copyright (C) 2025 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 { useState } from 'react'; + +import { Button, Flex, Label, Text } from 'design'; +import * as Icons from 'design/Icon'; + +import { + ClickableLabel, + ExpandableContainer, + SectionParagraph, + SectionTitle, + type UserDetailsSectionProps, +} from './UserDetails'; + +export function UserRoles({ user, onEdit }: UserDetailsSectionProps) { + const [isExpanded, setIsExpanded] = useState(false); + + const roles = user.roles || []; + const initialItemCount = 7; + const rolesToShow = isExpanded ? roles : roles.slice(0, initialItemCount); + const hasMoreRoles = roles.length > initialItemCount; + + return ( + <> + + + Roles ({roles.length}) + {onEdit && ( + + + Edit + + )} + + + + {roles.length === 0 ? ( + No roles assigned. + ) : ( + <> + + + {rolesToShow.map(role => ( + + + + {role} + + + ))} + {hasMoreRoles && !isExpanded && ( + setIsExpanded(!isExpanded)} + > + + {roles.length - initialItemCount} more + + )} + + + {hasMoreRoles && isExpanded && ( + + setIsExpanded(!isExpanded)} + > + Show less + + + )} + > + )} + + > + ); +} diff --git a/web/packages/teleport/src/Users/UserDetails/UserTraits.tsx b/web/packages/teleport/src/Users/UserDetails/UserTraits.tsx new file mode 100644 index 0000000000000..09c1baf8a88fa --- /dev/null +++ b/web/packages/teleport/src/Users/UserDetails/UserTraits.tsx @@ -0,0 +1,70 @@ +/** + * Teleport + * Copyright (C) 2025 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 { Button, Flex, Label, Text } from 'design'; +import * as Icons from 'design/Icon'; + +import { + SectionParagraph, + SectionTitle, + type UserDetailsSectionProps, +} from './UserDetails'; + +export function UserTraits({ user, onEdit }: UserDetailsSectionProps) { + const allTraits = user.allTraits || []; + const traitsWithValues = Object.keys(allTraits).filter( + key => + user.allTraits[key].length > 0 && + user.allTraits[key].some(value => value.trim() !== '') + ); + + return ( + <> + + + Traits + {onEdit && ( + + + Edit + + )} + + + + {traitsWithValues.length === 0 ? ( + No traits assigned. + ) : ( + + {traitsWithValues.map(key => ( + + {key}: {user.allTraits[key].join(', ')} + + ))} + + )} + + > + ); +} diff --git a/web/packages/teleport/src/Users/UserDetails/index.ts b/web/packages/teleport/src/Users/UserDetails/index.ts new file mode 100644 index 0000000000000..be757986b4f91 --- /dev/null +++ b/web/packages/teleport/src/Users/UserDetails/index.ts @@ -0,0 +1,27 @@ +/** + * Teleport + * Copyright (C) 2025 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 . + */ + +export { + UserDetails, + type UserDetailsProps, + UserDetailsTitle, + type UserDetailsSectionProps, +} from './UserDetails'; + +export { UserRoles } from './UserRoles'; +export { UserTraits } from './UserTraits'; diff --git a/web/packages/teleport/src/Users/UserList/UserList.tsx b/web/packages/teleport/src/Users/UserList/UserList.tsx index ce0f846b95fa2..6fcfbd246e5df 100644 --- a/web/packages/teleport/src/Users/UserList/UserList.tsx +++ b/web/packages/teleport/src/Users/UserList/UserList.tsx @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +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'; @@ -27,11 +29,14 @@ export default function UserList({ onEdit, onDelete, onReset, + onUserClick, onSearchChange, search, serversidePagination, usersAcl, + selectedUser, }: Props) { + const theme = useTheme(); const canEdit = usersAcl.edit; const canDelete = usersAcl.remove; @@ -56,6 +61,17 @@ export default function UserList({ /> ), }} + row={{ + onClick: onUserClick, + getStyle: (user: User) => { + if (selectedUser?.name === user.name) { + return { + backgroundColor: theme.colors.interactive.tonal.primary[0], + }; + } + return { cursor: 'pointer' }; + }, + }} columns={[ { key: 'name', @@ -162,8 +178,10 @@ type Props = { onEdit(user: User): void; onDelete(user: User): void; onReset(user: User): void; + onUserClick(user: User): void; onSearchChange(search: string): void; search: string; serversidePagination: SeversidePagination; usersAcl: Access; + selectedUser?: User | null; }; diff --git a/web/packages/teleport/src/Users/Users.story.tsx b/web/packages/teleport/src/Users/Users.story.tsx index 77c6b9ee6c93a..e12be8bdfd8de 100644 --- a/web/packages/teleport/src/Users/Users.story.tsx +++ b/web/packages/teleport/src/Users/Users.story.tsx @@ -172,6 +172,7 @@ const sample = { InviteCollaborators: null, onEmailPasswordResetClose: () => null, EmailPasswordReset: null, + UserDetails: null, showMauInfo: false, onDismissUsersMauNotice: () => null, canEditUsers: true, diff --git a/web/packages/teleport/src/Users/Users.test.tsx b/web/packages/teleport/src/Users/Users.test.tsx index ac45d4d4d30b2..0774fc1c0c513 100644 --- a/web/packages/teleport/src/Users/Users.test.tsx +++ b/web/packages/teleport/src/Users/Users.test.tsx @@ -30,6 +30,8 @@ import { import { InfoGuidePanelProvider } from 'shared/components/SlidingSidePanel/InfoGuide'; import { ContextProvider } from 'teleport'; +import { InfoGuideSidePanel } from 'teleport/components/SlidingSidePanel/InfoGuideSidePanel'; +import * as Main from 'teleport/Main/Main'; import { createTeleportContext } from 'teleport/mocks/contexts'; import { Access } from 'teleport/services/user'; import { successGetUsersV2 } from 'teleport/test/helpers/users'; @@ -60,6 +62,8 @@ describe('invite collaborators integration', () => { let props: State; beforeEach(() => { + jest.spyOn(Main, 'useNoMinWidth').mockReturnValue(); + props = { operation: { type: 'invite-collaborators' }, fetch: ctx.userService.fetchUsersV2, @@ -75,6 +79,7 @@ describe('invite collaborators integration', () => { inviteCollaboratorsOpen: false, onEmailPasswordResetClose: () => undefined, EmailPasswordReset: null, + UserDetails: null, showMauInfo: false, onDismissUsersMauNotice: () => null, usersAcl: defaultAcl, @@ -168,6 +173,7 @@ test('Users not equal to MAU Notice', async () => { inviteCollaboratorsOpen: false, onEmailPasswordResetClose: () => undefined, EmailPasswordReset: null, + UserDetails: null, showMauInfo: true, onDismissUsersMauNotice: jest.fn(), usersAcl: defaultAcl, @@ -221,6 +227,7 @@ describe('email password reset integration', () => { inviteCollaboratorsOpen: false, onEmailPasswordResetClose: () => undefined, EmailPasswordReset: null, + UserDetails: null, showMauInfo: false, onDismissUsersMauNotice: () => null, usersAcl: defaultAcl, @@ -276,6 +283,7 @@ describe('permission handling', () => { inviteCollaboratorsOpen: false, onEmailPasswordResetClose: () => undefined, EmailPasswordReset: null, + UserDetails: null, showMauInfo: false, onDismissUsersMauNotice: () => null, usersAcl: defaultAcl, @@ -405,4 +413,46 @@ describe('permission handling', () => { menuItems.every(item => item.textContent.includes('Delete')) ).not.toBe(true); }); + + test('users detail panel opens when clicking on a row', async () => { + const testProps = { + ...props, + usersAcl: { + read: true, + list: true, + edit: true, + create: true, + remove: false, + }, + UserDetails: ({ user }) => ( + + Test Details: {user.name} + + ), + }; + + render( + + + + + + + + + ); + + await screen.findByPlaceholderText('Search...'); + const userRow = screen.getByText('tester'); + expect(userRow).toBeInTheDocument(); + expect(screen.queryByTestId('user-details-panel')).not.toBeInTheDocument(); + + const user = userEvent.setup(); + await user.click(userRow); + + await waitFor(() => { + expect(screen.getByTestId('user-details-panel')).toBeInTheDocument(); + }); + expect(screen.getByText('Test Details: tester')).toBeInTheDocument(); + }); }); diff --git a/web/packages/teleport/src/Users/Users.tsx b/web/packages/teleport/src/Users/Users.tsx index 6a35c749630bc..d65f623081a4a 100644 --- a/web/packages/teleport/src/Users/Users.tsx +++ b/web/packages/teleport/src/Users/Users.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { Link as InternalLink } from 'react-router-dom'; import { Alert, Box, Button, Link as ExternalLink, Flex, Text } from 'design'; @@ -27,7 +27,9 @@ import { InfoParagraph, InfoUl, ReferenceLinks, + useInfoGuide, } from 'shared/components/SlidingSidePanel/InfoGuide'; +import { useEscape } from 'shared/hooks/useEscape'; import { useServerSidePagination } from 'teleport/components/hooks'; import { @@ -36,10 +38,13 @@ import { FeatureHeaderTitle, } from 'teleport/components/Layout'; import cfg from 'teleport/config'; +import { useNoMinWidth } from 'teleport/Main'; import { User } from 'teleport/services/user'; +import { useUrlParams } from './state'; import { UserAddEdit } from './UserAddEdit'; import { UserDelete } from './UserDelete'; +import { UserDetailsTitle } from './UserDetails'; import UserList from './UserList'; import UserReset from './UserReset'; import useUsers, { State, UsersContainerProps } from './useUsers'; @@ -66,12 +71,15 @@ export function Users(props: State) { inviteCollaboratorsOpen, InviteCollaborators, EmailPasswordReset, + UserDetails, onEmailPasswordResetClose, fetch, } = props; - const [search, setSearch] = useState(''); + const [params, setParams] = useUrlParams(); const abortControllerRef = useRef(null); + const { setInfoGuideConfig, infoGuideConfig } = useInfoGuide(); + const detailsPanelWidth = 480; const serverSidePagination = useServerSidePagination({ pageSize: 20, @@ -83,16 +91,18 @@ export function Users(props: State) { return { agents: items || [], startKey }; }, clusterId: '', - params: { search }, + params: { search: params.search }, }); + useNoMinWidth(); + useEffect(() => { // Cancel previous request and create new controller abortControllerRef.current?.abort(); abortControllerRef.current = new AbortController(); serverSidePagination.fetch(); - }, [search]); + }, [params.search]); // Cleanup controller on unmount useEffect(() => { @@ -101,6 +111,70 @@ export function Users(props: State) { }; }, []); + const isSuccess = serverSidePagination.attempt.status === 'success'; + const userData = serverSidePagination.fetchedData.agents; + + // fetch user from username + const user = useMemo(() => { + if (isSuccess && params.user) { + return userData?.find(u => u.name === params.user) || null; + } + }, [params.user, userData, isSuccess]); + + // this effect will open the user details panel if the selected user is found + // in the pagination results, otherwise it will close the panel and clear the + // user URL param + useEffect(() => { + if (params.user && user) { + const botOrExternal = user.isBot || !user.isLocal; + + const onEdit = + usersAcl.edit && !botOrExternal ? () => onStartEdit(user) : undefined; + + const onReset = usersAcl.edit ? () => onStartReset(user) : undefined; + + const onDelete = usersAcl.remove ? () => onStartDelete(user) : undefined; + + setInfoGuideConfig({ + id: user.name, + guide: , + title: ( + + ), + panelWidth: detailsPanelWidth, + }); + } else { + const userNotFound = params.user && isSuccess && !user; + + if (userNotFound) { + setCurrentUser(null); + } + setInfoGuideConfig(null); + } + }, [user]); + + // detect if panel was closed by user interaction (i.e. clicking x) + // and clear current user + useEffect(() => { + if (!infoGuideConfig && user && params.user) { + setCurrentUser(null); + } + }, [infoGuideConfig]); + + useEscape(() => { + setCurrentUser(null); + }); + + const setCurrentUser = (user: User | null) => { + setParams({ ...params, user: user?.name || null }); + }; + const requiredPermissions = Object.entries(usersAcl) .map(([key, value]) => { if (key === 'edit') { @@ -221,12 +295,14 @@ export function Users(props: State) { )} setParams({ ...params, search: search })} + search={params.search} onEdit={onStartEdit} onDelete={onStartDelete} onReset={onStartReset} + onUserClick={setCurrentUser} usersAcl={usersAcl} + selectedUser={user} /> {(operation.type === 'create' || operation.type === 'edit') && ( . + */ + +import { act, renderHook } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import type { PropsWithChildren } from 'react'; +import { Router } from 'react-router'; + +import { + searchParamsToState, + stateToSearchParams, + useUrlParams, + type UsersUrlState, +} from './state'; + +describe('searchParamsToState', () => { + it('returns default state when no params provided', () => { + const params = new URLSearchParams(); + const state = searchParamsToState(params); + + expect(state).toEqual({ + search: '', + user: null, + }); + }); + + it('parses parameters', () => { + const params = new URLSearchParams('?search=admin&user=bob'); + const state = searchParamsToState(params); + + expect(state.search).toBe('admin'); + expect(state.user).toBe('bob'); + }); +}); + +describe('stateToSearchParams', () => { + it('returns empty params for default state', () => { + const state: UsersUrlState = { + search: '', + user: null, + }; + + const params = stateToSearchParams(state); + expect(params).toBe(''); + }); + + it('combines all parameters correctly', () => { + const state: UsersUrlState = { + search: 'test', + user: 'alice@company.com', + }; + + const params = stateToSearchParams(state); + const urlParams = new URLSearchParams(params); + + expect(urlParams.get('search')).toBe('test'); + expect(urlParams.get('user')).toBe('alice@company.com'); + }); +}); + +describe('useUrlParams', () => { + it('initializes params from URL search params', () => { + const history = createMemoryHistory({ + initialEntries: ['/users?search=test&user=alice@company.com'], + }); + + function wrapper({ children }: PropsWithChildren) { + return {children}; + } + + const { result } = renderHook(() => useUrlParams(), { + wrapper, + }); + + const [params] = result.current; + + expect(params.search).toBe('test'); + expect(params.user).toBe('alice@company.com'); + }); + + it('updates URL when state changes', () => { + const history = createMemoryHistory(); + + function wrapper({ children }: PropsWithChildren) { + return {children}; + } + + const { result } = renderHook(() => useUrlParams(), { + wrapper, + }); + + const [, setState] = result.current; + + act(() => { + setState({ + search: 'new search', + user: 'selected-user', + }); + }); + + expect(history.location.search).toContain('search=new+search'); + expect(history.location.search).toContain('user=selected-user'); + }); +}); diff --git a/web/packages/teleport/src/Users/state.ts b/web/packages/teleport/src/Users/state.ts new file mode 100644 index 0000000000000..711b5aaa11afa --- /dev/null +++ b/web/packages/teleport/src/Users/state.ts @@ -0,0 +1,78 @@ +/** + * Teleport + * Copyright (C) 2025 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 { useCallback, useMemo } from 'react'; +import { useHistory, useLocation } from 'react-router'; + +export interface UsersUrlState { + search: string; + user: string | null; +} + +export function searchParamsToState(params: URLSearchParams): UsersUrlState { + const state: UsersUrlState = { + search: params.get('search') ?? '', + user: params.get('user') ?? null, + }; + + return state; +} + +export function stateToSearchParams(state: UsersUrlState): string { + const urlParams = new URLSearchParams(); + + if (state.search) { + urlParams.set('search', state.search); + } + + if (state.user) { + urlParams.set('user', state.user); + } + + return urlParams.toString(); +} + +export function useUrlParams(): [ + UsersUrlState, + (newState: Partial) => void, +] { + const history = useHistory(); + const location = useLocation(); + + const params = useMemo(() => { + return searchParamsToState(new URLSearchParams(location.search)); + }, [location.search]); + + const setParams = useCallback( + (next: UsersUrlState) => { + const current = searchParamsToState(new URLSearchParams(location.search)); + + const hasChanged = + current.search !== next.search || current.user !== next.user; + + if (hasChanged) { + const nextParams = stateToSearchParams(next); + const nextSearch = nextParams ? `?${nextParams}` : ''; + history.push({ search: nextSearch }); + } + }, + [location.search, history] + ); + + return [params, setParams]; +} diff --git a/web/packages/teleport/src/Users/useUsers.ts b/web/packages/teleport/src/Users/useUsers.ts index c6f2cb47dcc3d..2c4157cf7b0ed 100644 --- a/web/packages/teleport/src/Users/useUsers.ts +++ b/web/packages/teleport/src/Users/useUsers.ts @@ -23,9 +23,15 @@ import { storageService } from 'teleport/services/storageService'; import { User } from 'teleport/services/user'; import useTeleport from 'teleport/useTeleport'; +import { + UserDetails as DefaultUserDetails, + UserDetailsProps, +} from './UserDetails/UserDetails'; + export default function useUsers({ InviteCollaborators, EmailPasswordReset, + UserDetails = DefaultUserDetails, }: UsersContainerProps) { const ctx = useTeleport(); const [operation, setOperation] = useState({ @@ -108,6 +114,7 @@ export default function useUsers({ inviteCollaboratorsOpen, onEmailPasswordResetClose, EmailPasswordReset, + UserDetails, showMauInfo, onDismissUsersMauNotice, fetch, @@ -140,10 +147,12 @@ type InviteCollaboratorsElement = ( type EmailPasswordResetElement = ( props: EmailPasswordResetDialogProps ) => ReactElement; +type UserDetailsElement = (props: UserDetailsProps) => ReactElement; export type UsersContainerProps = { InviteCollaborators?: InviteCollaboratorsElement; EmailPasswordReset?: EmailPasswordResetElement; + UserDetails?: UserDetailsElement; }; export type State = ReturnType;