diff --git a/web/packages/design/src/theme/themes/darkTheme.ts b/web/packages/design/src/theme/themes/darkTheme.ts index eb0f044ee51c2..d4158282b3d5f 100644 --- a/web/packages/design/src/theme/themes/darkTheme.ts +++ b/web/packages/design/src/theme/themes/darkTheme.ts @@ -32,7 +32,11 @@ import { lighten } from '../utils/colorManipulator'; import { sharedColors, sharedStyles } from './sharedStyles'; import { DataVisualisationColors, Theme, ThemeColors } from './types'; -const dataVisualisationColors: DataVisualisationColors = { +/** + * Used for the user icon in Connect (the top-right one). + * In both the light and dark mode, the dark version of dataVisualisationColors is used. + */ +export const dataVisualisationColors: DataVisualisationColors = { primary: { purple: '#9F85FF', wednesdays: '#F74DFF', diff --git a/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/ClusterConnectPanel.story.tsx b/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/ClusterConnectPanel.story.tsx new file mode 100644 index 0000000000000..8a4d827e6e4b0 --- /dev/null +++ b/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/ClusterConnectPanel.story.tsx @@ -0,0 +1,83 @@ +/** + * 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 { + makeLoggedInUser, + makeRootCluster, +} from 'teleterm/services/tshd/testHelpers'; +import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; + +import { ClusterConnectPanel } from './ClusterConnectPanel'; + +export default { + title: 'Teleterm/ClusterConnectPanel', +}; + +const profileStatusError = + 'No YubiKey device connected with serial number 14358031. Connect the device and try again.'; +const clusterOrange = makeRootCluster({ + name: 'orange', + loggedInUser: makeLoggedInUser({ + name: 'bob', + roles: ['access', 'editor'], + sshLogins: ['root'], + }), + uri: '/clusters/orange', +}); +const clusterViolet = makeRootCluster({ + name: 'violet', + loggedInUser: makeLoggedInUser({ name: 'sammy' }), + uri: '/clusters/violet', +}); + +export const Empty = () => { + return ( + + + + ); +}; + +export const WithClusters = () => { + const ctx = new MockAppContext(); + ctx.addRootCluster(clusterOrange); + ctx.addRootCluster(clusterViolet); + + return ( + + ; + + ); +}; + +export const WithErrors = () => { + const ctx = new MockAppContext(); + ctx.addRootCluster( + makeRootCluster({ + ...clusterOrange, + profileStatusError, + }) + ); + ctx.addRootCluster(clusterViolet); + return ( + + ; + + ); +}; diff --git a/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/ClusterConnectPanel.tsx b/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/ClusterConnectPanel.tsx index 773f5808cc6e6..b8eda061b186f 100644 --- a/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/ClusterConnectPanel.tsx +++ b/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/ClusterConnectPanel.tsx @@ -16,21 +16,51 @@ * along with this program. If not, see . */ +import { useCallback, useEffect, useRef } from 'react'; import styled from 'styled-components'; -import { Box, ButtonPrimary, Flex, H1, ResourceIcon, Text } from 'design'; +import { + Box, + ButtonPrimary, + Flex, + H1, + H2, + P2, + ResourceIcon, + Text, +} from 'design'; import { useAppContext } from 'teleterm/ui/appContextProvider'; - -import { RecentClusters } from './RecentClusters'; +import { NullKeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation/KeyboardArrowsNavigation'; +import { useStoreSelector } from 'teleterm/ui/hooks/useStoreSelector'; +import { ClusterList } from 'teleterm/ui/TopBar/Identity/IdentityList/IdentityList'; +import { RootClusterUri } from 'teleterm/ui/uri'; export function ClusterConnectPanel() { const ctx = useAppContext(); - - function handleConnect() { + const clusters = useStoreSelector( + 'clustersService', + useCallback(state => state.clusters, []) + ); + const rootClusters = [...clusters.values()].filter(c => !c.leaf); + function add(): void { ctx.commandLauncher.executeCommand('cluster-connect', {}); } + function connect(clusterUri: RootClusterUri): void { + ctx.workspacesService.setActiveWorkspace(clusterUri); + } + + const containerRef = useRef(); + + // Focus the first item. + const hasCluster = !!rootClusters.length; + useEffect(() => { + if (hasCluster) { + containerRef.current.querySelector('li').focus(); + } + }, [hasCluster]); + return ( @@ -41,16 +71,48 @@ export function ClusterConnectPanel() { alignItems="center" > -

Connect a Cluster

- - Connect an existing Teleport cluster
to start using Teleport - Connect. -
- - Connect - + {hasCluster ? ( + +

Clusters

+ + Log in to a cluster to use Teleport Connect. + + {/*Disable arrows navigation, it doesn't work well here,*/} + {/*since it requires the container to be focused.*/} + {/*The user can navigate with Tab.*/} + + p.theme.radii[2]}px; + padding: ${p => p.theme.space[2]}px; + } + `} + > + + + +
+ ) : ( + <> +

Connect a Cluster

+ + Connect an existing Teleport cluster
to start using + Teleport Connect. +
+ + Connect + + + )} -
); diff --git a/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/RecentClusters.tsx b/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/RecentClusters.tsx deleted file mode 100644 index c1b7264633c6d..0000000000000 --- a/web/packages/teleterm/src/ui/TabHost/ClusterConnectPanel/RecentClusters.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 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 { Box, ButtonBorder, Card, Flex, Text } from 'design'; - -import { useAppContext } from 'teleterm/ui/appContextProvider'; -import { ProfileStatusError } from 'teleterm/ui/components/ProfileStatusError'; -import { RootClusterUri } from 'teleterm/ui/uri'; -import { getUserWithClusterName } from 'teleterm/ui/utils'; - -export function RecentClusters() { - const ctx = useAppContext(); - - ctx.clustersService.useState(); - - const rootClusters = ctx.clustersService - .getClusters() - .filter(c => !c.leaf) - .map(cluster => ({ - userWithClusterName: getUserWithClusterName({ - userName: cluster.loggedInUser?.name, - clusterName: cluster.name, - }), - connectionProblemError: cluster.profileStatusError, - uri: cluster.uri, - })); - - function connect(clusterUri: RootClusterUri): void { - ctx.workspacesService.setActiveWorkspace(clusterUri); - } - - if (!rootClusters.length) { - return null; - } - - return ( - - - Recent clusters - - - {rootClusters.map((cluster, index) => ( - - - - {cluster.userWithClusterName} - - {cluster.connectionProblemError && ( - - )} - - connect(cluster.uri)} - title={`Connect to ${cluster.userWithClusterName}`} - > - Connect - - - ))} - - - ); -} diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/EmptyIdentityList/EmptyIdentityList.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/EmptyIdentityList/EmptyIdentityList.tsx deleted file mode 100644 index 1824054ebdfce..0000000000000 --- a/web/packages/teleterm/src/ui/TopBar/Identity/EmptyIdentityList/EmptyIdentityList.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 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 { ButtonPrimary, Flex, ResourceIcon, Text } from 'design'; - -interface EmptyIdentityListProps { - onConnect(): void; -} - -export function EmptyIdentityList(props: EmptyIdentityListProps) { - return ( - - - - No cluster connected - - - Connect - - - ); -} 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 9abacf8a59480..43eb4967ef282 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/Identity.story.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/Identity.story.tsx @@ -16,91 +16,104 @@ * along with this program. If not, see . */ -import { useEffect, useRef } from 'react'; +import { useLayoutEffect } from 'react'; import Flex from 'design/Flex'; import { TrustedDeviceRequirement } from 'gen-proto-ts/teleport/legacy/types/trusted_device_requirement_pb'; +import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; import { makeLoggedInUser, makeRootCluster, } from 'teleterm/services/tshd/testHelpers'; +import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; +import { RootClusterUri } from 'teleterm/ui/uri'; -import { Identity, IdentityHandler, IdentityProps } from './Identity'; -import { IdentityRootCluster } from './useIdentity'; +import { IdentityContainer } from './Identity'; export default { title: 'Teleterm/Identity', }; -const makeTitle = (userWithClusterName: string) => userWithClusterName; +const clusterOrange = makeRootCluster({ + name: 'orange', + loggedInUser: makeLoggedInUser({ + name: 'bob', + roles: ['access', 'editor'], + sshLogins: ['root'], + }), + uri: '/clusters/orange', +}); +const clusterViolet = makeRootCluster({ + name: 'violet', + loggedInUser: makeLoggedInUser({ name: 'sammy' }), + uri: '/clusters/violet', +}); +const clusterGreen = makeRootCluster({ + name: 'green', + loggedInUser: undefined, + uri: '/clusters/green', +}); + const profileStatusError = 'No YubiKey device connected with serial number 14358031. Connect the device and try again.'; -const OpenedIdentity = (props: IdentityProps) => { - const ref = useRef(); - useEffect(() => { - if (ref.current) { - ref.current.togglePopover(); - } - }, [ref.current]); +const OpenIdentityPopover = (props: { + clusters: Cluster[]; + activeClusterUri: RootClusterUri | undefined; +}) => { + const ctx = new MockAppContext(); + props.clusters.forEach(c => { + ctx.addRootCluster(c); + }); + ctx.workspacesService.setState(draftState => { + draftState.rootClusterUri = props.activeClusterUri; + }); + useOpenPopover(); return ( - + + + ); }; +const useOpenPopover = () => { + useLayoutEffect(() => { + const isProfileSelectorOpen = !!document.querySelector( + 'button[title~="logout"i]' + ); + + if (isProfileSelectorOpen) { + return; + } + + const button = document.querySelector( + 'button[title~="profiles"i]' + ) as HTMLButtonElement; + + button?.click(); + }, []); +}; + export function NoRootClusters() { - return ( - Promise.resolve()} - logout={() => {}} - addCluster={() => {}} - /> - ); + return ; } export function OneClusterWithNoActiveCluster() { - const identityRootCluster: IdentityRootCluster = { - active: false, - clusterName: 'teleport-localhost', - userName: '', - uri: '/clusters/localhost', - connected: false, - profileStatusError: '', - }; - return ( - Promise.resolve()} - logout={() => {}} - addCluster={() => {}} + ); } export function OneClusterWithActiveCluster() { - const identityRootCluster: IdentityRootCluster = { - active: true, - clusterName: 'Teleport-Localhost', - userName: 'alice', - uri: '/clusters/localhost', - connected: true, - profileStatusError: '', - }; - const cluster = makeRootCluster({ - uri: '/clusters/localhost', - name: 'teleport-localhost', - proxyHost: 'localhost:3080', loggedInUser: makeLoggedInUser({ name: 'alice', roles: ['access', 'editor'], @@ -109,297 +122,105 @@ export function OneClusterWithActiveCluster() { }); return ( - Promise.resolve()} - logout={() => {}} - addCluster={() => {}} - /> + ); } export function ManyClustersWithNoActiveCluster() { - const identityRootCluster1: IdentityRootCluster = { - active: false, - clusterName: 'orange', - userName: 'bob', - uri: '/clusters/orange', - connected: true, - profileStatusError: '', - }; - const identityRootCluster2: IdentityRootCluster = { - active: false, - clusterName: 'violet', - userName: 'sammy', - uri: '/clusters/violet', - connected: true, - profileStatusError: '', - }; - const identityRootCluster3: IdentityRootCluster = { - active: false, - clusterName: 'green', - userName: '', - uri: '/clusters/green', - connected: true, - profileStatusError: '', - }; - return ( - Promise.resolve()} - logout={() => {}} - addCluster={() => {}} + ); } export function ManyClustersWithActiveCluster() { - const identityRootCluster1: IdentityRootCluster = { - active: false, - clusterName: 'orange', - userName: 'bob', - uri: '/clusters/orange', - connected: true, - profileStatusError: '', - }; - const identityRootCluster2: IdentityRootCluster = { - active: true, - clusterName: 'violet', - userName: 'sammy', - uri: '/clusters/violet', - connected: true, - profileStatusError: '', - }; - const identityRootCluster3: IdentityRootCluster = { - active: false, - clusterName: 'green', - userName: '', - uri: '/clusters/green', - connected: true, - profileStatusError: '', - }; - - const activeIdentityRootCluster = identityRootCluster2; - const activeCluster = makeRootCluster({ - uri: activeIdentityRootCluster.uri, - name: activeIdentityRootCluster.clusterName, - proxyHost: 'localhost:3080', - loggedInUser: makeLoggedInUser({ - name: activeIdentityRootCluster.userName, - roles: ['access', 'editor'], - sshLogins: ['root'], - }), - }); - return ( - Promise.resolve()} - logout={() => {}} - addCluster={() => {}} + ); } export function ManyClustersWithProfileErrorsAndActiveCluster() { - const identityRootCluster1: IdentityRootCluster = { - active: false, - clusterName: 'orange', - userName: 'bob', - uri: '/clusters/orange', - connected: false, - profileStatusError: profileStatusError, - }; - const identityRootCluster2: IdentityRootCluster = { - active: true, - clusterName: 'violet', - userName: 'sammy', - uri: '/clusters/violet', - connected: true, - profileStatusError: '', - }; - const identityRootCluster3: IdentityRootCluster = { - active: false, - clusterName: 'green', - userName: '', - uri: '/clusters/green', - connected: false, - profileStatusError: profileStatusError, - }; - - const activeIdentityRootCluster = identityRootCluster2; - const activeCluster = makeRootCluster({ - uri: activeIdentityRootCluster.uri, - name: activeIdentityRootCluster.clusterName, - proxyHost: 'localhost:3080', - loggedInUser: makeLoggedInUser({ - name: activeIdentityRootCluster.userName, - roles: ['access', 'editor'], - sshLogins: ['root'], - }), - }); - return ( - Promise.resolve()} - logout={() => {}} - addCluster={() => {}} + activeClusterUri={clusterOrange.uri} /> ); } export function LongNamesWithManyRoles() { - const identityRootCluster1: IdentityRootCluster = { - active: false, - clusterName: 'orange', - userName: 'bob', - uri: '/clusters/orange', - connected: true, - profileStatusError: '', - }; - const identityRootCluster2: IdentityRootCluster = { - active: true, - clusterName: 'psv-eindhoven-eredivisie-production-lorem-ipsum', - userName: 'ruud-van-nistelrooy-van-der-sar', - uri: '/clusters/psv', - connected: true, - profileStatusError: '', - }; - const identityRootCluster3: IdentityRootCluster = { - active: false, - clusterName: 'green', - userName: '', - uri: '/clusters/green', - connected: true, - profileStatusError: '', - }; - - const activeIdentityRootCluster = identityRootCluster2; - const activeCluster = makeRootCluster({ - uri: activeIdentityRootCluster.uri, - name: activeIdentityRootCluster.clusterName, - proxyHost: 'localhost:3080', - loggedInUser: makeLoggedInUser({ - name: activeIdentityRootCluster.userName, - 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', - ], - sshLogins: ['root'], - }), - }); - return ( - Promise.resolve()} - logout={() => {}} - addCluster={() => {}} + activeClusterUri={clusterViolet.uri} /> ); } export function TrustedDeviceEnrolled() { - const identityRootCluster: IdentityRootCluster = { - active: false, - clusterName: 'orange', - userName: 'bob', - uri: '/clusters/orange', - connected: true, - profileStatusError: '', - }; - - const activeIdentityRootCluster = identityRootCluster; - const activeCluster = makeRootCluster({ - uri: activeIdentityRootCluster.uri, - name: activeIdentityRootCluster.clusterName, - proxyHost: 'localhost:3080', - loggedInUser: makeLoggedInUser({ - isDeviceTrusted: true, - name: activeIdentityRootCluster.userName, - roles: ['circle-mark-app-access', 'grafana-lite-app-access'], - sshLogins: ['root'], - }), - }); - return ( - Promise.resolve()} - logout={() => {}} - addCluster={() => {}} + ); } export function TrustedDeviceRequiredButNotEnrolled() { - const identityRootCluster: IdentityRootCluster = { - active: false, - clusterName: 'orange', - userName: 'bob', - uri: '/clusters/orange', - connected: true, - profileStatusError: '', - }; - - const activeIdentityRootCluster = identityRootCluster; - const activeCluster = makeRootCluster({ - uri: activeIdentityRootCluster.uri, - name: activeIdentityRootCluster.clusterName, - proxyHost: 'localhost:3080', - loggedInUser: makeLoggedInUser({ - trustedDeviceRequirement: TrustedDeviceRequirement.REQUIRED, - name: activeIdentityRootCluster.userName, - roles: ['circle-mark-app-access'], - sshLogins: ['root'], - }), - }); - return ( - Promise.resolve()} - logout={() => {}} - addCluster={() => {}} + ); } diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/Identity.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/Identity.tsx index f5c7cd25cbc1f..e15db0888d35d 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/Identity.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/Identity.tsx @@ -16,14 +16,7 @@ * along with this program. If not, see . */ -import { - forwardRef, - useCallback, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; import { Box } from 'design'; @@ -31,15 +24,16 @@ import Popover from 'design/Popover'; import { TrustedDeviceRequirement } from 'gen-proto-ts/teleport/legacy/types/trusted_device_requirement_pb'; import * as tshd from 'teleterm/services/tshd/types'; +import { KeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation'; +import { useStoreSelector } from 'teleterm/ui/hooks/useStoreSelector'; import { useKeyboardShortcutFormatters, useKeyboardShortcuts, } from 'teleterm/ui/services/keyboardShortcuts'; -import { EmptyIdentityList } from './EmptyIdentityList/EmptyIdentityList'; -import { IdentityList } from './IdentityList/IdentityList'; +import { ActiveCluster, ClusterList } from './IdentityList/IdentityList'; import { IdentitySelector } from './IdentitySelector/IdentitySelector'; -import { IdentityRootCluster, useIdentity } from './useIdentity'; +import { useIdentity } from './useIdentity'; export function IdentityContainer() { const { @@ -48,17 +42,27 @@ export function IdentityContainer() { changeRootCluster, logout, addCluster, + refreshCluster, + changeColor, } = useIdentity(); + const selectorRef = useRef(); + const [open, setOpen] = useState(false); const { getLabelWithAccelerator } = useKeyboardShortcutFormatters(); - - const presenterRef = useRef(); + const hasClusters = activeRootCluster || rootClusters.length; + const togglePopoverOrAddCluster = useCallback(() => { + if (hasClusters) { + setOpen(o => !o); + } else { + addCluster(); + } + }, [addCluster, hasClusters]); useKeyboardShortcuts( useMemo( () => ({ - openProfiles: presenterRef.current?.togglePopover, + openProfiles: togglePopoverOrAddCluster, }), - [presenterRef.current?.togglePopover] + [togglePopoverOrAddCluster] ) ); @@ -68,109 +72,71 @@ export function IdentityContainer() { 'openProfiles' ); + function withClose any>( + fn: T + ): (...args: Parameters) => ReturnType { + return (...args) => { + setOpen(false); + return fn(...args); + }; + } + + const deviceTrustStatus = calculateDeviceTrustStatus( + activeRootCluster?.loggedInUser + ); + const activeColor = useStoreSelector( + 'workspacesService', + useCallback(state => state.workspaces[state.rootClusterUri]?.color, []) + ); + return ( - + <> + + setOpen(false)} + popoverCss={() => `max-width: min(450px, 90%)`} + > + + {activeRootCluster && ( + logout(activeRootCluster.uri))} + onRefresh={withClose(() => refreshCluster(activeRootCluster.uri))} + deviceTrustStatus={deviceTrustStatus} + /> + )} + + {focusGrabber} + + + + + ); } -export type IdentityHandler = { togglePopover: () => void }; - -export type IdentityProps = { - activeRootCluster: tshd.Cluster | undefined; - rootClusters: IdentityRootCluster[]; - changeRootCluster: (clusterUri: string) => Promise; - logout: (clusterUri: string) => void; - addCluster: () => void; - makeTitle: (userWithClusterName: string | undefined) => string; -}; - -export const Identity = forwardRef( - ( - { - activeRootCluster, - rootClusters, - changeRootCluster, - logout, - addCluster, - makeTitle, - }, - ref - ) => { - const selectorRef = useRef(); - const [isPopoverOpened, setIsPopoverOpened] = useState(false); - - const togglePopover = useCallback(() => { - setIsPopoverOpened(wasOpened => !wasOpened); - }, [setIsPopoverOpened]); - - function withClose any>( - fn: T - ): (...args: Parameters) => ReturnType { - return (...args) => { - setIsPopoverOpened(false); - return fn(...args); - }; - } - - useImperativeHandle(ref, () => ({ - togglePopover: () => { - togglePopover(); - }, - })); - - const loggedInUser = activeRootCluster?.loggedInUser; - - const deviceTrustStatus = calculateDeviceTrustStatus(loggedInUser); - - return ( - <> - - setIsPopoverOpened(false)} - popoverCss={() => `max-width: min(560px, 90%)`} - > - - {rootClusters.length ? ( - - ) : ( - - )} - - - - ); - } -); - const Container = styled(Box)` background: ${props => props.theme.colors.levels.elevated}; + min-width: 300px; width: 100%; `; @@ -192,3 +158,18 @@ function calculateDeviceTrustStatus( } return 'none'; } + +// Hack - for some reason xterm.js doesn't allow moving focus to the Identity popover +// when it is focused using element.focus(). +// It used to restore focus after the popover was closed, but this no longer seems to work. +const focusGrabber = ( + +); diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/AddNewClusterItem.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/AddNewClusterItem.tsx deleted file mode 100644 index 71755da3d4a21..0000000000000 --- a/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/AddNewClusterItem.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 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 { Add } from 'design/Icon'; - -import { useKeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation'; -import { ListItem } from 'teleterm/ui/components/ListItem'; - -interface AddNewClusterItemProps { - index: number; - - onClick(): void; -} - -export function AddNewClusterItem(props: AddNewClusterItemProps) { - const { isActive } = useKeyboardArrowsNavigation({ - index: props.index, - onRun: props.onClick, - }); - - return ( - - - Add another cluster - - ); -} - -const StyledListItem = styled(ListItem)` - border-radius: 0; - height: 38px; - justify-content: center; - color: ${props => props.theme.colors.text.slightlyMuted}; -`; diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/ColorPicker.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/ColorPicker.tsx new file mode 100644 index 0000000000000..479b852d6b60f --- /dev/null +++ b/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/ColorPicker.tsx @@ -0,0 +1,130 @@ +/** + * 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 styled from 'styled-components'; + +import { Box, Flex } from 'design'; +import { Pencil } from 'design/Icon'; +import { useRefClickOutside } from 'shared/hooks/useRefClickOutside'; + +import { + WorkspaceColor, + workspaceColorMapping, + workspaceColors, +} from 'teleterm/ui/services/workspacesService'; + +import { UserIcon } from '../IdentitySelector/UserIcon'; + +export function ColorPicker(props: { + letter: string; + color: WorkspaceColor; + setColor(color: WorkspaceColor): void; +}) { + const [open, setOpen] = useState(false); + const [hoveredColor, setHoveredColor] = useState< + WorkspaceColor | undefined + >(); + const ref = useRefClickOutside({ open, setOpen }); + + const userIconProps = { + size: 'big' as const, + onClick: () => setOpen(o => !o), + letter: props.letter, + }; + + return ( + + + + + {open && ( + + + + {workspaceColors.options.map(color => ( + setHoveredColor(color)} + onMouseLeave={() => setHoveredColor(undefined)} + onClick={() => { + props.setColor(color); + setOpen(false); + }} + /> + ))} + + + )} + + ); +} + +const Circle = styled.button<{ color?: string }>` + border-radius: 50%; + background: ${props => props.color}; + height: 16px; + width: 16px; + border: none; + cursor: pointer; + box-shadow: rgba(0, 0, 0, 0.15) 0 1px 3px; + + &:focus-visible { + outline: 1px solid ${props => props.theme.colors.text.muted}; + } + + &:hover { + opacity: 0.9; + } +`; + +const AbsolutePencilIcon = styled(Pencil).attrs({ size: 11 })` + position: absolute; + bottom: -1px; + left: 21px; + border-radius: 50%; + color: black; + background: rgb(240, 240, 240); + box-shadow: rgba(0, 0, 0, 0.15) 0 1px 3px; + height: 16px; + width: 16px; +`; 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 9f2d023272023..86158707c1200 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/IdentityList.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/IdentityList.tsx @@ -19,68 +19,117 @@ import { JSX } from 'react'; import styled from 'styled-components'; -import { Box, Flex, Label, P3, Text } from 'design'; -import { ShieldCheck, ShieldWarning } from 'design/Icon'; +import { ButtonText, Flex, Label, P3 } from 'design'; +import { Logout, Refresh, ShieldCheck, ShieldWarning } from 'design/Icon'; import Link from 'design/Link'; +import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; -import { LoggedInUser } from 'teleterm/services/tshd/types'; -import { KeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation'; +import { ProfileStatusError } from 'teleterm/ui/components/ProfileStatusError'; +import { WorkspaceColor } from 'teleterm/ui/services/workspacesService'; import { DeviceTrustStatus } from 'teleterm/ui/TopBar/Identity/Identity'; +import { RootClusterUri } from 'teleterm/ui/uri'; -import { IdentityRootCluster } from '../useIdentity'; -import { AddNewClusterItem } from './AddNewClusterItem'; -import { IdentityListItem } from './IdentityListItem'; +import { ColorPicker } from './ColorPicker'; +import { + AddClusterItem, + getClusterLetter, + IdentityListItem, + TitleAndSubtitle, +} from './IdentityListItem'; -export function IdentityList(props: { - loggedInUser: LoggedInUser; - clusters: IdentityRootCluster[]; - onSelectCluster(clusterUri: string): void; - onAddCluster(): void; - onLogout(clusterUri: string): void; +export function ActiveCluster(props: { + activeCluster: Cluster | undefined; + activeColor: WorkspaceColor; deviceTrustStatus: DeviceTrustStatus; + onChangeColor(color: WorkspaceColor): void; + onRefresh(): void; + onLogout(): void; }) { return ( - - {props.loggedInUser && ( - <> - - - {props.loggedInUser.name} - - {props.loggedInUser.roles.map(role => ( - - ))} - - - - - - - )} - - {focusGrabber} - - {props.clusters.map((cluster, index) => ( - props.onSelectCluster(cluster.uri)} - onLogout={() => props.onLogout(cluster.uri)} + <> + + + + - ))} - - - - + + + + props.onRefresh()} + > + + + props.onLogout()} + intent="danger" + size="small" + > + + + + + {props.activeCluster.profileStatusError && ( + props.theme.space[2]}px; + gap: 14px; + `} /> - - - + )} + + {props.activeCluster.loggedInUser?.roles.map(role => ( + + ))} + + + + + + ); +} + +export function ClusterList(props: { + clusters: Cluster[]; + onSelect(clusterUri: RootClusterUri): void; + onLogout?(clusterUri: RootClusterUri): void; + onAdd(): void; +}) { + return ( + <> + {props.clusters.map((cluster, index) => ( + props.onSelect(cluster.uri)} + onLogout={ + props.onLogout ? () => props.onLogout(cluster.uri) : undefined + } + /> + ))} + + ); } @@ -126,21 +175,6 @@ function DeviceTrustMessage(props: { status: DeviceTrustStatus }) { } } -// Hack - for some reason xterm.js doesn't allow moving a focus to the Identity popover -// when it is focused using element.focus(). Moreover, it looks like this solution has a benefit -// of returning the focus to the previously focused element when popover is closed. -const focusGrabber = ( - -); - const Separator = styled.div` background: ${props => props.theme.colors.spotBackground[1]}; height: 1px; diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/IdentityListItem.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/IdentityListItem.tsx index d1d053612d1dd..bc3a8b2f6248a 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/IdentityListItem.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/IdentityList/IdentityListItem.tsx @@ -15,85 +15,157 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import { useCallback } from 'react'; +import styled from 'styled-components'; -import { useState } from 'react'; - -import { ButtonIcon, Flex, Label, Text } from 'design'; +import { ButtonText, Flex, P3, Text } from 'design'; import { Logout } from 'design/Icon'; +import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; import { useKeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation'; import { ListItem } from 'teleterm/ui/components/ListItem'; import { ProfileStatusError } from 'teleterm/ui/components/ProfileStatusError'; -import { getUserWithClusterName } from 'teleterm/ui/utils'; +import { useStoreSelector } from 'teleterm/ui/hooks/useStoreSelector'; +import { WorkspaceColor } from 'teleterm/ui/services/workspacesService'; -import { IdentityRootCluster } from '../useIdentity'; +import { UserIcon } from '../IdentitySelector/UserIcon'; export function IdentityListItem(props: { index: number; - cluster: IdentityRootCluster; + cluster: Cluster; onSelect(): void; - onLogout(): void; + /** If defined, the logout button is rendered. */ + onLogout?(): void; }) { - const [isHovered, setIsHovered] = useState(false); const { isActive } = useKeyboardArrowsNavigation({ index: props.index, onRun: props.onSelect, }); - - const userWithClusterName = getUserWithClusterName(props.cluster); + const workspaceColor = useStoreSelector( + 'workspacesService', + useCallback( + state => state.workspaces[props.cluster.uri]?.color, + [props.cluster.uri] + ) + ); return ( - + (e.key === 'Enter' || e.key === 'Space') && props.onSelect() + } isActive={isActive} - onMouseEnter={() => { - setIsHovered(true); - }} - onMouseLeave={() => { - setIsHovered(false); - }} + title={`Switch to ${props.cluster.name}`} > - - - - {userWithClusterName} - - {props.cluster.profileStatusError && ( - - )} - - - {props.cluster.active ? ( - - ) : null} - + + {props.onLogout && ( + { e.stopPropagation(); props.onLogout(); }} > - {/* Due to the icon shape it appears to be not centered, so a small margin is added */} - - - + + + )} - + {props.cluster.profileStatusError && ( + props.theme.space[2]}px; + gap: 10px; + `} + /> + )} + ); } + +export function AddClusterItem(props: { index: number; onClick(): void }) { + const { isActive } = useKeyboardArrowsNavigation({ + index: props.index, + onRun: props.onClick, + }); + + return ( + + + + ); +} + +const StyledListItem = styled(ListItem)` + padding: ${props => props.theme.space[2]}px ${props => props.theme.space[3]}px; + flex-direction: column; + align-items: start; + gap: ${props => props.theme.space[1]}px; + border-radius: 0; + height: 100%; + &:hover .logout { + visibility: visible; + } +`; + +function WithIconItem(props: { + letter: string; + title: string; + subtitle?: string; + color?: WorkspaceColor; +}) { + return ( + + + + + ); +} + +export function TitleAndSubtitle(props: { title: string; subtitle?: string }) { + return ( + + + {props.title} + + + {props.subtitle && ( + + {props.subtitle} + + )} + + ); +} + +export function getClusterLetter(cluster: Cluster): string { + return cluster.name.at(0); +} diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/IdentitySelector.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/IdentitySelector.tsx index c99d3f20ff729..df1d83ac0d7ee 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/IdentitySelector.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/IdentitySelector.tsx @@ -19,35 +19,39 @@ import { forwardRef } from 'react'; import { Box } from 'design'; +import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; +import { WorkspaceColor } from 'teleterm/ui/services/workspacesService'; import { ConnectionStatusIndicator } from 'teleterm/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionStatusIndicator'; import { DeviceTrustStatus } from 'teleterm/ui/TopBar/Identity/Identity'; import { TopBarButton } from 'teleterm/ui/TopBar/TopBarButton'; import { getUserWithClusterName } from 'teleterm/ui/utils'; -import { PamIcon } from './PamIcon'; +import { getClusterLetter } from '../IdentityList/IdentityListItem'; import { UserIcon } from './UserIcon'; -interface IdentitySelectorProps { - isOpened: boolean; - userName: string; - clusterName: string; - onClick(): void; - makeTitle: (userWithClusterName: string | undefined) => string; - deviceTrustStatus: DeviceTrustStatus; -} - export const IdentitySelector = forwardRef< HTMLButtonElement, - IdentitySelectorProps + { + open: boolean; + activeCluster: Cluster | undefined; + onClick(): void; + makeTitle(userWithClusterName: string | undefined): string; + deviceTrustStatus: DeviceTrustStatus; + activeColor: WorkspaceColor; + } >((props, ref) => { - const isSelected = props.userName && props.clusterName; - const selectorText = isSelected && getUserWithClusterName(props); + const selectorText = + props.activeCluster && + getUserWithClusterName({ + clusterName: props.activeCluster.name, + userName: props.activeCluster.loggedInUser?.name, + }); const title = props.makeTitle(selectorText); return ( - {isSelected ? ( + {props.activeCluster ? ( - + {props.deviceTrustStatus === 'requires-enrollment' && ( ) : ( - + )} ); diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/PamIcon.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/PamIcon.tsx deleted file mode 100644 index a7a4c9d4dc282..0000000000000 --- a/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/PamIcon.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 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 { Image } from 'design'; - -import pam from './pam.svg'; - -export function PamIcon() { - return ( - - - - ); -} - -const PamCircle = styled.div` - height: 24px; - width: 24px; - display: flex; - align-content: center; - justify-content: center; - border-radius: 50%; - background: ${props => props.theme.colors.spotBackground[0]}; -`; diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/UserIcon.tsx b/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/UserIcon.tsx index 4807171195940..a71c1b3f571cf 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/UserIcon.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/UserIcon.tsx @@ -16,25 +16,67 @@ * along with this program. If not, see . */ +import { MouseEvent, ReactNode } from 'react'; import styled from 'styled-components'; -interface UserIconProps { - letter: string; -} +import { + WorkspaceColor, + workspaceColorMapping, +} from 'teleterm/ui/services/workspacesService'; -export function UserIcon(props: UserIconProps) { - return {props.letter.toLocaleUpperCase()}; +export function UserIcon(props: { + letter: string; + /** If not provided, a default neutral color is rendered. */ + color?: WorkspaceColor; + onClick?(e: MouseEvent): void; + children?: ReactNode; + interactive?: boolean; + size?: 'regular' | 'big'; +}) { + return ( + + {props.letter?.toLocaleUpperCase()} + {props.children} + + ); } -const Circle = styled.span` +const Circle = styled.span<{ + color?: string; + interactive?: boolean; + size: string; +}>` + position: relative; border-radius: 50%; - color: ${props => props.theme.colors.buttons.primary.text}; - background: ${props => props.theme.colors.buttons.primary.default}; - height: 24px; - width: 24px; + color: ${props => + props.color + ? props.theme.colors.text.primaryInverse + : props.theme.colors.text.main}; + background: ${props => + props.color || props.theme.colors.interactive.tonal.neutral[1]}; + height: ${props => props.size}; + width: ${props => props.size}; display: flex; - flex-shrink: 0; + font-weight: 500; justify-content: center; align-items: center; - overflow: hidden; + box-shadow: rgba(0, 0, 0, 0.15) 0 1px 3px; + border: none; + &:focus-visible { + outline: 2px solid ${props => props.theme.colors.text.muted}; + } + ${props => + props.interactive && + ` + &:hover { + opacity: 0.9; + } + cursor: pointer; + `} `; diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/pam.svg b/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/pam.svg deleted file mode 100644 index 8ae4aed3b0686..0000000000000 --- a/web/packages/teleterm/src/ui/TopBar/Identity/IdentitySelector/pam.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/web/packages/teleterm/src/ui/TopBar/Identity/useIdentity.ts b/web/packages/teleterm/src/ui/TopBar/Identity/useIdentity.ts index a19e5201b3322..95e111e175594 100644 --- a/web/packages/teleterm/src/ui/TopBar/Identity/useIdentity.ts +++ b/web/packages/teleterm/src/ui/TopBar/Identity/useIdentity.ts @@ -16,9 +16,14 @@ * along with this program. If not, see . */ -import { Cluster, LoggedInUser } from 'teleterm/services/tshd/types'; +import { useCallback } from 'react'; + +import { Cluster } from 'teleterm/services/tshd/types'; import { useAppContext } from 'teleterm/ui/appContextProvider'; -import { useWorkspaceServiceState } from 'teleterm/ui/services/workspacesService'; +import { + useWorkspaceServiceState, + WorkspaceColor, +} from 'teleterm/ui/services/workspacesService'; import { RootClusterUri } from 'teleterm/ui/uri'; export function useIdentity() { @@ -31,61 +36,43 @@ export function useIdentity() { await ctx.workspacesService.setActiveWorkspace(clusterUri); } - function addCluster(): void { + const addCluster = useCallback(() => { ctx.commandLauncher.executeCommand('cluster-connect', {}); + }, [ctx.commandLauncher]); + + function refreshCluster(clusterUri: RootClusterUri): void { + ctx.commandLauncher.executeCommand('cluster-connect', { clusterUri }); } function logout(clusterUri: RootClusterUri): void { ctx.commandLauncher.executeCommand('cluster-logout', { clusterUri }); } + const activeClusterUri = ctx.workspacesService.getRootClusterUri(); function getActiveRootCluster(): Cluster | undefined { - const clusterUri = ctx.workspacesService.getRootClusterUri(); - if (!clusterUri) { - return; - } - return ctx.clustersService.findCluster(clusterUri); + return ctx.clustersService.findCluster(activeClusterUri); } - function getLoggedInUser(): LoggedInUser | undefined { + function changeColor(color: WorkspaceColor): undefined { const clusterUri = ctx.workspacesService.getRootClusterUri(); if (!clusterUri) { return; } - const cluster = ctx.clustersService.findCluster(clusterUri); - if (!cluster) { - return; - } - return cluster.loggedInUser; + ctx.workspacesService.changeWorkspaceColor(clusterUri, color); } - const rootClusters: IdentityRootCluster[] = ctx.clustersService + const rootClusters = ctx.clustersService .getClusters() .filter(c => !c.leaf) - .map(cluster => ({ - active: cluster.uri === ctx.workspacesService.getRootClusterUri(), - clusterName: cluster.name, - userName: cluster.loggedInUser?.name, - uri: cluster.uri, - connected: cluster.connected, - profileStatusError: cluster.profileStatusError, - })); + .filter(c => c.uri !== activeClusterUri); return { changeRootCluster, addCluster, + refreshCluster, logout, - loggedInUser: getLoggedInUser(), + changeColor, activeRootCluster: getActiveRootCluster(), rootClusters, }; } - -export interface IdentityRootCluster { - active: boolean; - clusterName: string; - userName: string; - uri: RootClusterUri; - connected: boolean; - profileStatusError: string; -} diff --git a/web/packages/teleterm/src/ui/components/KeyboardArrowsNavigation/KeyboardArrowsNavigation.tsx b/web/packages/teleterm/src/ui/components/KeyboardArrowsNavigation/KeyboardArrowsNavigation.tsx index 7ea02bdc193bf..1ee0d43115751 100644 --- a/web/packages/teleterm/src/ui/components/KeyboardArrowsNavigation/KeyboardArrowsNavigation.tsx +++ b/web/packages/teleterm/src/ui/components/KeyboardArrowsNavigation/KeyboardArrowsNavigation.tsx @@ -106,6 +106,24 @@ export const KeyboardArrowsNavigation: FC = props => { ); }; +export const NullKeyboardArrowsNavigation: FC = props => { + const value = useMemo( + () => ({ + addItem: () => {}, + removeItem: () => {}, + activeIndex: -1, + setActiveIndex: () => {}, + }), + [] + ); + + return ( + +
{props.children}
+
+ ); +}; + function getNextIndex( items: RunActiveItemHandler[], currentIndex: number diff --git a/web/packages/teleterm/src/ui/components/ListItem.tsx b/web/packages/teleterm/src/ui/components/ListItem.tsx index 59b9ce56f70bc..44c2326f5bdc2 100644 --- a/web/packages/teleterm/src/ui/components/ListItem.tsx +++ b/web/packages/teleterm/src/ui/components/ListItem.tsx @@ -24,26 +24,33 @@ export const StaticListItem = styled.li` display: flex; align-items: center; justify-content: flex-start; - width: 100%; - position: relative; - font-size: 14px; - padding: 0 16px; + outline: none; font-weight: ${props => props.theme.regular}; font-family: ${props => props.theme.font}; color: ${props => props.theme.colors.text.main}; + position: relative; + font-size: 14px; + padding: 0 16px; height: 34px; background: inherit; border: none; border-radius: 4px; `; -export const ListItem = styled(StaticListItem)<{ isActive?: boolean }>` +export const ListItem = styled(StaticListItem).attrs({ tabIndex: 0 })<{ + isActive?: boolean; +}>` cursor: pointer; background: ${props => - props.isActive ? props.theme.colors.spotBackground[0] : null}; + props.isActive ? props.theme.colors.interactive.tonal.neutral[0] : null}; - &:focus, + &:focus-visible { + outline: 1px solid ${props => props.theme.colors.text.muted}; + background: ${props => props.theme.colors.interactive.tonal.neutral[0]}; + } &:hover { - background: ${props => props.theme.colors.spotBackground[0]}; + outline: 1px solid + ${props => props.theme.colors.interactive.tonal.neutral[0]}; + background: ${props => props.theme.colors.interactive.tonal.neutral[0]}; } `; diff --git a/web/packages/teleterm/src/ui/components/ProfileStatusError.tsx b/web/packages/teleterm/src/ui/components/ProfileStatusError.tsx index dfc6be8684823..de97f6432e863 100644 --- a/web/packages/teleterm/src/ui/components/ProfileStatusError.tsx +++ b/web/packages/teleterm/src/ui/components/ProfileStatusError.tsx @@ -21,12 +21,20 @@ import { Warning } from 'design/Icon'; export function ProfileStatusError(props: { error: string; - mb?: number | string; -}) { + className?: string; +}): JSX.Element { return ( - + - {toWellFormattedConnectionError(props.error)} + + {toWellFormattedConnectionError(props.error)} + ); } diff --git a/web/packages/teleterm/src/ui/services/workspacesService/color.ts b/web/packages/teleterm/src/ui/services/workspacesService/color.ts index d6627fc0b6c5f..8c1333aa68ab6 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/color.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/color.ts @@ -18,6 +18,8 @@ import { z } from 'zod'; +import { dataVisualisationColors } from 'design/theme/themes/darkTheme'; + import Logger from 'teleterm/logger'; export type WorkspaceColor = z.infer; @@ -57,7 +59,7 @@ export function parseWorkspaceColor( /** * Determines the next available unused color across all workspaces. - * If all colors are already in use, it defaults to returning purple. + * If all colors are already in use, it returns purple. */ function getNextWorkspaceColor( workspaces: Record @@ -67,3 +69,19 @@ function getNextWorkspaceColor( const unusedColors = allColors.difference(takenColors); return unusedColors.size > 0 ? [...unusedColors][0] : 'purple'; } + +/** + * Maps workspace colors to the theme colors. + * We always use dark theme colors. + * They look good in both light and dark modes, + * and we avoid confusing users with different shades of the same color. + */ +export const workspaceColorMapping: Record = { + purple: dataVisualisationColors.primary.purple, + red: dataVisualisationColors.primary.abbey, + green: dataVisualisationColors.primary.caribbean, + yellow: dataVisualisationColors.primary.sunflower, + blue: dataVisualisationColors.primary.picton, + cyan: dataVisualisationColors.primary.cyan, + pink: dataVisualisationColors.primary.wednesdays, +}; diff --git a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts index 399321bae23b5..48a18aff66d29 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts @@ -135,7 +135,7 @@ describe('restoring workspace', () => { expect(workspacesService.getRestoredState().workspaces).toStrictEqual({}); }); - it('restores profile color from state or assigns if empty', async () => { + it('restores workspace color from state or assigns if empty', async () => { const clusterFoo = makeRootCluster({ uri: '/clusters/foo' }); const workspaceFoo: PersistedWorkspace = { color: 'blue',