diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca081480f2df7..5f8bbd5cfcef3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,6 +344,9 @@ importers: '@opentelemetry/semantic-conventions': specifier: 1.27.0 version: 1.27.0 + '@tanstack/react-query': + specifier: ^5.56.2 + version: 5.56.2(react@18.3.1) '@xterm/addon-canvas': specifier: ^0.7.0 version: 0.7.0(@xterm/xterm@5.5.0) @@ -2264,6 +2267,14 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@tanstack/query-core@5.56.2': + resolution: {integrity: sha512-gor0RI3/R5rVV3gXfddh1MM+hgl0Z4G7tj6Xxpq6p2I03NGPaJ8dITY9Gz05zYYb/EJq9vPas/T4wn9EaDPd4Q==} + + '@tanstack/react-query@5.56.2': + resolution: {integrity: sha512-SR0GzHVo6yzhN72pnRhkEFRAHMsUo5ZPzAxfTMvUxFIDVS6W9LYUp6nXW3fcHVdg0ZJl8opSH85jqahvm6DSVg==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.1.0': resolution: {integrity: sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA==} engines: {node: '>=18'} @@ -9229,6 +9240,13 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@tanstack/query-core@5.56.2': {} + + '@tanstack/react-query@5.56.2(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.56.2 + react: 18.3.1 + '@testing-library/dom@10.1.0': dependencies: '@babel/code-frame': 7.24.7 diff --git a/web/packages/design/src/Box/Box.tsx b/web/packages/design/src/Box/Box.tsx index b2cab7695a493..86507144467d8 100644 --- a/web/packages/design/src/Box/Box.tsx +++ b/web/packages/design/src/Box/Box.tsx @@ -32,6 +32,8 @@ import { HeightProps, justifySelf, JustifySelfProps, + fontSize, + FontSizeProps, lineHeight, LineHeightProps, maxHeight, @@ -65,6 +67,7 @@ export interface BoxProps TextAlignProps, FlexProps, AlignSelfProps, + FontSizeProps, JustifySelfProps, BorderProps, BordersProps, @@ -87,6 +90,7 @@ const Box = styled.div` ${justifySelf} ${borders} ${overflow} + ${fontSize} `; Box.displayName = 'Box'; diff --git a/web/packages/teleport/package.json b/web/packages/teleport/package.json index dc132c480b1b9..4a689951b1809 100644 --- a/web/packages/teleport/package.json +++ b/web/packages/teleport/package.json @@ -32,6 +32,7 @@ "@opentelemetry/sdk-trace-base": "1.26.0", "@opentelemetry/sdk-trace-web": "1.26.0", "@opentelemetry/semantic-conventions": "1.27.0", + "@tanstack/react-query": "^5.56.2", "@xterm/xterm": "^5.5.0", "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.10.0", diff --git a/web/packages/teleport/src/Teleport.tsx b/web/packages/teleport/src/Teleport.tsx index 83158e8e70062..06e3ff99969ee 100644 --- a/web/packages/teleport/src/Teleport.tsx +++ b/web/packages/teleport/src/Teleport.tsx @@ -19,6 +19,8 @@ import React, { Suspense, useEffect } from 'react'; import ThemeProvider, { updateFavicon } from 'design/ThemeProvider'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + import { Route, Router, Switch } from 'teleport/components/Router'; import { CatchError } from 'teleport/components/CatchError'; import Authenticated from 'teleport/components/Authenticated'; @@ -51,6 +53,15 @@ import { Main } from './Main'; import type { History } from 'history'; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + const Teleport: React.FC = props => { const { ctx, history } = props; const createPublicRoutes = props.renderPublicRoutes || publicOSSRoutes; @@ -81,34 +92,36 @@ const Teleport: React.FC = props => { }, []); return ( - - - - - - - {createPublicRoutes()} - - - - - - - {createPrivateRoutes()} - - - - - - - - - - - + + + + + + + + {createPublicRoutes()} + + + + + + + {createPrivateRoutes()} + + + + + + + + + + + + ); }; 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..20e8bb78de830 --- /dev/null +++ b/web/packages/teleport/src/Users/UserDetails/UserDetails.tsx @@ -0,0 +1,253 @@ +import { Link, NavLink, useParams } from 'react-router-dom'; + +import { ButtonSecondary, Flex, H1, H3 } from 'design'; +import { ArrowBack } from 'design/Icon'; + +import { useQuery } from '@tanstack/react-query'; +import styled, { useTheme } from 'styled-components'; +import React, { useEffect, useRef } from 'react'; +import Box from 'design/Box'; +import { AccessPath } from 'e-teleport/AccessGraph/AccessPath'; + +import api from 'teleport/services/api'; +import { FeatureHeaderTitle } from 'teleport/components/Layout'; +import cfg from 'teleport/config'; + +interface User { + name: string; + roles: string[]; + authType: string; + allTraits: AllTraits; + origin: string; + isBot: boolean; + traits: Traits; +} + +interface AllTraits { + db_users: string[]; + logins: string[]; +} + +interface Traits { + logins: string[]; + databaseUsers: string[]; +} + +const TabsContainer = styled.div` + position: relative; + display: flex; + gap: ${p => p.theme.space[5]}px; + align-items: center; + padding: 0 ${p => p.theme.space[5]}px; + border-bottom: 1px solid ${p => p.theme.colors.spotBackground[0]}; +`; + +const TabContainer = styled(NavLink)<{ selected?: boolean }>` + padding: ${p => p.theme.space[1] + p.theme.space[2]}px + ${p => p.theme.space[2]}px; + position: relative; + cursor: pointer; + z-index: 2; + opacity: ${p => (p.selected ? 1 : 0.5)}; + transition: opacity 0.3s linear; + color: ${p => p.theme.colors.text.main}; + font-weight: 300; + font-size: 16px; + line-height: ${p => p.theme.space[5]}px; + white-space: nowrap; + text-decoration: none; + + &:hover { + opacity: 1; + } +`; + +const TabBorder = styled.div` + position: absolute; + bottom: -1px; + background: ${p => p.theme.colors.brand}; + height: 2px; + transition: all 0.3s cubic-bezier(0.19, 1, 0.22, 1); +`; + +enum Tab { + Audit, + AccessPath, +} + +function getUser(username: string, signal?: AbortSignal): Promise { + return api.get(`/v1/webapi/users/${username}`, signal); +} + +export function UserDetails() { + const { username } = useParams<{ username: string; tab?: string }>(); + + const theme = useTheme(); + + const query = useQuery({ + queryKey: ['user', username], + queryFn: ({ signal }) => getUser(username, signal), + }); + + const borderRef = useRef(null); + const parentRef = useRef(); + + const activeTab = Tab.AccessPath; + + useEffect(() => { + if (!parentRef.current || !borderRef.current) { + return; + } + + const activeElement = parentRef.current.querySelector( + `[data-tab-id="${activeTab}"]` + ); + + if (activeElement) { + const parentBounds = parentRef.current.getBoundingClientRect(); + const activeBounds = activeElement.getBoundingClientRect(); + + const left = activeBounds.left - parentBounds.left; + const width = activeBounds.width; + + borderRef.current.style.left = `${left}px`; + borderRef.current.style.width = `${width}px`; + } + }, [activeTab]); + + let content; + if (activeTab === Tab.AccessPath) { + content = ( + + + + ); + } + + return ( + + + + + +

{username}

+ + + Local User + +
+
+ + + Edit + + Reset Authentication + +
+ + + +

Roles

+ + + {query.data?.roles?.map(role => ( + + {role} + + ))} + +
+ {query.data?.traits?.logins?.length > 0 && ( + +

Logins

+ + + {query.data?.traits.logins.map(role => ( + + {role} + + ))} + +
+ )} + {query.data?.traits?.databaseUsers?.length > 0 && ( + +

Database Users

+ + + {query.data?.traits.databaseUsers.map(role => ( + + {role} + + ))} + +
+ )} +
+ + + + Access Path + + + + + + {content} +
+ ); +} diff --git a/web/packages/teleport/src/Users/UserList/UserList.tsx b/web/packages/teleport/src/Users/UserList/UserList.tsx index 53861cd969245..960fed8fef6c7 100644 --- a/web/packages/teleport/src/Users/UserList/UserList.tsx +++ b/web/packages/teleport/src/Users/UserList/UserList.tsx @@ -20,6 +20,8 @@ import React from 'react'; import { Cell, LabelCell } from 'design/DataTable'; import { MenuButton, MenuItem } from 'shared/components/MenuAction'; +import { useHistory } from 'react-router'; + import { User, UserOrigin } from 'teleport/services/user'; import { ClientSearcheableTableWithQueryParamSupport } from 'teleport/components/ClientSearcheableTableWithQueryParamSupport'; @@ -30,9 +32,23 @@ export default function UserList({ onDelete, onReset, }: Props) { + const history = useHistory(); + + function handleRowClick(row: User) { + history.push(`/web/users/${row.name}`); + } + return ( ; } +export function UserRoute() { + return ( + + + + + ); +} + export function Users(props: State) { const { attempt, diff --git a/web/packages/teleport/src/Users/index.ts b/web/packages/teleport/src/Users/index.ts index 8174b5db7cf9a..e52fd4ebf8eae 100644 --- a/web/packages/teleport/src/Users/index.ts +++ b/web/packages/teleport/src/Users/index.ts @@ -16,4 +16,4 @@ * along with this program. If not, see . */ -export { UsersContainer as Users } from './Users'; +export { UsersContainer as Users, UserRoute } from './Users'; diff --git a/web/packages/teleport/src/components/Layout/Layout.tsx b/web/packages/teleport/src/components/Layout/Layout.tsx index 7ba7e541bd49a..192805445c3f6 100644 --- a/web/packages/teleport/src/components/Layout/Layout.tsx +++ b/web/packages/teleport/src/components/Layout/Layout.tsx @@ -47,7 +47,7 @@ const FeatureHeaderTitle = styled(H1)` /** * Feature Box (container) */ -const FeatureBox = styled(Flex)` +const FeatureBox = styled(Flex)<{ fullScreen?: boolean }>` width: 100%; height: 100%; flex-direction: column; @@ -61,7 +61,7 @@ const FeatureBox = styled(Flex)` */ &::after { content: ' '; - padding-bottom: 24px; + padding-bottom: ${p => (p.fullScreen ? 0 : 24)}px; } /* Allow overriding padding settings. */ diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 217b48e52b7a8..c5b42bf76474c 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -159,6 +159,7 @@ const cfg = { desktops: '/web/cluster/:clusterId/desktops', desktop: '/web/cluster/:clusterId/desktops/:desktopName/:username', users: '/web/users', + userDetails: '/web/users/:username/:tab?', bots: '/web/bots', botsNew: '/web/bots/new/:type?', console: '/web/cluster/:clusterId/console', diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index 61c6dffe7eee7..1ae2a0126cfd5 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -56,7 +56,7 @@ import { AccountPage } from './Account'; import { Support } from './Support'; import { Clusters } from './Clusters'; import { TrustedClusters } from './TrustedClusters'; -import { Users } from './Users'; +import { UserRoute } from './Users'; import { RolesContainer as Roles } from './Roles'; import { DeviceTrustLocked } from './DeviceTrust'; import { RecordingsContainer as Recordings } from './Recordings'; @@ -188,8 +188,8 @@ export class FeatureUsers implements TeleportFeature { route = { title: 'Manage Users', path: cfg.routes.users, - exact: true, - component: Users, + exact: false, + component: UserRoute, }; hasAccess(flags: FeatureFlags): boolean { @@ -199,7 +199,7 @@ export class FeatureUsers implements TeleportFeature { navigationItem = { title: NavTitle.Users, icon: UsersIcon, - exact: true, + exact: false, getLink() { return cfg.getUsersRoute(); },