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',