Connect: remember clusters after logout (#66318)

* Separate logging out and forgetting profile

* Store `proxyHost` in the workspace and decouple workspace existence from profile existence

* Display workspaces instead of profiles in identity list

* Only activate workspace on launch when cluster exists

* Handle cluster disappearing when `ClusterLogin` is open

* Fix e2e tests

* Add comments

* Clean up rendering workspaces

* Make test name more obvious

* Make workspace state the source of truth for activation

* Adjust callsites to updated `addWorkspace` and `setActiveWorkspace` behavior

* Add `makeWorkspace` and `makePersistedWorkspace` test helpers

* Fix mistakenly passed `cluster.uri` instead of `cluster.proxyHost`

* Read `activeClusterUri` from `useStoreSelector`

* Update `proxyHost` in workspace whenever it changes in profile

* Use `proxyHost` in notification

* Introduce `rootClusterProxyHost`

* Get rid of `getActiveRootCluster()`

* Rename `identityItems`

* Improve comment for `setActiveWorkspace` init action

* Fix `Identity` story

* Previously used -> Saved in Connect

* Clear app updates managing cluster only when forgetting cluster

* Wrap `hasAnyWorkspaces` in boolean

* Expired session -> Session expired, Saved in Connect -> In history

* Fix e2e test
This commit is contained in:
Grzegorz Zdunek
2026-05-15 08:23:44 +00:00
committed by GitHub
parent 6500619d93
commit 047176ef68
45 changed files with 1053 additions and 363 deletions
+14 -1
View File
@@ -90,7 +90,20 @@ export async function login(
if (!password) {
throw new Error('login: password required when username is provided');
}
await page.getByRole('button', { name: 'Connect', exact: true }).click();
const connectButton = page.getByRole('button', {
name: 'Connect',
exact: true,
});
const addClusterItem = page
.getByRole('listitem')
.filter({ hasText: 'Add Cluster' });
// The 'Connect' button is visible only when no clusters are present.
if (await connectButton.isVisible()) {
await connectButton.click();
} else {
await addClusterItem.click();
}
const clusterInput = page.getByPlaceholder('teleport.example.com');
await expect(clusterInput).toBeVisible();
+6 -1
View File
@@ -17,6 +17,7 @@
*/
import { test, expect } from '@gravitational/e2e/helpers/connect';
import { startUrl } from '@gravitational/e2e/helpers/env';
test.use({ autoLogin: true });
@@ -28,5 +29,9 @@ test('logging out', async ({ app }) => {
page.getByText('Are you sure you want to log out?')
).toBeVisible();
await page.getByRole('button', { name: 'Log Out', exact: true }).click();
await expect(page.getByText('Connect a Cluster')).toBeVisible();
const proxyHostname = new URL(startUrl).hostname;
const previouslyUsedCluster = page
.getByRole('listitem')
.filter({ hasText: proxyHostname });
await expect(previouslyUsedCluster.getByText('Not logged in')).toBeVisible();
});
+10 -5
View File
@@ -28,6 +28,7 @@ import {
test,
withDefaultAppConfig,
} from '@gravitational/e2e/helpers/connect';
import { startUrl } from '@gravitational/e2e/helpers/env';
// These tests manage the app lifecycle manually (multiple launches/closes), so they do not use the
// `app` fixture.
@@ -138,12 +139,14 @@ test.describe('state restoration from disk', () => {
await using app = await launchApp(temp.path);
const { page } = app;
// With no tsh dir, no cluster can be connected.
await expect(page.getByText('Connect a Cluster')).toBeVisible();
// app_state.json still references the old cluster, so setActiveWorkspace should show an error.
await expect(
page.getByText('Could not set cluster as active')
page.getByText('Log in to a cluster to use Teleport Connect')
).toBeVisible();
const proxyHostname = new URL(startUrl).hostname;
const inHistoryCluster = page
.getByRole('listitem')
.filter({ hasText: proxyHostname });
await expect(inHistoryCluster.getByText('In history')).toBeVisible();
}
});
@@ -166,7 +169,9 @@ test.describe('state restoration from disk', () => {
page.getByText('Are you sure you want to log out?')
).toBeVisible();
await page.getByRole('button', { name: 'Log Out', exact: true }).click();
await expect(page.getByText('Connect a Cluster')).toBeVisible();
await expect(
page.getByText('Log in to a cluster to use Teleport Connect')
).toBeVisible();
// Login to the same cluster again.
await login(page);
@@ -113,7 +113,7 @@ const tests: {
removeProfile: true,
});
expect(rendererHandler.send).toHaveBeenCalledWith({
op: 'will-logout-and-remove',
op: 'will-logout',
uri: cluster.uri,
});
},
@@ -235,6 +235,36 @@ const tests: {
expect(rendererHandler.send).not.toHaveBeenCalled();
},
},
{
name: 'when cluster proxy host changes, it updates state and notifies renderer',
setup: async ({ tshdClient }) => {
const next = makeRootCluster({
connected: false,
proxyHost: 'new.example.com:443',
});
jest
.spyOn(tshdClient, 'listRootClusters')
.mockResolvedValue(new MockedUnaryCall({ clusters: [cluster] }));
jest.spyOn(tshdClient, 'clearStaleClusterClients');
return {
profileWatcher: makeWatcher([
{ op: 'changed', next, previous: cluster },
]),
};
},
expect: ({ clusterStore, tshdClient, rendererHandler }) => {
expect(clusterStore.getState().get(cluster.uri).proxyHost).toBe(
'new.example.com:443'
);
expect(tshdClient.clearStaleClusterClients).toHaveBeenCalledWith({
rootClusterUri: cluster.uri,
});
expect(rendererHandler.send).toHaveBeenCalledWith({
op: 'did-change-proxy-host',
uri: cluster.uri,
});
},
},
{
name: 'when access of logged in user changes, it updates state, clears stale clients, and notifies renderer',
setup: async ({ tshdClient }) => {
@@ -53,15 +53,18 @@ export interface ClusterLifecycleEvent {
* * did-add-cluster - A cluster has been successfully added.
* * did-change-access - The logged-in user has changed, or their roles,
* or access requests have been updated.
* * will-logout - The user is about to be logged out.
* * will-logout-and-remove - The user is about to be logged out
* and their profile (cluster) will be removed.
* * did-change-proxy-host - The proxy address saved in the profile has changed.
* * will-logout - The user is about to be logged out, but Connect should
* keep the workspace as remembered cluster state.
* * will-forget-cluster - The user is about to forget the cluster in
* Connect. Its workspace and profile will be removed.
*/
op:
| 'did-add-cluster'
| 'did-change-access'
| 'did-change-proxy-host'
| 'will-logout'
| 'will-logout-and-remove';
| 'will-forget-cluster';
}
export interface ProfileWatcherError {
@@ -142,15 +145,44 @@ export class ClusterLifecycleManager {
}
}
async logoutAndRemoveCluster(uri: RootClusterUri): Promise<void> {
this.logger.info('Logging out and removing cluster', { uri });
/**
* Logs out from the cluster without removing the tsh profile.
* Session credentials are cleared, but the cluster remains visible in both
* Connect and tsh.
*/
async logoutCluster(uri: RootClusterUri): Promise<void> {
this.logger.info('Logging out cluster', { uri });
try {
await this.sendRendererEvent({ op: 'will-logout-and-remove', uri });
this.onBeforeRemove(uri);
await this.clusterStore.logoutAndRemove(uri);
this.logger.info('Logged out and removed cluster', { uri });
await this.sendRendererEvent({ op: 'will-logout', uri });
await this.clusterStore.logout(uri, { removeProfile: false });
this.logger.info('Logged out cluster', { uri });
} catch (error) {
this.logger.error('Failed to log out and remove cluster', { uri }, error);
this.logger.error('Failed to log out cluster', { uri }, error);
throw error;
}
}
/**
* Forgets the cluster in Connect and removes its tsh profile.
* If this cluster was selected to manage app updates, clears that selection.
*/
async forgetCluster(uri: RootClusterUri): Promise<void> {
this.logger.info('Forgetting cluster', { uri });
try {
await this.sendRendererEvent({ op: 'will-forget-cluster', uri });
// Clear the managing cluster only when the cluster is forgotten in Connect.
// If the stored managing cluster is missing, auto-update resolution ignores it.
// Once that cluster is added back, the stored selection applies again.
//
// Do not wait for this promise to finish as we don't want to block logout
// on checking app updates.
this.appUpdater.maybeRemoveManagingCluster(uri).catch(error => {
this.logger.error('Failed to remove managing cluster', { uri }, error);
});
await this.clusterStore.logout(uri, { removeProfile: true });
this.logger.info('Forgot cluster', { uri });
} catch (error) {
this.logger.error('Failed to forget cluster', { uri }, error);
throw error;
}
}
@@ -159,17 +191,8 @@ export class ClusterLifecycleManager {
this.logger.info('Syncing cluster', { uri });
try {
const { previous, next } = await this.clusterStore.sync(uri);
const accessChanged = hasAccessChanged(
previous.loggedInUser,
next.loggedInUser
);
if (accessChanged) {
await this.sendRendererEvent({
op: 'did-change-access',
uri: next.uri,
});
}
this.logger.info('Cluster synced', { uri: next.uri, accessChanged });
await this.emitClusterChangedEvents(previous, next);
this.logger.info('Cluster synced', { uri: next.uri });
} catch (error) {
this.logger.error('Failed to sync cluster', { uri }, error);
throw error;
@@ -192,14 +215,6 @@ export class ClusterLifecycleManager {
}
}
private onBeforeRemove(uri: RootClusterUri): void {
// Do not wait for this promise to finish as we don't want to block logout
// on checking app updates.
this.appUpdater.maybeRemoveManagingCluster(uri).catch(error => {
this.logger.error('Failed to remove managing cluster', { uri }, error);
});
}
/**
* If the cluster is connected, try to sync it to get the full profile with details.
* Otherwise, update the cluster with the profile read from disk.
@@ -310,26 +325,21 @@ export class ClusterLifecycleManager {
// user logged in again via tsh and the auth server has disconnect_expired_cert enabled.
await client.clearStaleClusterClients({ rootClusterUri: next.uri });
await this.syncOrUpdateCluster(next);
if (!hasAccessChanged(previous.loggedInUser, next.loggedInUser)) {
this.logger.info('Cluster profile changed without access changes', {
uri: next.uri,
});
return;
}
await this.sendRendererEvent({
op: 'did-change-access',
uri: next.uri,
});
await this.emitClusterChangedEvents(previous, next);
}
private async handleClusterRemoved(cluster: Cluster): Promise<void> {
// When a profile disappears externally (for example by running tsh logout),
// keep the workspace as Connect's remembered cluster state.
await this.sendRendererEvent({
op: 'will-logout-and-remove',
op: 'will-logout',
uri: cluster.uri,
});
this.onBeforeRemove(cluster.uri);
await this.clusterStore.logoutAndRemove(cluster.uri);
// Keep the cluster store in sync with the profile state on disk.
// Once the profile is gone, we must remove if from the store too; otherwise Connect
// could try to use it before recreating the profile and RPCs would fail with errors such
// as "~/.tsh/<proxy-name> no such file or directory".
await this.clusterStore.logout(cluster.uri, { removeProfile: true });
}
private async handleClusterLogout(cluster: Cluster): Promise<void> {
@@ -342,6 +352,25 @@ export class ClusterLifecycleManager {
await this.syncOrUpdateCluster(cluster);
}
private async emitClusterChangedEvents(
previous: Cluster | undefined,
next: Cluster
): Promise<void> {
if (previous?.proxyHost !== next.proxyHost) {
await this.sendRendererEvent({
op: 'did-change-proxy-host',
uri: next.uri,
});
}
if (hasAccessChanged(previous?.loggedInUser, next.loggedInUser)) {
await this.sendRendererEvent({
op: 'did-change-access',
uri: next.uri,
});
}
}
private handleWatcherError(watcherError: ProfileWatcherError): void {
this.logger.info(
'Reporting profile watcher error to renderer',
@@ -18,6 +18,8 @@
import { enableMapSet, enablePatches } from 'immer';
import { LoggedInUser } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient';
import { MockTshClient } from 'teleterm/services/tshd/fixtures/mocks';
import {
@@ -78,14 +80,7 @@ test('adding a cluster does not overwrite an existing one', async () => {
});
test('syncs cluster', async () => {
const mockClient = new MockTshClient();
mockClient.getCluster = () => new MockedUnaryCall(clusterWithDetails);
mockClient.listLeafClusters = () =>
new MockedUnaryCall({ clusters: [leafCluster] });
const clusterStore = new ClusterStore(
() => Promise.resolve(mockClient),
mockWindowsManager
);
const { clusterStore } = getTestSetup();
await clusterStore.sync(cluster.uri);
@@ -94,19 +89,32 @@ test('syncs cluster', async () => {
expect(state.get(leafCluster.uri)).toStrictEqual(leafCluster);
});
test('logs out of cluster', async () => {
const mockClient = new MockTshClient();
mockClient.getCluster = () => new MockedUnaryCall(clusterWithDetails);
mockClient.listLeafClusters = () =>
new MockedUnaryCall({ clusters: [leafCluster] });
const logoutMock = jest.spyOn(mockClient, 'logout');
const clusterStore = new ClusterStore(
() => Promise.resolve(mockClient),
mockWindowsManager
);
test('logs out of cluster and keeps profile', async () => {
const { clusterStore, logoutMock } = getTestSetup();
await clusterStore.sync(cluster.uri);
await clusterStore.logoutAndRemove(cluster.uri);
await clusterStore.logout(cluster.uri);
expect(logoutMock).toHaveBeenCalledWith({
clusterUri: cluster.uri,
removeProfile: false,
});
const state = clusterStore.getState();
expect(state.get(clusterWithDetails.uri)).toEqual(
expect.objectContaining({
...clusterWithDetails,
connected: false,
loggedInUser: LoggedInUser.create(),
})
);
expect(state.get(leafCluster.uri)).toBeUndefined();
});
test('logs out of cluster and removes profile', async () => {
const { clusterStore, logoutMock } = getTestSetup();
await clusterStore.sync(cluster.uri);
await clusterStore.logout(cluster.uri, { removeProfile: true });
expect(logoutMock).toHaveBeenCalledWith({
clusterUri: cluster.uri,
@@ -116,3 +124,16 @@ test('logs out of cluster', async () => {
expect(state.get(clusterWithDetails.uri)).toBeUndefined();
expect(state.get(leafCluster.uri)).toBeUndefined();
});
function getTestSetup() {
const mockClient = new MockTshClient();
mockClient.getCluster = () => new MockedUnaryCall(clusterWithDetails);
mockClient.listLeafClusters = () =>
new MockedUnaryCall({ clusters: [leafCluster] });
const logoutMock = jest.spyOn(mockClient, 'logout');
const clusterStore = new ClusterStore(
() => Promise.resolve(mockClient),
mockWindowsManager
);
return { clusterStore, logoutMock };
}
@@ -22,6 +22,7 @@ import {
Cluster,
ShowResources,
} from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { LoggedInUser } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import Logger from 'teleterm/logger';
import { TshdClient } from 'teleterm/services/tshd';
@@ -76,16 +77,31 @@ export class ClusterStore {
}
/**
* Logs out of the cluster and removes its profile.
* Logs out of the cluster and optionally removes its profile.
* Should only be called via ClusterLifecycleManager.
*/
async logoutAndRemove(uri: RootClusterUri): Promise<void> {
async logout(
uri: RootClusterUri,
{ removeProfile = false }: { removeProfile?: boolean } = {}
): Promise<void> {
const client = await this.getTshdClient();
await client.logout({ clusterUri: uri, removeProfile: true });
await client.logout({ clusterUri: uri, removeProfile });
await this.update(draft => {
for (let d of draft.values()) {
if (routing.belongsToProfile(uri, d.uri)) {
if (!routing.belongsToProfile(uri, d.uri)) {
continue;
}
// Always remove leaf clusters.
if (removeProfile || d.leaf) {
draft.delete(d.uri);
} else {
// TODO(gzdunek): Get this logged-out state from getCluster instead of manually
// mirroring the fields it would return. getCluster cannot be used here yet
// because it always makes remote calls, which fail without a valid cert.
// It should support returning profile-backed data only.
d.connected = false;
d.loggedInUser = LoggedInUser.create();
}
}
});
@@ -204,6 +204,7 @@ export class MockMainProcessClient implements MainProcessClient {
return { cleanup: () => undefined };
}
async logout(): Promise<void> {}
async forgetCluster(): Promise<void> {}
async syncCluster(): Promise<void> {}
async addCluster(): Promise<Cluster> {
return makeRootCluster();
@@ -712,9 +712,11 @@ export default class MainProcess {
);
ipcHandle(MainProcessIpc.Logout, async (_, args) => {
await this.clusterLifecycleManager.logoutAndRemoveCluster(
args.clusterUri
);
await this.clusterLifecycleManager.logoutCluster(args.clusterUri);
});
ipcHandle(MainProcessIpc.ForgetCluster, async (_, args) => {
await this.clusterLifecycleManager.forgetCluster(args.clusterUri);
});
ipcMain.on(MainProcessIpc.InitClusterStoreSubscription, ev => {
@@ -262,6 +262,9 @@ export default function createMainProcessClient(): MainProcessClient {
logout: (clusterUri: RootClusterUri) => {
return ipcInvoke(MainProcessIpc.Logout, { clusterUri });
},
forgetCluster: (clusterUri: RootClusterUri) => {
return ipcInvoke(MainProcessIpc.ForgetCluster, { clusterUri });
},
registerClusterLifecycleHandler(listener): {
cleanup: () => void;
} {
@@ -234,6 +234,7 @@ export type MainProcessClient = {
syncCluster(clusterUri: RootClusterUri): Promise<void>;
syncRootClusters(): Promise<Cluster[]>;
logout(clusterUri: RootClusterUri): Promise<void>;
forgetCluster(clusterUri: RootClusterUri): Promise<void>;
subscribeToClusterStore(listener: (value: ClusterStoreUpdate) => void): {
cleanup: () => void;
};
@@ -377,6 +378,7 @@ export enum MainProcessIpc {
AddCluster = 'main-process-add-cluster',
SyncRootClusters = 'main-process-sync-root-clusters',
Logout = 'main-process-logout',
ForgetCluster = 'main-process-forget-cluster',
RegisterClusterLifecycleHandler = 'main-process-register-cluster-lifecycle-handler',
}
@@ -36,6 +36,7 @@ import * as tsh from './types';
export const rootClusterUri = '/clusters/teleport-local.com';
export const leafClusterUri = `${rootClusterUri}/leaves/leaf`;
export const rootClusterProxyHost = 'teleport-local.com:3080';
export const makeServer = (props: Partial<tsh.Server> = {}): tsh.Server => ({
uri: `${rootClusterUri}/servers/1234abcd-1234-abcd-1234-abcd1234abcd`,
@@ -118,7 +119,7 @@ export const makeRootCluster = (
name: 'teleport-local',
connected: true,
leaf: false,
proxyHost: 'teleport-local.com:3080',
proxyHost: rootClusterProxyHost,
authClusterId: 'fefe3434-fefe-3434-fefe-3434fefe3434',
loggedInUser: makeLoggedInUser(),
proxyVersion: '11.1.0',
@@ -39,6 +39,7 @@ test('fetching requestable roles for servers uses UUID, not hostname', async ()
appContext.clustersService.setState(draftState => {
draftState.clusters.set(rootClusterUri, cluster);
});
appContext.workspacesService.addWorkspace(cluster);
await appContext.workspacesService.setActiveWorkspace(rootClusterUri);
await appContext.workspacesService
.getWorkspaceAccessRequestsService(rootClusterUri)
@@ -76,6 +77,7 @@ test('fetching requestable roles for a kube_cluster resource without specifying
appContext.clustersService.setState(draftState => {
draftState.clusters.set(rootClusterUri, cluster);
});
appContext.workspacesService.addWorkspace(cluster);
await appContext.workspacesService.setActiveWorkspace(rootClusterUri);
await appContext.workspacesService
.getWorkspaceAccessRequestsService(rootClusterUri)
@@ -120,6 +122,7 @@ test(`fetching requestable roles for a kube cluster's namespaces only creates re
appContext.clustersService.setState(draftState => {
draftState.clusters.set(rootClusterUri, cluster);
});
appContext.workspacesService.addWorkspace(cluster);
await appContext.workspacesService.setActiveWorkspace(rootClusterUri);
await appContext.workspacesService
.getWorkspaceAccessRequestsService(rootClusterUri)
@@ -200,6 +203,7 @@ test('after creating an access request, pending requests and specifiable fields
appContext.clustersService.setState(draftState => {
draftState.clusters.set(rootClusterUri, cluster);
});
appContext.workspacesService.addWorkspace(cluster);
await appContext.workspacesService.setActiveWorkspace(rootClusterUri);
await appContext.workspacesService
.getWorkspaceAccessRequestsService(rootClusterUri)
@@ -318,6 +322,7 @@ test('updating kube namespaces', async () => {
appContext.clustersService.setState(draftState => {
draftState.clusters.set(rootClusterUri, cluster);
});
appContext.workspacesService.addWorkspace(cluster);
await appContext.workspacesService.setActiveWorkspace(rootClusterUri);
await appContext.workspacesService
.getWorkspaceAccessRequestsService(rootClusterUri)
+7 -6
View File
@@ -27,6 +27,7 @@ import Logger, { NullService } from 'teleterm/logger';
import { makeRootCluster } from 'teleterm/services/tshd/testHelpers';
import { App } from 'teleterm/ui/App';
import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
import { makeWorkspace } from 'teleterm/ui/services/workspacesService/testHelpers';
import { IAppContext } from 'teleterm/ui/types';
mockIntersectionObserver();
@@ -73,16 +74,16 @@ test('activating a workspace via deep link overrides the previously active works
.mockReturnValue({
rootClusterUri: previouslyActiveCluster.uri,
workspaces: {
[previouslyActiveCluster.uri]: {
[previouslyActiveCluster.uri]: makeWorkspace({
localClusterUri: previouslyActiveCluster.uri,
documents: [],
location: undefined,
},
[deepLinkCluster.uri]: {
}),
[deepLinkCluster.uri]: makeWorkspace({
localClusterUri: deepLinkCluster.uri,
documents: [],
location: undefined,
},
}),
},
});
appContext.mainProcessClient.configService.set(
@@ -174,7 +175,7 @@ test.each<{
.mockReturnValue({
rootClusterUri: rootCluster.uri,
workspaces: {
[rootCluster.uri]: {
[rootCluster.uri]: makeWorkspace({
localClusterUri: rootCluster.uri,
documents: [
{
@@ -187,7 +188,7 @@ test.each<{
},
],
location: undefined,
},
}),
},
});
appContext.mainProcessClient.configService.set(
@@ -43,7 +43,14 @@ export const AppInitializer = () => {
// activate it.
const rootClusterUri =
appContext.workspacesService.getRestoredState()?.rootClusterUri;
if (rootClusterUri) {
if (
rootClusterUri &&
// If the previously active workspace no longer has a cluster, start without an
// active workspace so ClusterConnectPanel is shown. This can happen when the
// profile was removed outside Connect. Recreating the profile should be a
// user-initiated reconnect action, not an automatic startup side effect.
appContext.clustersService.findCluster(rootClusterUri)
) {
void appContext.workspacesService.setActiveWorkspace(rootClusterUri);
}
appContext.mainProcessClient.signalUserInterfaceReadiness({
@@ -58,7 +58,7 @@ export function useClusterLogin(props: Props) {
const cluster = clustersService.findCluster(clusterUri);
const refAbortCtrl = useRef<AbortController>(null);
const loggedInUserName =
props.prefill.username || cluster.loggedInUser?.name || null;
props.prefill.username || cluster?.loggedInUser?.name || null;
const [ssoPrompt, setSsoPrompt] = useState<SsoPrompt>('no-prompt');
const [passwordlessLoginState, setPasswordlessLoginState] =
useState<PasswordlessLoginState>();
@@ -96,6 +96,7 @@ it('opens cluster connect dialog if the cluster is not added yet', async () => {
clustersService.setState(draft => {
draft.clusters.set(cluster.uri, { ...cluster, connected: true });
});
workspacesService.addWorkspace(cluster);
dialog.onSuccess(dialog.clusterUri);
@@ -119,6 +120,7 @@ it('switches to the workspace if the cluster already exists', async () => {
clustersService.setState(draft => {
draft.clusters.set(cluster.uri, { ...cluster, connected: true });
});
workspacesService.addWorkspace(cluster);
await launchDeepLink(appCtx, auxCtx, successResult);
@@ -136,6 +138,7 @@ it('does not switch workspaces if the user does not log in to the cluster when a
clustersService.setState(draft => {
draft.clusters.set(cluster.uri, { ...cluster });
});
workspacesService.addWorkspace(cluster);
jest.spyOn(modalsService, 'openRegularDialog').mockImplementation(dialog => {
if (dialog.kind !== 'cluster-connect') {
@@ -254,6 +254,7 @@ const testSetup = (
draftState.clusters.set(rootClusterUri, cluster);
draftState.clusters.set(leafCluster.uri, leafCluster);
});
appContext.workspacesService.addWorkspace(cluster);
appContext.workspacesService.setActiveWorkspace(rootClusterUri);
const documentsService =
appContext.workspacesService.getWorkspaceDocumentService(rootClusterUri);
@@ -17,6 +17,7 @@
*/
import { MutableRefObject, useMemo } from 'react';
import { useCallback } from 'react';
import { createPortal } from 'react-dom';
import styled from 'styled-components';
@@ -42,6 +43,7 @@ import { DocumentGatewayApp } from 'teleterm/ui/DocumentGatewayApp';
import { DocumentGatewayCliClient } from 'teleterm/ui/DocumentGatewayCliClient';
import { DocumentGatewayKube } from 'teleterm/ui/DocumentGatewayKube';
import { DocumentTerminal } from 'teleterm/ui/DocumentTerminal';
import { useStoreSelector } from 'teleterm/ui/hooks/useStoreSelector';
import * as types from 'teleterm/ui/services/workspacesService';
import {
DocumentsService,
@@ -60,6 +62,14 @@ export function DocumentsRenderer(props: {
desktopSessionControlsRef: MutableRefObject<HTMLDivElement>;
}) {
const { workspacesService } = useAppContext();
const clusters = useStoreSelector(
'clustersService',
useCallback(state => state.clusters, [])
);
const workspaces = useStoreSelector(
'workspacesService',
useCallback(state => state.workspaces, [])
);
function renderDocuments(documentsService: DocumentsService) {
return documentsService.getDocuments().map(doc => {
@@ -75,24 +85,25 @@ export function DocumentsRenderer(props: {
});
}
const workspaces = useMemo(
const workspacesWithClusters = useMemo(
() =>
Object.entries(workspacesService.getWorkspaces()).map(
([clusterUri, workspace]: [RootClusterUri, Workspace]) => ({
Object.entries(workspaces)
// Workspaces can outlive their clusters. Render only those that have an accompanying cluster available.
.filter(([clusterUri]) => clusters.has(clusterUri))
.map(([clusterUri, workspace]: [RootClusterUri, Workspace]) => ({
rootClusterUri: clusterUri,
localClusterUri: workspace.localClusterUri,
documentsService:
workspacesService.getWorkspaceDocumentService(clusterUri),
accessRequestsService:
workspacesService.getWorkspaceAccessRequestsService(clusterUri),
})
),
[workspacesService.getWorkspaces()]
})),
[workspaces, clusters, workspacesService]
);
return (
<>
{workspaces.map(workspace => (
{workspacesWithClusters.map(workspace => (
<DocumentsContainer
isVisible={
workspace.rootClusterUri === workspacesService.getRootClusterUri()
@@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useCallback, useEffect, useRef } from 'react';
import { useEffect, useRef } from 'react';
import styled from 'styled-components';
import {
@@ -30,37 +30,33 @@ import {
Text,
} from 'design';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import { NullKeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation/KeyboardArrowsNavigation';
import { useStoreSelector } from 'teleterm/ui/hooks/useStoreSelector';
import { TshHomeMigrationBanner } from 'teleterm/ui/TopBar/Identity';
import { ClusterList } from 'teleterm/ui/TopBar/Identity/IdentityList/IdentityList';
import { RootClusterUri } from 'teleterm/ui/uri';
import {
TshHomeMigrationBanner,
useIdentity,
IdentityList,
} from 'teleterm/ui/TopBar/Identity';
export function ClusterConnectPanel() {
const ctx = useAppContext();
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 {
// ClusterConnectPanel is rendered only when there is no active workspace, so
// the hook's "other workspaces" are all available workspaces.
otherWorkspaces: availableWorkspaces,
logout,
forget,
addCluster,
changeWorkspace,
} = useIdentity();
const containerRef = useRef<HTMLDivElement>(null);
// Focus the first item.
const hasCluster = !!rootClusters.length;
const hasAnyWorkspaces = !!availableWorkspaces.length;
useEffect(() => {
if (hasCluster) {
if (hasAnyWorkspaces) {
containerRef.current.querySelector('li').focus();
}
}, [hasCluster]);
}, [hasAnyWorkspaces]);
return (
<ScrollingContainer>
@@ -72,7 +68,7 @@ export function ClusterConnectPanel() {
alignItems="center"
>
<ResourceIcon width="120px" name="server" mb={3} />
{hasCluster ? (
{hasAnyWorkspaces ? (
<Flex flexDirection="column">
<H2>Clusters</H2>
<P2 color="text.slightlyMuted" mb={2}>
@@ -101,10 +97,12 @@ export function ClusterConnectPanel() {
}
`}
>
<ClusterList
clusters={rootClusters}
onAdd={add}
onSelect={connect}
<IdentityList
items={availableWorkspaces}
onAdd={addCluster}
onSelect={changeWorkspace}
onLogout={logout}
onForget={forget}
/>
</Flex>
</NullKeyboardArrowsNavigation>
@@ -116,7 +114,7 @@ export function ClusterConnectPanel() {
Connect an existing Teleport cluster <br /> to start using
Teleport Connect.
</Text>
<ButtonPrimary size="large" onClick={add}>
<ButtonPrimary size="large" onClick={addCluster}>
Connect
</ButtonPrimary>
</>
@@ -88,6 +88,7 @@ const meta: Meta<StoryProps> = {
const hasClusterWithLoggedInUser =
props.activeCluster && (hasOrange || hasViolet);
if (hasClusterWithLoggedInUser) {
clusters[0].connected = true;
clusters[0].loggedInUser = makeLoggedInUser({
...clusters[0].loggedInUser,
validUntil: Timestamp.fromDate(
@@ -165,6 +166,7 @@ const clusterViolet = makeRootCluster({
const clusterGreen = makeRootCluster({
name: 'green',
loggedInUser: undefined,
connected: false,
uri: '/clusters/green',
});
@@ -183,9 +185,9 @@ const OpenIdentityPopover = (props: {
props.clusters.forEach(c => {
ctx.addRootCluster(c);
});
ctx.workspacesService.addWorkspace(clusterGreen.uri);
ctx.workspacesService.addWorkspace(clusterViolet.uri);
ctx.workspacesService.addWorkspace(clusterOrange.uri);
ctx.workspacesService.addWorkspace(clusterGreen);
ctx.workspacesService.addWorkspace(clusterViolet);
ctx.workspacesService.addWorkspace(clusterOrange);
ctx.workspacesService.setState(draftState => {
draftState.rootClusterUri = props.activeClusterUri;
draftState.workspaces[clusterGreen.uri].color = 'green';
@@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { screen } from '@testing-library/react';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from 'design/utils/testing';
@@ -121,6 +121,91 @@ test('roles list remembers it was expanded', async () => {
expect(await screen.findByText('requests-reviewer')).toBeVisible();
});
test('shows each identity row with the correct profile status and action', async () => {
const appContext = new MockAppContext();
const activeCluster = makeRootCluster({
uri: '/clusters/active',
loggedInUser: makeLoggedInUser({ name: 'active-user' }),
});
const connectedCluster = makeRootCluster({
uri: '/clusters/connected',
loggedInUser: makeLoggedInUser({
name: 'alice',
}),
});
const expiredCluster = makeRootCluster({
uri: '/clusters/expired',
connected: false,
loggedInUser: makeLoggedInUser({
name: 'expired-user',
}),
});
const disconnectedCluster = makeRootCluster({
uri: '/clusters/disconnected',
connected: false,
loggedInUser: undefined,
});
const executeCommandSpy = jest.spyOn(
appContext.commandLauncher,
'executeCommand'
);
const forgetClusterSpy = jest.spyOn(
appContext.mockMainProcessClient,
'forgetCluster'
);
appContext.addRootCluster(activeCluster);
appContext.addRootCluster(connectedCluster, { noActivate: true });
appContext.addRootCluster(expiredCluster, { noActivate: true });
appContext.addRootCluster(disconnectedCluster, { noActivate: true });
appContext.workspacesService.addWorkspace({
uri: '/clusters/orphan',
proxyHost: 'this-is-orphaned-cluster.com',
});
render(
<MockAppContextProvider appContext={appContext}>
<IdentityContainer />
</MockAppContextProvider>
);
await toggleProfiles();
expect(
within(await screen.findByTitle('Switch to connected')).getByText('alice')
).toBeVisible();
expect(
within(await screen.findByTitle('Switch to expired')).getByText(
'expired-user · Session expired'
)
).toBeVisible();
expect(
within(await screen.findByTitle('Switch to disconnected')).getByText(
'Not logged in'
)
).toBeVisible();
expect(
within(await screen.findByTitle('Switch to orphan')).getByText('In history')
).toBeVisible();
await userEvent.click(screen.getByTitle('Log out from connected'));
expect(executeCommandSpy).toHaveBeenCalledWith('cluster-logout', {
clusterUri: '/clusters/connected',
});
await toggleProfiles();
await userEvent.click(screen.getByTitle('Forget disconnected'));
expect(forgetClusterSpy).toHaveBeenCalledWith('/clusters/disconnected');
await toggleProfiles();
await userEvent.click(screen.getByTitle('Forget expired'));
expect(forgetClusterSpy).toHaveBeenCalledWith('/clusters/expired');
await toggleProfiles();
await userEvent.click(screen.getByTitle('Forget orphan'));
expect(forgetClusterSpy).toHaveBeenCalledWith('/clusters/orphan');
});
async function toggleProfiles() {
await userEvent.click(await screen.findByTitle(/Open Profiles/));
}
@@ -33,31 +33,34 @@ import {
useKeyboardShortcuts,
} from 'teleterm/ui/services/keyboardShortcuts';
import { ActiveCluster, ClusterList } from './IdentityList/IdentityList';
import { ActiveCluster, IdentityList } from './IdentityList/IdentityList';
import { IdentitySelector } from './IdentitySelector/IdentitySelector';
import { useIdentity } from './useIdentity';
export function IdentityContainer() {
const {
activeRootCluster,
rootClusters,
changeRootCluster,
activeWorkspaceCluster,
otherWorkspaces,
changeWorkspace,
logout,
addCluster,
forget,
refreshCluster,
changeColor,
} = useIdentity();
const selectorRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const { getLabelWithAccelerator } = useKeyboardShortcutFormatters();
const hasClusters = activeRootCluster || rootClusters.length;
const hasAnyWorkspaces = Boolean(
activeWorkspaceCluster || otherWorkspaces.length
);
const togglePopoverOrAddCluster = useCallback(() => {
if (hasClusters) {
if (hasAnyWorkspaces) {
setOpen(o => !o);
} else {
addCluster();
}
}, [addCluster, hasClusters]);
}, [addCluster, hasAnyWorkspaces]);
useKeyboardShortcuts(
useMemo(
@@ -84,7 +87,7 @@ export function IdentityContainer() {
}
const deviceTrustStatus = calculateDeviceTrustStatus(
activeRootCluster?.loggedInUser
activeWorkspaceCluster?.loggedInUser
);
const activeColor = useStoreSelector(
'workspacesService',
@@ -97,7 +100,7 @@ export function IdentityContainer() {
ref={selectorRef}
onClick={togglePopoverOrAddCluster}
open={open}
activeCluster={activeRootCluster}
activeCluster={activeWorkspaceCluster}
activeColor={activeColor}
makeTitle={makeTitle}
deviceTrustStatus={deviceTrustStatus}
@@ -112,24 +115,27 @@ export function IdentityContainer() {
updatePositionOnChildResize
>
<Container>
{activeRootCluster && (
{activeWorkspaceCluster && (
<ActiveCluster
activeCluster={activeRootCluster}
activeCluster={activeWorkspaceCluster}
activeColor={activeColor}
onChangeColor={changeColor}
onLogout={withClose(() => logout(activeRootCluster.uri))}
onRefresh={withClose(() => refreshCluster(activeRootCluster.uri))}
onLogout={withClose(() => logout(activeWorkspaceCluster.uri))}
onRefresh={withClose(() =>
refreshCluster(activeWorkspaceCluster.uri)
)}
deviceTrustStatus={deviceTrustStatus}
/>
)}
<TshHomeMigrationBanner />
<KeyboardArrowsNavigation>
{focusGrabber}
<ClusterList
clusters={rootClusters}
onSelect={withClose(changeRootCluster)}
<IdentityList
items={otherWorkspaces}
onSelect={withClose(changeWorkspace)}
onLogout={withClose(logout)}
onAdd={withClose(addCluster)}
onForget={withClose(forget)}
/>
</KeyboardArrowsNavigation>
</Container>
@@ -35,9 +35,10 @@ import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { ProfileStatusError } from 'teleterm/ui/components/ProfileStatusError';
import { usePersistedState } from 'teleterm/ui/hooks/usePersistedState';
import { WorkspaceColor } from 'teleterm/ui/services/workspacesService';
import { DeviceTrustStatus } from 'teleterm/ui/TopBar/Identity/Identity';
import { RootClusterUri, routing } from 'teleterm/ui/uri';
import { DeviceTrustStatus } from '../Identity';
import { IdentityItem } from '../useIdentity';
import { ColorPicker } from './ColorPicker';
import {
AddClusterItem,
@@ -71,7 +72,7 @@ export function ActiveCluster(props: {
<Flex gap={4} justifyContent="space-between">
<Flex alignItems="center" flex={1} minWidth="0" gap={2}>
<ColorPicker
letter={getProfileNameLetter(props.activeCluster)}
letter={getProfileNameLetter(props.activeCluster.uri)}
color={props.activeColor}
setColor={props.onChangeColor}
/>
@@ -149,26 +150,41 @@ export function ActiveCluster(props: {
);
}
export function ClusterList(props: {
clusters: Cluster[];
export function IdentityList(props: {
items: IdentityItem[];
onSelect(clusterUri: RootClusterUri): void;
onLogout?(clusterUri: RootClusterUri): void;
onLogout(clusterUri: RootClusterUri): void;
onForget(clusterUri: RootClusterUri): void;
onAdd(): void;
}) {
return (
<>
{props.clusters.map((cluster, index) => (
<IdentityListItem
key={cluster.uri}
index={index}
cluster={cluster}
onSelect={() => props.onSelect(cluster.uri)}
onLogout={
props.onLogout ? () => props.onLogout(cluster.uri) : undefined
}
/>
))}
<AddClusterItem index={props.clusters.length} onClick={props.onAdd} />
{props.items
.toSorted((a, b) => {
return (
// Puts items with profile first, then sorts equal groups alphabetically.
Number(!!b.cluster) - Number(!!a.cluster) ||
routing
.parseClusterName(a.uri)
.localeCompare(routing.parseClusterName(b.uri))
);
})
.map((identityItem, index) => {
const { cluster } = identityItem;
return (
<IdentityListItem
key={identityItem.uri}
uri={identityItem.uri}
index={index}
color={identityItem.workspace.color}
cluster={cluster}
onSelect={() => props.onSelect(identityItem.uri)}
onLogout={() => props.onLogout(identityItem.uri)}
onForget={() => props.onForget(identityItem.uri)}
/>
);
})}
<AddClusterItem index={props.items.length} onClick={props.onAdd} />
</>
);
}
@@ -15,42 +15,36 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useCallback } from 'react';
import styled from 'styled-components';
import { ButtonText, Flex, P3, Text } from 'design';
import { Logout } from 'design/Icon';
import { type IconComponentType, Logout, Trash } 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 { useStoreSelector } from 'teleterm/ui/hooks/useStoreSelector';
import { WorkspaceColor } from 'teleterm/ui/services/workspacesService';
import { routing } from 'teleterm/ui/uri';
import { RootClusterUri, routing } from 'teleterm/ui/uri';
import { UserIcon } from '../IdentitySelector/UserIcon';
export function IdentityListItem(props: {
index: number;
cluster: Cluster;
uri: RootClusterUri;
color: WorkspaceColor;
cluster: Cluster | undefined;
onSelect(): void;
/** If defined, the logout button is rendered. */
onLogout?(): void;
onLogout(): void;
onForget(): void;
}) {
const { isActive } = useKeyboardArrowsNavigation({
index: props.index,
onRun: props.onSelect,
});
const workspaceColor = useStoreSelector(
'workspacesService',
useCallback(
state => state.workspaces[props.cluster.uri]?.color,
[props.cluster.uri]
)
);
const profileName = routing.parseClusterName(props.cluster.uri);
const profileName = routing.parseClusterName(props.uri);
const subtitle = getSubtitle(props.cluster);
return (
<StyledListItem
@@ -63,33 +57,28 @@ export function IdentityListItem(props: {
>
<Flex width="100%" justifyContent="space-between">
<WithIconItem
letter={getProfileNameLetter(props.cluster)}
color={workspaceColor}
letter={getProfileNameLetter(props.uri)}
color={props.color}
title={profileName}
subtitle={props.cluster.loggedInUser?.name}
subtitle={subtitle}
/>
{props.onLogout && (
<ButtonText
intent="danger"
size="small"
className="logout"
css={`
visibility: hidden;
transition: none;
`}
p={1}
ml={4}
title={`Log out from ${profileName}`}
onClick={e => {
e.stopPropagation();
props.onLogout();
}}
>
<Logout size="small" />
</ButtonText>
)}
<Flex alignItems="center" gap={2}>
{props.cluster?.connected ? (
<SideButton
Icon={Logout}
title={`Log out from ${profileName}`}
onClick={props.onLogout}
/>
) : (
<SideButton
Icon={Trash}
title={`Forget ${profileName}`}
onClick={props.onForget}
/>
)}
</Flex>
</Flex>
{props.cluster.profileStatusError && (
{props.cluster?.profileStatusError && (
<ProfileStatusError
error={props.cluster.profileStatusError}
// Align the error with the user icon.
@@ -116,6 +105,33 @@ export function AddClusterItem(props: { index: number; onClick(): void }) {
);
}
function SideButton(props: {
title: string;
Icon: IconComponentType;
onClick: () => void;
}) {
return (
<ButtonText
intent="danger"
size="small"
className="logout"
css={`
visibility: hidden;
transition: none;
`}
p={1}
ml={4}
title={props.title}
onClick={e => {
e.stopPropagation();
props.onClick();
}}
>
<props.Icon size="medium" />
</ButtonText>
);
}
const StyledListItem = styled(ListItem)`
padding: ${props => props.theme.space[2]}px ${props => props.theme.space[3]}px;
flex-direction: column;
@@ -169,6 +185,32 @@ export function TitleAndSubtitle(props: { title: string; subtitle?: string }) {
);
}
export function getProfileNameLetter(cluster: Cluster): string {
return routing.parseClusterName(cluster.uri).at(0);
export function getProfileNameLetter(uri: RootClusterUri): string {
return routing.parseClusterName(uri).at(0);
}
/**
* Maps cluster/profile state to the subtitle shown in the identity list.
*
* | How this state happened | Internal state | Subtitle |
* | ----------------------------------------------------------------------------------------------------| -------------------------------------------------------- | -------------------------- |
* | `tsh logout` removed the tsh profile, but Connect still remembers the workspace. | `cluster` is undefined. | In history |
* | `tsh logout --proxy=... --user=...` or Connect logout removed the credentials but kept the profile. | `cluster` exists, but `loggedInUser.name` is empty. | Not logged in |
* | The user's credentials expired. | `cluster` has `loggedInUser.name`, but is not connected. | `<user> · Session expired` |
* | The user is currently logged in. | `cluster` has `loggedInUser.name` and is connected. | `<user>` |
*/
function getSubtitle(cluster: Cluster | undefined): string {
if (!cluster) {
return 'In history';
}
if (!cluster.loggedInUser?.name) {
return 'Not logged in';
}
if (!cluster.connected) {
return `${cluster.loggedInUser.name} · Session expired`;
}
return cluster.loggedInUser.name;
}
@@ -64,7 +64,7 @@ export const IdentitySelector = forwardRef<
<Box>
<UserIcon
color={props.activeColor}
letter={getProfileNameLetter(props.activeCluster)}
letter={getProfileNameLetter(props.activeCluster.uri)}
/>
{props.deviceTrustStatus === 'requires-enrollment' && (
<ConnectionStatusIndicator
@@ -20,3 +20,5 @@ export {
IdentityContainer as Identity,
TshHomeMigrationBanner,
} from './Identity';
export { useIdentity } from './useIdentity';
export { IdentityList } from './IdentityList/IdentityList';
@@ -18,21 +18,34 @@
import { useCallback } from 'react';
import { Cluster } from 'teleterm/services/tshd/types';
import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { getErrorMessage } from 'shared/utils/error';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import {
useWorkspaceServiceState,
WorkspaceColor,
} from 'teleterm/ui/services/workspacesService';
import { useStoreSelector } from 'teleterm/ui/hooks/useStoreSelector';
import { WorkspaceColor } from 'teleterm/ui/services/workspacesService';
import { Workspace } from 'teleterm/ui/services/workspacesService';
import { RootClusterUri } from 'teleterm/ui/uri';
export function useIdentity() {
const ctx = useAppContext();
ctx.clustersService.useState();
useWorkspaceServiceState();
const workspaces = useStoreSelector(
'workspacesService',
useCallback(state => state.workspaces, [])
);
const clusters = useStoreSelector(
'clustersService',
useCallback(state => state.clusters, [])
);
const activeClusterUri = useStoreSelector(
'workspacesService',
useCallback(state => state.rootClusterUri, [])
);
const activeWorkspaceCluster =
activeClusterUri && clusters.get(activeClusterUri);
async function changeRootCluster(clusterUri: RootClusterUri): Promise<void> {
async function changeWorkspace(clusterUri: RootClusterUri): Promise<void> {
await ctx.workspacesService.setActiveWorkspace(clusterUri);
}
@@ -48,9 +61,15 @@ export function useIdentity() {
ctx.commandLauncher.executeCommand('cluster-logout', { clusterUri });
}
const activeClusterUri = ctx.workspacesService.getRootClusterUri();
function getActiveRootCluster(): Cluster | undefined {
return ctx.clustersService.findCluster(activeClusterUri);
async function forget(clusterUri: RootClusterUri): Promise<void> {
try {
await ctx.mainProcessClient.forgetCluster(clusterUri);
} catch (err) {
ctx.notificationsService.notifyError({
title: 'Failed to forget cluster',
description: getErrorMessage(err),
});
}
}
function changeColor(color: WorkspaceColor): undefined {
@@ -61,18 +80,28 @@ export function useIdentity() {
ctx.workspacesService.changeWorkspaceColor(clusterUri, color);
}
const rootClusters = ctx.clustersService
.getClusters()
.filter(c => !c.leaf)
.filter(c => c.uri !== activeClusterUri);
const otherWorkspaces: IdentityItem[] = Object.entries(workspaces)
.filter(([uri]) => uri !== activeClusterUri)
.map(([uri, workspace]) => ({
uri,
workspace: workspace,
cluster: clusters.get(uri),
}));
return {
changeRootCluster,
changeWorkspace,
addCluster,
refreshCluster,
logout,
forget,
changeColor,
activeRootCluster: getActiveRootCluster(),
rootClusters,
activeWorkspaceCluster,
otherWorkspaces,
};
}
export interface IdentityItem {
uri: RootClusterUri;
workspace: Workspace;
cluster: Cluster | undefined;
}
+18 -2
View File
@@ -229,7 +229,23 @@ export default class AppContext implements IAppContext {
const task = processingQueue.then(async () => {
switch (op) {
case 'did-add-cluster':
return this.workspacesService.addWorkspace(uri);
const cluster = this.clustersService.findCluster(uri);
if (!cluster) {
throw new Error(`Cluster ${uri} does not exist`);
}
return this.workspacesService.addWorkspace({
uri,
proxyHost: cluster.proxyHost,
});
case 'did-change-proxy-host':
const updatedCluster = this.clustersService.findCluster(uri);
if (!updatedCluster) {
throw new Error(`Cluster ${uri} does not exist`);
}
return this.workspacesService.updateWorkspaceProxyHost({
uri,
proxyHost: updatedCluster.proxyHost,
});
case 'did-change-access':
if (!this.clustersService.findCluster(uri)?.connected) {
// Only refresh resources when the cluster is connected.
@@ -238,7 +254,7 @@ export default class AppContext implements IAppContext {
return this.resourceRefreshListener(uri);
case 'will-logout':
return cleanUpBeforeLogout(this, uri, { removeWorkspace: false });
case 'will-logout-and-remove':
case 'will-forget-cluster':
return cleanUpBeforeLogout(this, uri, { removeWorkspace: true });
default:
op satisfies never;
@@ -17,7 +17,7 @@
*/
import { IAppContext } from 'teleterm/ui/types';
import { ClusterUri, RootClusterUri } from 'teleterm/ui/uri';
import { ClusterUri, RootClusterUri, routing } from 'teleterm/ui/uri';
const commands = {
'tsh-install': {
@@ -102,11 +102,14 @@ const commands = {
description: '',
async run(ctx: IAppContext, args: { clusterUri: ClusterUri }) {
const { clusterUri } = args;
const rootCluster =
ctx.clustersService.findRootClusterByResource(clusterUri);
await ctx.workspacesService.setActiveWorkspace(rootCluster.uri);
const rootClusterUri = routing.ensureRootClusterUri(clusterUri);
const { isAtDesiredWorkspace } =
await ctx.workspacesService.setActiveWorkspace(rootClusterUri);
if (!isAtDesiredWorkspace) {
return;
}
const documentsService =
ctx.workspacesService.getWorkspaceDocumentService(rootCluster.uri);
ctx.workspacesService.getWorkspaceDocumentService(rootClusterUri);
const doc = documentsService.findClusterDocument(clusterUri);
if (doc) {
documentsService.open(doc.uri);
@@ -62,7 +62,7 @@ export class MockAppContext extends AppContext {
draftState.clusters.set(cluster.uri, cluster);
});
const docs = Array.isArray(doc) ? doc : [doc];
this.workspacesService.addWorkspace(cluster.uri);
this.workspacesService.addWorkspace(cluster);
this.workspacesService.setState(draftState => {
if (!options?.noActivate) {
draftState.rootClusterUri = cluster.uri;
@@ -362,6 +362,10 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
return gateway;
}
async addCluster(proxy: string) {
return await this.mainProcessClient.addCluster(proxy);
}
findCluster(clusterUri: uri.ClusterUri) {
return this.state.clusters.get(clusterUri);
}
@@ -17,6 +17,7 @@
*/
import Logger, { NullService } from 'teleterm/logger';
import { makeWorkspace } from 'teleterm/ui/services/workspacesService/testHelpers';
import { ClustersService } from '../clusters';
import { StatePersistenceService } from '../statePersistence';
@@ -253,7 +254,7 @@ function getTestSetupWithMockedDocuments(documents: Document[]) {
// Insert the documents.
workspacesService.setState(draftState => {
draftState.workspaces['/clusters/localhost'] = {
draftState.workspaces['/clusters/localhost'] = makeWorkspace({
color: 'purple',
accessRequests: {
pending: getEmptyPendingAccessRequest(),
@@ -262,7 +263,7 @@ function getTestSetupWithMockedDocuments(documents: Document[]) {
localClusterUri: '/clusters/localhost',
location: documents[0]?.uri,
documents: documents,
};
});
});
return { workspacesService, connectionTrackerService };
@@ -107,7 +107,11 @@ export class ConnectionTrackerService extends ImmutableStore<ConnectionTrackerSt
this._trackedConnectionOperationsFactory.create(connection);
if (rootClusterUri !== this._workspacesService.getRootClusterUri()) {
await this._workspacesService.setActiveWorkspace(rootClusterUri);
const { isAtDesiredWorkspace } =
await this._workspacesService.setActiveWorkspace(rootClusterUri);
if (!isAtDesiredWorkspace) {
return;
}
}
activate(params);
}
@@ -43,6 +43,7 @@ export type PersistedWorkspace = Omit<
// TODO(gzdunek) DELETE IN v19.0.0: Make the field required by removing the 'color' type below and the omitted 'color' above.
// This only expresses that existing persisted state from older versions might not have color defined.
color?: WorkspaceColor;
proxyHost?: string;
};
export type WorkspacesPersistedState = Omit<
@@ -214,6 +214,7 @@ test('cloud app triggers alert', async () => {
function setTestCluster(appContext: IAppContext): void {
const testCluster = makeRootCluster();
appContext.workspacesService.addWorkspace(testCluster);
appContext.workspacesService.setState(d => {
d.rootClusterUri = testCluster.uri;
});
@@ -182,7 +182,11 @@ export async function setUpAppGateway(
origin: options.telemetry.origin,
});
} else {
await ctx.workspacesService.setActiveWorkspace(rootClusterUri);
const { isAtDesiredWorkspace } =
await ctx.workspacesService.setActiveWorkspace(rootClusterUri);
if (!isAtDesiredWorkspace) {
return;
}
documentsService.add(doc);
documentsService.open(doc.uri);
}
@@ -21,6 +21,7 @@ import {
makeRootCluster,
} from 'teleterm/services/tshd/testHelpers';
import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
import { makeWorkspace } from 'teleterm/ui/services/workspacesService/testHelpers';
import { IAppContext } from 'teleterm/ui/types';
import { connectToDatabase } from './connectToDatabase';
@@ -97,6 +98,7 @@ function setTestCluster(
): void {
appContext.workspacesService.setState(d => {
d.rootClusterUri = cluster.uri;
d.workspaces = { [cluster.uri]: makeWorkspace() };
});
appContext.clustersService.setState(d => {
d.clusters.set(cluster.uri, cluster);
@@ -63,7 +63,11 @@ export async function connectToDatabase(
origin: telemetry.origin,
});
} else {
await ctx.workspacesService.setActiveWorkspace(rootClusterUri);
const { isAtDesiredWorkspace } =
await ctx.workspacesService.setActiveWorkspace(rootClusterUri);
if (!isAtDesiredWorkspace) {
return;
}
documentsService.add(doc);
documentsService.open(doc.uri);
}
@@ -34,7 +34,11 @@ export async function connectToKube(
origin: telemetry.origin,
});
await ctx.workspacesService.setActiveWorkspace(rootClusterUri);
const { isAtDesiredWorkspace } =
await ctx.workspacesService.setActiveWorkspace(rootClusterUri);
if (!isAtDesiredWorkspace) {
return;
}
documentsService.add(doc);
documentsService.open(doc.uri);
}
@@ -42,7 +42,11 @@ export async function connectToServer(
doc.title = `${target.login}@${target.hostname}`;
doc.login = target.login;
await ctx.workspacesService.setActiveWorkspace(rootClusterUri);
const { isAtDesiredWorkspace } =
await ctx.workspacesService.setActiveWorkspace(rootClusterUri);
if (!isAtDesiredWorkspace) {
return;
}
documentsService.add(doc);
documentsService.open(doc.uri);
}
@@ -33,7 +33,11 @@ export async function connectToWindowsDesktop(
}
): Promise<void> {
const rootClusterUri = routing.ensureRootClusterUri(target.uri);
await ctx.workspacesService.setActiveWorkspace(rootClusterUri);
const { isAtDesiredWorkspace } =
await ctx.workspacesService.setActiveWorkspace(rootClusterUri);
if (!isAtDesiredWorkspace) {
return;
}
ctx.workspacesService
.getWorkspaceDocumentService(rootClusterUri)
.openExistingOrAddNew(
@@ -0,0 +1,65 @@
/**
* Teleport
* Copyright (C) 2026 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
rootClusterProxyHost,
rootClusterUri,
} from 'teleterm/services/tshd/testHelpers';
import { PersistedWorkspace } from '../statePersistence';
import { getEmptyPendingAccessRequest } from './accessRequestsService';
import { makeDocumentCluster } from './documentsService/testHelpers';
import {
getDefaultUnifiedResourcePreferences,
Workspace,
} from './workspacesService';
export function makeWorkspace(props?: Partial<Workspace>): Workspace {
const clusterUri = props?.localClusterUri ?? rootClusterUri;
const defaultDocument = makeDocumentCluster({ clusterUri });
return {
accessRequests: {
isBarCollapsed: false,
pending: getEmptyPendingAccessRequest(),
},
color: 'purple',
connectMyComputer: undefined,
documents: [defaultDocument],
hasDocumentsToReopen: false,
localClusterUri: clusterUri,
location: defaultDocument.uri,
proxyHost: rootClusterProxyHost,
unifiedResourcePreferences: getDefaultUnifiedResourcePreferences(),
...props,
};
}
export function makePersistedWorkspace(
props?: Partial<PersistedWorkspace>
): PersistedWorkspace {
const clusterUri = props?.localClusterUri ?? rootClusterUri;
return {
documents: [],
localClusterUri: clusterUri,
location: undefined,
proxyHost: rootClusterProxyHost,
...props,
};
}
@@ -17,12 +17,6 @@
*/
import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb';
import {
AvailableResourceMode,
DefaultTab,
LabelsViewMode,
ViewMode,
} from 'gen-proto-ts/teleport/userpreferences/v1/unified_resource_preferences_pb';
import Logger, { NullService } from 'teleterm/logger';
import { createMockFileStorage } from 'teleterm/services/fileStorage/fixtures/mocks';
@@ -37,16 +31,15 @@ import { ClustersService } from '../clusters';
import { ModalsService } from '../modals';
import { NotificationsService } from '../notifications';
import {
PersistedWorkspace,
StatePersistenceService,
WorkspacesPersistedState,
} from '../statePersistence';
import { getEmptyPendingAccessRequest } from './accessRequestsService';
import {
DocumentCluster,
DocumentsService,
DocumentVnetDiagReport,
} from './documentsService';
import { makePersistedWorkspace, makeWorkspace } from './testHelpers';
import { WorkspacesService, WorkspacesState } from './workspacesService';
beforeAll(() => {
@@ -60,7 +53,7 @@ beforeEach(() => {
describe('restoring workspace', () => {
it('restores the workspace if there is a persisted state for given clusterUri', () => {
const cluster = makeRootCluster();
const testWorkspace: PersistedWorkspace = {
const testWorkspace = makePersistedWorkspace({
localClusterUri: cluster.uri,
documents: [
{
@@ -70,7 +63,7 @@ describe('restoring workspace', () => {
},
],
location: '/docs/some_uri',
};
});
const persistedWorkspace = { [cluster.uri]: testWorkspace };
@@ -82,27 +75,13 @@ describe('restoring workspace', () => {
workspacesService.restorePersistedState();
expect(workspacesService.getWorkspaces()).toStrictEqual({
[cluster.uri]: {
accessRequests: {
pending: {
kind: 'resource',
resources: new Map(),
},
isBarCollapsed: false,
},
color: 'purple',
[cluster.uri]: makeWorkspace({
proxyHost: cluster.proxyHost,
localClusterUri: testWorkspace.localClusterUri,
documents: [expect.objectContaining({ kind: 'doc.cluster' })],
location: expect.any(String),
hasDocumentsToReopen: true,
connectMyComputer: undefined,
unifiedResourcePreferences: {
defaultTab: DefaultTab.ALL,
viewMode: ViewMode.CARD,
labelsViewMode: LabelsViewMode.COLLAPSED,
availableResourceMode: AvailableResourceMode.NONE,
},
},
}),
});
expect(workspacesService.getRestoredState().workspaces).toStrictEqual(
persistedWorkspace
@@ -119,46 +98,98 @@ describe('restoring workspace', () => {
workspacesService.restorePersistedState();
expect(workspacesService.getWorkspaces()).toStrictEqual({
[cluster.uri]: {
accessRequests: {
isBarCollapsed: false,
pending: {
kind: 'resource',
resources: new Map(),
},
},
color: 'purple',
[cluster.uri]: makeWorkspace({
proxyHost: cluster.proxyHost,
localClusterUri: cluster.uri,
documents: [expect.objectContaining({ kind: 'doc.cluster' })],
location: expect.any(String),
hasDocumentsToReopen: false,
connectMyComputer: undefined,
unifiedResourcePreferences: {
defaultTab: DefaultTab.ALL,
viewMode: ViewMode.CARD,
labelsViewMode: LabelsViewMode.COLLAPSED,
availableResourceMode: AvailableResourceMode.NONE,
},
},
}),
});
expect(workspacesService.getRestoredState().workspaces).toStrictEqual({});
});
it('keeps restored workspaces even when no matching cluster exists', () => {
const cluster = makeRootCluster();
const orphanClusterUri = '/clusters/orphan';
const orphanWorkspace = makePersistedWorkspace({
color: 'blue',
localClusterUri: orphanClusterUri,
documents: [
{
kind: 'doc.terminal_shell',
uri: '/docs/some_orphan_doc',
title: '/Users/alice/Downloads',
},
],
location: '/docs/some_orphan_doc',
});
const { workspacesService } = getTestSetup({
cluster,
persistedWorkspaces: {
[orphanClusterUri]: orphanWorkspace,
},
});
workspacesService.restorePersistedState();
expect(workspacesService.getWorkspace(orphanClusterUri)).toBeDefined();
});
it('skips workspaces without a matching cluster or saved proxy host', () => {
const orphanClusterUri = '/clusters/orphan';
const orphanWorkspace = makePersistedWorkspace({
// No stored proxy host.
proxyHost: undefined,
localClusterUri: orphanClusterUri,
});
const { workspacesService } = getTestSetup({
// No matching cluster.
cluster: undefined,
persistedWorkspaces: {
[orphanClusterUri]: orphanWorkspace,
},
});
workspacesService.restorePersistedState();
expect(workspacesService.getWorkspace(orphanClusterUri)).toBeUndefined();
});
it('keeps proxy host for restored workspaces without matching clusters', () => {
const orphanClusterUri = '/clusters/orphan';
const orphanWorkspace = makePersistedWorkspace({
proxyHost: 'orphan.example.com:443',
localClusterUri: orphanClusterUri,
});
const { workspacesService } = getTestSetup({
cluster: undefined,
persistedWorkspaces: {
[orphanClusterUri]: orphanWorkspace,
},
});
workspacesService.restorePersistedState();
expect(workspacesService.getWorkspace(orphanClusterUri).proxyHost).toBe(
orphanWorkspace.proxyHost
);
});
it('restores workspace color from state or assigns if empty', async () => {
const clusterFoo = makeRootCluster({ uri: '/clusters/foo' });
const workspaceFoo: PersistedWorkspace = {
const workspaceFoo = makePersistedWorkspace({
color: 'blue',
localClusterUri: clusterFoo.uri,
documents: [],
location: undefined,
};
});
const clusterBar = makeRootCluster({ uri: '/clusters/bar' });
const workspaceBar: PersistedWorkspace = {
const workspaceBar = makePersistedWorkspace({
color: 'purple',
localClusterUri: clusterBar.uri,
documents: [],
location: undefined,
};
});
const clusterBaz = makeRootCluster({ uri: '/clusters/baz' });
const clusterQux = makeRootCluster({ uri: '/clusters/qux' });
const clusterWaldo = makeRootCluster({ uri: '/clusters/waldo' });
@@ -209,12 +240,7 @@ describe('state persistence', () => {
rootClusterUri: cluster.uri,
isInitialized: true,
workspaces: {
[cluster.uri]: {
accessRequests: {
isBarCollapsed: true,
pending: getEmptyPendingAccessRequest(),
},
color: 'purple',
[cluster.uri]: makeWorkspace({
localClusterUri: cluster.uri,
documents: [
{
@@ -236,7 +262,7 @@ describe('state persistence', () => {
},
],
location: '/docs/authorize_web_session',
},
}),
},
};
const { workspacesService, statePersistenceService } = getTestSetup({
@@ -273,14 +299,8 @@ describe('state persistence', () => {
rootClusterUri: cluster.uri,
isInitialized: true,
workspaces: {
[cluster.uri]: {
color: 'purple',
[cluster.uri]: makeWorkspace({
localClusterUri: cluster.uri,
location: undefined,
accessRequests: {
isBarCollapsed: true,
pending: getEmptyPendingAccessRequest(),
},
documents: [
makeDocumentVnetDiagReport({
report: {
@@ -289,7 +309,7 @@ describe('state persistence', () => {
},
}),
],
},
}),
},
};
@@ -344,6 +364,7 @@ describe('setActiveWorkspace', () => {
cluster,
persistedWorkspaces: {},
});
workspacesService.addWorkspace(cluster);
// Resolve the modal immediately.
jest
@@ -381,6 +402,55 @@ describe('setActiveWorkspace', () => {
expect(workspacesService.getRootClusterUri()).toBeUndefined();
});
it('recreates a missing cluster from workspace proxy host before connecting', async () => {
const clusterUri = '/clusters/foo.example.com';
const proxyHost = 'foo.example.com:443';
const { workspacesService, modalsService, clustersService } = getTestSetup({
cluster: undefined,
persistedWorkspaces: {
[clusterUri]: makePersistedWorkspace({
proxyHost,
localClusterUri: clusterUri,
}),
},
});
workspacesService.restorePersistedState();
expect(clustersService.findCluster(clusterUri)).toBeUndefined();
jest
.spyOn(modalsService, 'openRegularDialog')
.mockImplementation(dialog => {
if (dialog.kind === 'cluster-connect') {
dialog.onCancel();
} else {
throw new Error(`Got unexpected dialog ${dialog.kind}`);
}
return {
closeDialog: () => {},
};
});
const result = await workspacesService.setActiveWorkspace(clusterUri);
expect(clustersService.addCluster).toHaveBeenCalledWith(proxyHost);
expect(clustersService.findCluster(clusterUri)).toEqual(
expect.objectContaining({
uri: clusterUri,
proxyHost,
connected: false,
})
);
expect(modalsService.openRegularDialog).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'cluster-connect',
clusterUri,
}),
expect.any(AbortSignal)
);
expect(result.isAtDesiredWorkspace).toBe(false);
});
it('does not switch the workspace if the login modal gets closed', async () => {
const cluster = makeRootCluster({
connected: false,
@@ -423,6 +493,7 @@ describe('setActiveWorkspace', () => {
cluster: rootCluster,
persistedWorkspaces: {},
});
workspacesService.addWorkspace(rootCluster);
jest.spyOn(notificationsService, 'notifyError');
@@ -442,7 +513,7 @@ describe('setActiveWorkspace', () => {
it('sets location to first document if location points to non-existing document when reopening documents', async () => {
const cluster = makeRootCluster();
const testWorkspace: PersistedWorkspace = {
const testWorkspace = makePersistedWorkspace({
localClusterUri: cluster.uri,
documents: [
{
@@ -457,7 +528,7 @@ describe('setActiveWorkspace', () => {
},
],
location: '/docs/non-existing-doc',
};
});
const { workspacesService, modalsService } = getTestSetup({
cluster,
@@ -503,7 +574,7 @@ describe('setActiveWorkspace', () => {
it('ongoing setActive call is canceled when the method is called again', async () => {
const clusterFoo = makeRootCluster({ uri: '/clusters/foo' });
const clusterBar = makeRootCluster({ uri: '/clusters/bar' });
const workspace1: PersistedWorkspace = {
const workspace1 = makePersistedWorkspace({
localClusterUri: clusterFoo.uri,
documents: [
{
@@ -513,7 +584,7 @@ describe('setActiveWorkspace', () => {
},
],
location: '/docs/non-existing-doc',
};
});
const { workspacesService } = getTestSetup({
cluster: [clusterFoo, clusterBar],
@@ -534,7 +605,7 @@ describe('setActiveWorkspace', () => {
it('opens the documents-reopen dialog in the same tick as setting rootClusterUri', async () => {
const cluster = makeRootCluster();
const testWorkspace: PersistedWorkspace = {
const testWorkspace = makePersistedWorkspace({
localClusterUri: cluster.uri,
documents: [
{
@@ -544,7 +615,7 @@ describe('setActiveWorkspace', () => {
},
],
location: '/docs/terminal_shell_uri',
};
});
const { workspacesService, modalsService } = getTestSetup({
cluster,
@@ -585,6 +656,60 @@ describe('setActiveWorkspace', () => {
});
});
describe('clearWorkspace', () => {
it('preserves proxy host and color for later workspace reuse', () => {
const clusterFoo = makeRootCluster({ uri: '/clusters/foo' });
const clusterBar = makeRootCluster({ uri: '/clusters/bar' });
const { workspacesService } = getTestSetup({
cluster: [clusterFoo, clusterBar],
persistedWorkspaces: {},
});
workspacesService.restorePersistedState();
workspacesService.changeWorkspaceColor(clusterFoo.uri, 'red');
const previousProxyHost = workspacesService.getWorkspace(
clusterFoo.uri
).proxyHost;
const previousLocation = workspacesService.getWorkspace(
clusterFoo.uri
).location;
workspacesService.clearWorkspace(clusterFoo.uri);
const workspace = workspacesService.getWorkspace(clusterFoo.uri);
expect(workspace.color).toBe('red');
expect(workspace.proxyHost).toBe(previousProxyHost);
expect(workspace.documents).toHaveLength(1);
expect(workspace.documents[0]).toEqual(
expect.objectContaining({ kind: 'doc.cluster' })
);
expect(workspace.location).not.toEqual(previousLocation);
});
});
describe('updateWorkspaceProxyHost', () => {
it('updates the remembered proxy host for an existing workspace', () => {
const cluster = makeRootCluster({
uri: '/clusters/foo.example.com',
proxyHost: 'foo.example.com:443',
});
const { workspacesService } = getTestSetup({
cluster,
persistedWorkspaces: {},
});
workspacesService.addWorkspace(cluster);
workspacesService.updateWorkspaceProxyHost({
uri: cluster.uri,
proxyHost: 'proxy.foo.example.com:443',
});
expect(workspacesService.getWorkspace(cluster.uri).proxyHost).toBe(
'proxy.foo.example.com:443'
);
});
});
function getTestSetup(options: {
cluster: tshd.Cluster | tshd.Cluster[] | undefined;
persistedWorkspaces: WorkspacesPersistedState['workspaces'];
@@ -620,6 +745,16 @@ function getTestSetup(options: {
),
getRootClusters: () => normalizedClusters,
syncRootClustersAndCatchErrors: async () => {},
addCluster: jest.fn(async proxy => {
const cluster = makeRootCluster({
uri: `/clusters/${proxy.replace(/:\d+$/, '')}`,
proxyHost: proxy,
connected: false,
loggedInUser: undefined,
});
normalizedClusters.push(cluster);
return cluster;
}),
};
let clusterDocument: DocumentCluster;
@@ -647,6 +782,7 @@ function getTestSetup(options: {
return {
workspacesService,
modalsService,
clustersService,
notificationsService,
statePersistenceService,
};
@@ -27,6 +27,7 @@ import {
UnifiedResourcePreferences,
ViewMode,
} from 'gen-proto-ts/teleport/userpreferences/v1/unified_resource_preferences_pb';
import { getErrorMessage } from 'shared/utils/error';
import { arrayObjectIsEqual } from 'shared/utils/highbar';
import Logger from 'teleterm/logger';
@@ -112,6 +113,11 @@ export interface Workspace {
* This field is not persisted to disk.
*/
hasDocumentsToReopen?: boolean;
/**
* The proxy address used to add this root cluster. It lets Connect reconnect
* a remembered workspace even if the tsh profile is no longer available.
*/
proxyHost: string;
}
export class WorkspacesService extends ImmutableStore<WorkspacesState> {
@@ -298,9 +304,9 @@ export class WorkspacesService extends ImmutableStore<WorkspacesState> {
/**
* setActiveWorkspace changes the active workspace to that of the given root cluster.
* If the root cluster doesn't have a workspace yet, setActiveWorkspace creates a default
* workspace state for the cluster and then asks the user about restoring documents from the
* previous session if there are any.
* The workspace must already exist. If its cluster is missing from the cluster store,
* setActiveWorkspace tries to add it back using the proxy host saved in the workspace state.
* It then asks the user about restoring documents from the previous session if there are any.
* Only one call can be executed at a time. Any ongoing call is canceled when a new one is initiated.
*
* setActiveWorkspace never returns a rejected promise on its own.
@@ -318,8 +324,8 @@ export class WorkspacesService extends ImmutableStore<WorkspacesState> {
* workspace of the given cluster.
*
* setActiveWorkspace never rejects on its own. However, it may fail to switch to the workspace
* if the user closes the cluster connect dialog or if the cluster with the given clusterUri
* wasn't found.
* if the workspace or cluster cannot be found, the cluster cannot be added back, or the user
* closes the cluster connect dialog.
*
* Callsites which don't check this return value were most likely written before this field was
* added. They operate with the assumption that by the time the program gets to the
@@ -339,16 +345,32 @@ export class WorkspacesService extends ImmutableStore<WorkspacesState> {
return { isAtDesiredWorkspace: true };
}
const workspace = this.getWorkspace(clusterUri);
if (!workspace) {
this.logger.error(
`The workspace for cluster ${clusterUri} does not exist`
);
this.notificationsService.notifyError({
title: `Could not switch to cluster ${routing.parseClusterName(clusterUri)}`,
});
return { isAtDesiredWorkspace: false };
}
let cluster = this.clustersService.findCluster(clusterUri);
if (!cluster) {
this.notificationsService.notifyError({
title: 'Could not set cluster as active',
description: `Cluster with URI ${clusterUri} does not exist`,
});
this.logger.warn(
`Could not find cluster with uri ${clusterUri} when changing active cluster`
);
return { isAtDesiredWorkspace: false };
const proxyHost = workspace.proxyHost;
try {
cluster = await this.clustersService.addCluster(proxyHost);
} catch (err) {
this.logger.error('Failed to re-add cluster', err);
this.notificationsService.notifyError({
title: `Could not reconnect to ${proxyHost}. Check that the proxy is reachable and try again.`,
description: getErrorMessage(err),
});
return { isAtDesiredWorkspace: false };
}
}
if (cluster.profileStatusError) {
@@ -392,20 +414,12 @@ export class WorkspacesService extends ImmutableStore<WorkspacesState> {
return { isAtDesiredWorkspace: false };
}
}
// If we don't have a workspace for this cluster, add it.
// TODO(gzdunek): Creating a workspace here might not be necessary
// after we started calling workspacesService.addWorkspace in ClusterAdd.
this.setState(draftState => {
if (!draftState.workspaces[clusterUri]) {
draftState.workspaces[clusterUri] = getWorkspaceDefaultState(
clusterUri,
draftState.workspaces
);
}
draftState.rootClusterUri = clusterUri;
});
const { hasDocumentsToReopen } = this.getWorkspace(clusterUri);
const { hasDocumentsToReopen } = workspace;
if (!hasDocumentsToReopen) {
return { isAtDesiredWorkspace: true };
}
@@ -445,18 +459,44 @@ export class WorkspacesService extends ImmutableStore<WorkspacesState> {
return { isAtDesiredWorkspace: true };
}
addWorkspace(clusterUri: RootClusterUri): void {
if (this.state.workspaces[clusterUri]) {
addWorkspace({
uri,
proxyHost,
}: {
uri: RootClusterUri;
proxyHost: string;
}): void {
if (this.state.workspaces[uri]) {
this.updateWorkspaceProxyHost({ uri, proxyHost });
return;
}
this.setState(draftState => {
draftState.workspaces[clusterUri] = getWorkspaceDefaultState(
clusterUri,
draftState.workspaces
draftState.workspaces[uri] = getWorkspaceDefaultState(
uri,
draftState.workspaces,
{ proxyHost }
);
});
}
updateWorkspaceProxyHost({
uri,
proxyHost,
}: {
uri: RootClusterUri;
proxyHost: string;
}): void {
const workspace = this.state.workspaces[uri];
if (!workspace || !proxyHost || workspace.proxyHost === proxyHost) {
return;
}
this.setState(draftState => {
draftState.workspaces[uri].proxyHost = proxyHost;
});
}
removeWorkspace(clusterUri: RootClusterUri): void {
this.setState(draftState => {
delete draftState.workspaces[clusterUri];
@@ -468,9 +508,11 @@ export class WorkspacesService extends ImmutableStore<WorkspacesState> {
clearWorkspace(clusterUri: RootClusterUri): void {
this.setState(draftState => {
draftState.workspaces[clusterUri] = getWorkspaceDefaultState(
const currentWorkspace = draftState.workspaces[clusterUri];
draftState.workspaces[clusterUri] = getClearedWorkspaceState(
clusterUri,
draftState.workspaces
draftState.workspaces,
currentWorkspace
);
});
this.restoredState = produce(this.restoredState, draftState => {
@@ -524,22 +566,47 @@ export class WorkspacesService extends ImmutableStore<WorkspacesState> {
// Make the restored state immutable.
this.restoredState = produce(restoredState, () => {});
const restoredWorkspaces = this.clustersService
.getRootClusters()
// Start restoring clusters from the ones that already have a workspace.
// The algorithm that assigns a color in getWorkspaceDefaultState needs
// to know all used colors.
const restoredWorkspaceUris = Object.keys(this.restoredState.workspaces);
const workspaceUris = Array.from(
new Set([
...restoredWorkspaceUris,
...this.clustersService.getRootClusters().map(cluster => cluster.uri),
])
);
const restoredWorkspaces = workspaceUris
// Restore persisted workspaces first, so their colors are reserved before
// assigning colors to newly discovered clusters.
.toSorted((a, b) => {
const hasA = !!this.restoredState.workspaces[a.uri];
const hasB = !!this.restoredState.workspaces[b.uri];
const hasA = !!this.restoredState.workspaces[a];
const hasB = !!this.restoredState.workspaces[b];
return hasB === hasA ? 0 : hasA ? -1 : 1;
})
.reduce((workspaces, cluster) => {
const restoredWorkspace = this.restoredState.workspaces[cluster.uri];
workspaces[cluster.uri] = getWorkspaceDefaultState(
cluster.uri,
.reduce<Record<string, Workspace>>((workspaces, rootClusterUri) => {
const restoredWorkspace = this.restoredState.workspaces[rootClusterUri];
const cluster = this.clustersService.findCluster(rootClusterUri);
const proxyHost = cluster?.proxyHost ?? restoredWorkspace?.proxyHost;
// Skip workspaces with no known proxy address. If the cluster is missing and the workspace has no
// saved proxy host, Connect cannot add the cluster again.
if (!proxyHost) {
return workspaces;
}
const overrides = { ...restoredWorkspace, proxyHost };
if (!cluster) {
workspaces[rootClusterUri] = getClearedWorkspaceState(
rootClusterUri,
workspaces,
overrides
);
return workspaces;
}
workspaces[rootClusterUri] = getWorkspaceDefaultState(
rootClusterUri,
workspaces,
restoredWorkspace
overrides
);
return workspaces;
}, {});
@@ -646,6 +713,7 @@ export class WorkspacesService extends ImmutableStore<WorkspacesState> {
const documentsToPersist = getDocumentsToPersist(workspace.documents);
stateToSave.workspaces[w] = {
proxyHost: workspace.proxyHost,
localClusterUri: workspace.localClusterUri,
location: workspace.location,
color: workspace.color,
@@ -745,10 +813,11 @@ function getLocationToRestore(
function getWorkspaceDefaultState(
rootClusterUri: RootClusterUri,
workspaces: Record<string, Workspace>,
restoredWorkspace?: Immutable<PersistedWorkspace>
overrides?: Partial<Immutable<PersistedWorkspace>>
): Workspace {
const defaultDocument = createClusterDocument({ clusterUri: rootClusterUri });
const defaultWorkspace: Workspace = {
proxyHost: '',
accessRequests: {
pending: getEmptyPendingAccessRequest(),
isBarCollapsed: false,
@@ -761,27 +830,59 @@ function getWorkspaceDefaultState(
unifiedResourcePreferences: parseUnifiedResourcePreferences(undefined),
color: parseWorkspaceColor(undefined, workspaces),
};
if (!restoredWorkspace) {
if (!overrides) {
return defaultWorkspace;
}
defaultWorkspace.localClusterUri = restoredWorkspace.localClusterUri;
defaultWorkspace.unifiedResourcePreferences = parseUnifiedResourcePreferences(
restoredWorkspace.unifiedResourcePreferences
);
defaultWorkspace.color = parseWorkspaceColor(
restoredWorkspace.color,
workspaces
);
defaultWorkspace.connectMyComputer = restoredWorkspace.connectMyComputer;
if (overrides.localClusterUri) {
defaultWorkspace.localClusterUri = overrides.localClusterUri;
}
if (overrides.unifiedResourcePreferences) {
defaultWorkspace.unifiedResourcePreferences =
parseUnifiedResourcePreferences(overrides.unifiedResourcePreferences);
}
if (overrides.color) {
defaultWorkspace.color = parseWorkspaceColor(overrides.color, workspaces);
}
if (overrides.proxyHost) {
defaultWorkspace.proxyHost = overrides.proxyHost;
}
if (overrides.connectMyComputer) {
defaultWorkspace.connectMyComputer = overrides.connectMyComputer;
}
defaultWorkspace.hasDocumentsToReopen = hasDocumentsToReopen({
previousDocuments: restoredWorkspace.documents,
previousDocuments: overrides.documents,
currentDocuments: defaultWorkspace.documents,
});
return defaultWorkspace;
}
/**
* Returns a cleared workspace state while preserving stable workspace metadata.
* This is used after logout, so session-related state is discarded, but `color`
* and `proxyHost` are kept for the remembered cluster.
*/
function getClearedWorkspaceState(
rootClusterUri: RootClusterUri,
workspaces: Record<string, Workspace>,
currentWorkspaceState: Partial<Immutable<PersistedWorkspace>> | undefined
) {
return getWorkspaceDefaultState(
rootClusterUri,
workspaces,
currentWorkspaceState && {
proxyHost: currentWorkspaceState.proxyHost,
color: currentWorkspaceState.color,
}
);
}
// TODO(gzdunek): Parse the entire workspace state read from disk like below.
function parseUnifiedResourcePreferences(
unifiedResourcePreferences: unknown