diff --git a/lib/teleterm/daemon/daemon.go b/lib/teleterm/daemon/daemon.go
index 19724daceb6b3..13f12f4dfa253 100644
--- a/lib/teleterm/daemon/daemon.go
+++ b/lib/teleterm/daemon/daemon.go
@@ -1131,10 +1131,14 @@ func (s *Service) AuthenticateWebDevice(ctx context.Context, rootClusterURI uri.
}
devicesClient := proxyClient.CurrentCluster().DevicesClient()
- ceremony := dtauthn.NewCeremony()
- confirmationToken, err := ceremony.RunWeb(ctx, devicesClient, &devicepb.DeviceWebToken{
- Id: req.DeviceWebToken.Id,
- Token: req.DeviceWebToken.Token,
+ var confirmationToken *devicepb.DeviceConfirmationToken
+ err = clusters.AddMetadataToRetryableError(ctx, func() error {
+ ceremony := dtauthn.NewCeremony()
+ confirmationToken, err = ceremony.RunWeb(ctx, devicesClient, &devicepb.DeviceWebToken{
+ Id: req.DeviceWebToken.Id,
+ Token: req.DeviceWebToken.Token,
+ })
+ return trace.Wrap(err)
})
if err != nil {
return nil, trace.Wrap(err)
diff --git a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts
index 8696b36ee532d..8d0fa6999711d 100644
--- a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts
+++ b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts
@@ -101,7 +101,13 @@ export class MockTshClient implements TshdClient {
getSuggestedAccessLists = () => new MockedUnaryCall({ accessLists: [] });
promoteAccessRequest = () => new MockedUnaryCall({});
updateTshdEventsServerAddress = () => new MockedUnaryCall({});
- authenticateWebDevice = () => new MockedUnaryCall({});
+ authenticateWebDevice = () =>
+ new MockedUnaryCall({
+ confirmationToken: {
+ id: '123456789',
+ token: '7c8e7438-abe1-4cbc-b3e6-bd233bba967c',
+ },
+ });
startHeadlessWatcher = () => new MockedUnaryCall({});
}
diff --git a/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.story.tsx b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.story.tsx
new file mode 100644
index 0000000000000..7b697092f7c62
--- /dev/null
+++ b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.story.tsx
@@ -0,0 +1,90 @@
+/**
+ * Teleport
+ * Copyright (C) 2024 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 { wait } from 'shared/utils/wait';
+
+import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider';
+import {
+ makeRootCluster,
+ makeLoggedInUser,
+ rootClusterUri,
+} from 'teleterm/services/tshd/testHelpers';
+import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
+import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider';
+import * as types from 'teleterm/ui/services/workspacesService';
+import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient';
+
+import { DocumentAuthorizeWebSession } from './DocumentAuthorizeWebSession';
+
+export default {
+ title: 'Teleterm/DocumentAuthorizeWebSession',
+};
+
+const doc: types.DocumentAuthorizeWebSession = {
+ uri: '/docs/e2hyt5',
+ rootClusterUri: rootClusterUri,
+ kind: 'doc.authorize_web_session',
+ title: 'Authorize Web Session',
+ webSessionRequest: {
+ redirectUri: '',
+ token: '',
+ id: '',
+ },
+};
+
+export function DeviceNotTrusted() {
+ const rootCluster = makeRootCluster();
+ const appContext = new MockAppContext();
+ appContext.clustersService.setState(draftState => {
+ draftState.clusters.set(rootCluster.uri, rootCluster);
+ });
+ return (
+
+
+
+
+
+ );
+}
+
+export function DeviceTrusted() {
+ const rootCluster = makeRootCluster({
+ loggedInUser: makeLoggedInUser({ isDeviceTrusted: true }),
+ });
+ const appContext = new MockAppContext();
+ appContext.clustersService.setState(draftState => {
+ draftState.clusters.set(rootCluster.uri, rootCluster);
+ });
+ appContext.clustersService.authenticateWebDevice = async () => {
+ await wait(2_000);
+ return new MockedUnaryCall({
+ confirmationToken: {
+ id: '123456789',
+ token: '7c8e7438-abe1-4cbc-b3e6-bd233bba967c',
+ },
+ });
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.test.tsx b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.test.tsx
new file mode 100644
index 0000000000000..c06ff109ff93c
--- /dev/null
+++ b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.test.tsx
@@ -0,0 +1,105 @@
+/**
+ * Teleport
+ * Copyright (C) 2024 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 { render, screen } from 'design/utils/testing';
+import userEvent from '@testing-library/user-event';
+
+import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider';
+import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider';
+import {
+ makeRootCluster,
+ makeLoggedInUser,
+ rootClusterUri,
+} from 'teleterm/services/tshd/testHelpers';
+import * as types from 'teleterm/ui/services/workspacesService';
+import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
+
+import { DocumentAuthorizeWebSession } from './DocumentAuthorizeWebSession';
+
+const doc: types.DocumentAuthorizeWebSession = {
+ uri: '/docs/e2hyt5',
+ rootClusterUri: rootClusterUri,
+ kind: 'doc.authorize_web_session',
+ title: 'Authorize Web Session',
+ webSessionRequest: {
+ redirectUri: '',
+ token: '',
+ id: '',
+ },
+};
+
+test('authorize button is disabled when device is not trusted', async () => {
+ const rootCluster = makeRootCluster({
+ loggedInUser: makeLoggedInUser({ isDeviceTrusted: false }),
+ });
+ const appContext = new MockAppContext();
+ appContext.clustersService.setState(draftState => {
+ draftState.clusters.set(rootCluster.uri, rootCluster);
+ });
+
+ render(
+
+
+
+
+
+ );
+
+ expect(await screen.findByText(/This device is not trusted/)).toBeVisible();
+ expect(await screen.findByText(/Authorize Session/)).toBeDisabled();
+});
+
+test('authorizing a session opens its URL and closes document', async () => {
+ jest.spyOn(window, 'open').mockImplementation();
+ const rootCluster = makeRootCluster({
+ loggedInUser: makeLoggedInUser({ isDeviceTrusted: true }),
+ });
+ const appContext = new MockAppContext();
+ appContext.clustersService.setState(draftState => {
+ draftState.clusters.set(rootCluster.uri, rootCluster);
+ });
+ appContext.workspacesService.setState(draftState => {
+ draftState.workspaces[rootCluster.uri] = {
+ localClusterUri: rootCluster.uri,
+ documents: [doc],
+ location: undefined,
+ accessRequests: undefined,
+ };
+ });
+
+ render(
+
+
+
+
+
+ );
+
+ const button = await screen.findByText(/Authorize Session/);
+ await userEvent.click(button);
+
+ expect(await screen.findByText(/Session Authorized/)).toBeVisible();
+ expect(window.open).toHaveBeenCalledWith(
+ 'https://teleport-local:3080/webapi/devices/webconfirm?id=123456789&token=7c8e7438-abe1-4cbc-b3e6-bd233bba967c'
+ );
+ expect(
+ appContext.workspacesService
+ .getWorkspaceDocumentService(rootCluster.uri)
+ .getDocuments()
+ ).toHaveLength(0);
+});
diff --git a/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.tsx b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.tsx
new file mode 100644
index 0000000000000..461a8de679ae4
--- /dev/null
+++ b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.tsx
@@ -0,0 +1,196 @@
+/**
+ * Teleport
+ * Copyright (C) 2024 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 { Text, Alert, ButtonPrimary, H1, ButtonText } from 'design';
+import Flex from 'design/Flex';
+import { useAsync, Attempt } from 'shared/hooks/useAsync';
+import { processRedirectUri } from 'shared/redirects';
+import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
+import { DeviceConfirmationToken } from 'gen-proto-ts/teleport/devicetrust/v1/device_confirmation_token_pb';
+
+import Document from 'teleterm/ui/Document';
+import { routing } from 'teleterm/ui/uri';
+import { retryWithRelogin } from 'teleterm/ui/utils';
+import { useAppContext } from 'teleterm/ui/appContextProvider';
+import * as types from 'teleterm/ui/services/workspacesService';
+import { WebSessionRequest } from 'teleterm/ui/services/workspacesService';
+import { useWorkspaceContext } from 'teleterm/ui/Documents';
+
+export function DocumentAuthorizeWebSession(props: {
+ doc: types.DocumentAuthorizeWebSession;
+ visible: boolean;
+}) {
+ const ctx = useAppContext();
+ const { documentsService } = useWorkspaceContext();
+ const rootCluster = ctx.clustersService.findCluster(props.doc.rootClusterUri);
+ const [authorizeAttempt, authorize] = useAsync(async () => {
+ const {
+ response: { confirmationToken },
+ } = await retryWithRelogin(ctx, props.doc.rootClusterUri, () =>
+ ctx.clustersService.authenticateWebDevice(
+ props.doc.rootClusterUri,
+ props.doc.webSessionRequest
+ )
+ );
+ return confirmationToken;
+ });
+ const clusterName = routing.parseClusterName(props.doc.rootClusterUri);
+ const canAuthorize = rootCluster.loggedInUser?.isDeviceTrusted;
+
+ async function authorizeAndCloseDocument() {
+ const [confirmationToken, error] = await authorize();
+ if (!error) {
+ const url = buildAuthorizedSessionUrl(
+ rootCluster,
+ props.doc.webSessionRequest,
+ confirmationToken
+ );
+ // This endpoint verifies the token and "upgrades" the web session and redirects to "/web".
+ window.open(url);
+ closeAndNotify();
+ }
+ }
+
+ function openUnauthorizedAndCloseDocument() {
+ const url = buildUnauthorizedSessionUrl(
+ rootCluster,
+ props.doc.webSessionRequest
+ );
+ window.open(url);
+ closeAndNotify();
+ }
+
+ function closeAndNotify() {
+ documentsService.close(props.doc.uri);
+ ctx.notificationsService.notifyInfo(
+ 'Web session has been opened in the browser'
+ );
+ }
+
+ return (
+
+
+ Authorize Web Session
+
+ {/*It's technically possible to open a deep link to authorize a session on a device that is not enrolled.*/}
+ {!canAuthorize && (
+
+ To authorize a web session, you must first{' '}
+
+ enroll your device
+
+ . Then log out of Teleport Connect, log back in, and try
+ again.
+ >
+ }
+ >
+ This device is not trusted
+
+ )}
+ {authorizeAttempt.status === 'error' && (
+
+ Could not authorize the session
+
+ )}
+
+ Would you like to authorize a device trust web session for{' '}
+ {clusterName}?
+
+ The session will automatically open in a new browser tab.
+
+
+
+ {getButtonText(authorizeAttempt)}
+
+
+ Open Session Without Device Trust
+
+
+
+
+
+ );
+}
+
+const confirmPath = 'webapi/devices/webconfirm';
+
+function buildAuthorizedSessionUrl(
+ rootCluster: Cluster,
+ webSessionRequest: WebSessionRequest,
+ confirmationToken: DeviceConfirmationToken
+): string {
+ const { redirectUri } = webSessionRequest;
+
+ let url = `https://${rootCluster.proxyHost}/${confirmPath}?id=${confirmationToken.id}&token=${confirmationToken.token}`;
+ if (redirectUri) {
+ url = `${url}&redirect_uri=${redirectUri}`;
+ }
+ return url;
+}
+
+function buildUnauthorizedSessionUrl(
+ rootCluster: Cluster,
+ webSessionRequest: WebSessionRequest
+): string {
+ // processedRedirectUri is the path part of the redirectUri.
+ // Unlike in buildAuthorizedSessionUrl, here we return a full path to open
+ // instead of including redirection as the `redirect_uri` query parameter.
+ const processedRedirectUri = processRedirectUri(
+ webSessionRequest.redirectUri
+ );
+ return `https://${rootCluster.proxyHost}${processedRedirectUri}`;
+}
+
+function getButtonText(attempt: Attempt): string {
+ switch (attempt.status) {
+ case '':
+ case 'error':
+ return 'Authorize Session';
+ case 'processing':
+ return 'Authorizing Session…';
+ case 'success':
+ return 'Session Authorized';
+ }
+}
diff --git a/web/packages/teleterm/src/ui/ModalsHost/modals/AuthenticateWebDevice/AuthenticateWebDevice.story.tsx b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/index.ts
similarity index 52%
rename from web/packages/teleterm/src/ui/ModalsHost/modals/AuthenticateWebDevice/AuthenticateWebDevice.story.tsx
rename to web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/index.ts
index b6f35a6e673f2..ffd92d5d17b86 100644
--- a/web/packages/teleterm/src/ui/ModalsHost/modals/AuthenticateWebDevice/AuthenticateWebDevice.story.tsx
+++ b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/index.ts
@@ -1,6 +1,6 @@
/**
* Teleport
- * Copyright (C) 2023 Gravitational, Inc.
+ * Copyright (C) 2024 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
@@ -16,24 +16,4 @@
* along with this program. If not, see .
*/
-import React from 'react';
-
-import { makeRootCluster } from 'teleterm/services/tshd/testHelpers';
-import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider';
-
-import { AuthenticateWebDevice } from './AuthenticateWebDevice';
-
-export default {
- title: 'Teleterm/ModalsHost/AuthenticateWebDevice',
-};
-
-export const Dialog = () => (
-
- {}}
- onCancel={() => {}}
- onAuthorize={async () => {}}
- />
-
-);
+export * from './DocumentAuthorizeWebSession';
diff --git a/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx b/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx
index 0be864d2748f6..d1d300f0d8428 100644
--- a/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx
+++ b/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx
@@ -41,6 +41,7 @@ import {
} from 'teleterm/ui/ConnectMyComputer';
import { DocumentGatewayKube } from 'teleterm/ui/DocumentGatewayKube';
import { DocumentGatewayApp } from 'teleterm/ui/DocumentGatewayApp';
+import { DocumentAuthorizeWebSession } from 'teleterm/ui/DocumentAuthorizeWebSession';
import Document from 'teleterm/ui/Document';
import { RootClusterUri, isDatabaseUri, isAppUri } from 'teleterm/ui/uri';
@@ -153,6 +154,8 @@ function MemoizedDocument(props: { doc: types.Document; visible: boolean }) {
return ;
case 'doc.connect_my_computer':
return ;
+ case 'doc.authorize_web_session':
+ return ;
default:
return (
diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx
index 3ccb6f6c164ad..ba276907d9037 100644
--- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx
+++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx
@@ -32,7 +32,6 @@ import { assertUnreachable } from '../utils';
import { UsageData } from './modals/UsageData';
import { UserJobRole } from './modals/UserJobRole';
import { ReAuthenticate } from './modals/ReAuthenticate';
-import { AuthenticateWebDevice } from './modals/AuthenticateWebDevice/AuthenticateWebDevice';
import { ChangeAccessRequestKind } from './modals/ChangeAccessRequestKind';
import { AskPin, ChangePin, OverwriteSlot, Touch } from './modals/HardwareKeys';
@@ -87,20 +86,6 @@ function renderDialog({
}
switch (dialog.kind) {
- case 'device-trust-authorize': {
- return (
- {
- handleClose();
- dialog.onCancel();
- }}
- onClose={handleClose}
- />
- );
- }
case 'cluster-connect': {
return (
.
- */
-
-import { Alert } from 'design/Alert';
-import { ButtonPrimary, ButtonSecondary, Text } from 'design';
-import Dialog, { DialogContent } from 'design/Dialog';
-import Flex from 'design/Flex';
-import { useAsync } from 'shared/hooks/useAsync';
-
-import { useAppContext } from 'teleterm/ui/appContextProvider';
-import { RootClusterUri, routing } from 'teleterm/ui/uri';
-
-export const AuthenticateWebDevice = ({
- hidden,
- onAuthorize,
- onClose,
- onCancel,
- rootClusterUri,
-}: {
- rootClusterUri: RootClusterUri;
- onCancel(): void;
- onClose(): void;
- onAuthorize(): Promise;
- hidden?: boolean;
-}) => {
- const [attempt, run] = useAsync(async () => {
- await onAuthorize();
- onClose();
- });
- const { clustersService } = useAppContext();
- const clusterName =
- clustersService.findCluster(rootClusterUri)?.name ||
- routing.parseClusterName(rootClusterUri);
-
- return (
-
- );
-};
diff --git a/web/packages/teleterm/src/ui/services/deepLinks/deepLinksService.ts b/web/packages/teleterm/src/ui/services/deepLinks/deepLinksService.ts
index 2dd0c68310a2a..6083df679f626 100644
--- a/web/packages/teleterm/src/ui/services/deepLinks/deepLinksService.ts
+++ b/web/packages/teleterm/src/ui/services/deepLinks/deepLinksService.ts
@@ -17,7 +17,6 @@
*/
import { AuthenticateWebDeviceDeepURL, DeepURL } from 'shared/deepLinks';
-import { processRedirectUri } from 'shared/redirects';
import { DeepLinkParseResult } from 'teleterm/deepLinks';
import { RootClusterUri, routing } from 'teleterm/ui/uri';
@@ -28,7 +27,6 @@ import { WorkspacesService } from 'teleterm/ui/services/workspacesService';
import { ModalsService } from 'teleterm/ui/services/modals';
import { NotificationsService } from 'teleterm/ui/services/notifications';
-const confirmPath = 'webapi/devices/webconfirm';
export class DeepLinksService {
constructor(
private runtimeSettings: RuntimeSettings,
@@ -97,9 +95,9 @@ export class DeepLinksService {
}
/**
- * askAuthorizeDeviceTrust opens a dialog asking the user if they'd like to authorize
+ * askAuthorizeDeviceTrust opens a document asking the user if they'd like to authorize
* a web session with device trust. If confirmed, the web session will be upgraded and the
- * user will be directed back to the web UI
+ * user will be directed back to the web UI.
*/
private async askAuthorizeDeviceTrust(
url: AuthenticateWebDeviceDeepURL
@@ -112,32 +110,18 @@ export class DeepLinksService {
}
const { rootClusterUri } = result;
- const rootCluster = this.clustersService.findCluster(rootClusterUri);
-
- this.modalsService.openRegularDialog({
- kind: 'device-trust-authorize',
+ const documentService =
+ this.workspacesService.getWorkspaceDocumentService(rootClusterUri);
+ const doc = documentService.createAuthorizeWebSessionDocument({
rootClusterUri,
- onCancel: () => {
- const processedRedirectURI = processRedirectUri(redirect_uri);
- window.open(`https://${rootCluster.proxyHost}${processedRedirectURI}`);
- },
- onAuthorize: async () => {
- const result = await this.clustersService.authenticateWebDevice(
- rootClusterUri,
- {
- id,
- token,
- }
- );
- let url = `https://${rootCluster.proxyHost}/${confirmPath}?id=${result.response.confirmationToken.id}&token=${result.response.confirmationToken.token}`;
- if (redirect_uri) {
- url = `${url}&redirect_uri=${redirect_uri}`;
- }
- // open url to confirm the token. This endpoint verifies the token and "upgrades"
- // the web session and redirects to "/web"
- window.open(url);
+ webSessionRequest: {
+ id,
+ token,
+ redirectUri: redirect_uri,
},
});
+ documentService.add(doc);
+ documentService.open(doc.uri);
}
/**
diff --git a/web/packages/teleterm/src/ui/services/modals/modalsService.ts b/web/packages/teleterm/src/ui/services/modals/modalsService.ts
index e02035a7426ec..19f52af9e1eb3 100644
--- a/web/packages/teleterm/src/ui/services/modals/modalsService.ts
+++ b/web/packages/teleterm/src/ui/services/modals/modalsService.ts
@@ -170,13 +170,6 @@ export interface DialogDocumentsReopen {
onCancel?(): void;
}
-export interface DialogDeviceTrustAuthorize {
- kind: 'device-trust-authorize';
- rootClusterUri: RootClusterUri;
- onAuthorize(): Promise;
- onCancel(): void;
-}
-
export interface DialogUsageData {
kind: 'usage-data';
onAllow(): void;
@@ -252,7 +245,6 @@ export type Dialog =
| DialogClusterConnect
| DialogClusterLogout
| DialogDocumentsReopen
- | DialogDeviceTrustAuthorize
| DialogUsageData
| DialogUserJobRole
| DialogResourceSearchErrors
diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts
index ec3851550e84c..1edc82ed9c524 100644
--- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts
+++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts
@@ -44,6 +44,8 @@ import {
DocumentTshNodeWithServerId,
DocumentClusterQueryParams,
DocumentPtySession,
+ WebSessionRequest,
+ DocumentAuthorizeWebSession,
} from './types';
import type { Shell } from 'teleterm/mainProcess/shell';
@@ -228,6 +230,21 @@ export class DocumentsService {
};
}
+ createAuthorizeWebSessionDocument(params: {
+ rootClusterUri: string;
+ webSessionRequest: WebSessionRequest;
+ }): DocumentAuthorizeWebSession {
+ const uri = routing.getDocUri({ docId: unique() });
+
+ return {
+ uri,
+ kind: 'doc.authorize_web_session',
+ title: 'Authorize Web Session',
+ rootClusterUri: params.rootClusterUri,
+ webSessionRequest: params.webSessionRequest,
+ };
+ }
+
openConnectMyComputerDocument(opts: {
// URI of the root cluster could be passed to the `DocumentsService`
// constructor and then to the document, instead of being taken from the parameter.
diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts
index e71525ce99b95..6bd654e01d963 100644
--- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts
+++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts
@@ -53,6 +53,8 @@ export function getResourceUri(
});
case 'doc.connect_my_computer':
return document.rootClusterUri;
+ case 'doc.authorize_web_session':
+ return document.rootClusterUri;
case 'doc.blank':
return undefined;
default:
diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts
index cc95e377520ec..0c7fd36952285 100644
--- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts
+++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts
@@ -221,6 +221,25 @@ export interface DocumentConnectMyComputer extends DocumentBase {
status: '' | 'connecting' | 'connected' | 'error';
}
+/**
+ * Document to authorize a web session with device trust.
+ * Unlike other documents, it is not persisted on disk.
+ */
+export interface DocumentAuthorizeWebSession extends DocumentBase {
+ kind: 'doc.authorize_web_session';
+ // `DocumentAuthorizeWebSession` always operates on the root cluster, so in theory `rootClusterUri` is not needed.
+ // However, there are a few components in the system, such as `getResourceUri`, which need to determine the relation
+ // between a document and a cluster just by looking at the document fields.
+ rootClusterUri: uri.RootClusterUri;
+ webSessionRequest: WebSessionRequest;
+}
+
+export interface WebSessionRequest {
+ id: string;
+ token: string;
+ redirectUri: string;
+}
+
export type DocumentTerminal =
| DocumentPtySession
| DocumentGatewayCliClient
@@ -234,7 +253,8 @@ export type Document =
| DocumentGateway
| DocumentCluster
| DocumentTerminal
- | DocumentConnectMyComputer;
+ | DocumentConnectMyComputer
+ | DocumentAuthorizeWebSession;
export function isDocumentTshNodeWithLoginHost(
doc: Document
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 be44ad0ea1a16..ded2d08080df5 100644
--- a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts
+++ b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts
@@ -33,7 +33,11 @@ import { NotificationsService } from '../notifications';
import { ModalsService } from '../modals';
import { getEmptyPendingAccessRequest } from './accessRequestsService';
-import { Workspace, WorkspacesService } from './workspacesService';
+import {
+ Workspace,
+ WorkspacesService,
+ WorkspacesState,
+} from './workspacesService';
import { DocumentCluster, DocumentsService } from './documentsService';
import type * as tshd from 'teleterm/services/tshd/types';
@@ -137,6 +141,116 @@ describe('restoring workspace', () => {
},
});
});
+
+ it('location is set to first document if it points to non-existing document', async () => {
+ const cluster = makeRootCluster();
+ const testWorkspace: Workspace = {
+ accessRequests: {
+ isBarCollapsed: true,
+ pending: getEmptyPendingAccessRequest(),
+ },
+ localClusterUri: cluster.uri,
+ documents: [
+ {
+ kind: 'doc.terminal_shell',
+ uri: '/docs/terminal_shell_uri_1',
+ title: '/Users/alice/Documents',
+ },
+ {
+ kind: 'doc.terminal_shell',
+ uri: '/docs/terminal_shell_uri_2',
+ title: '/Users/alice/Documents',
+ },
+ ],
+ location: '/docs/non-existing-doc',
+ };
+
+ const { workspacesService } = getTestSetup({
+ cluster,
+ persistedWorkspaces: { [cluster.uri]: testWorkspace },
+ });
+
+ await workspacesService.restorePersistedState();
+
+ expect(workspacesService.getWorkspace(cluster.uri).previous).toStrictEqual({
+ documents: [
+ {
+ kind: 'doc.terminal_shell',
+ uri: '/docs/terminal_shell_uri_1',
+ title: '/Users/alice/Documents',
+ },
+ {
+ kind: 'doc.terminal_shell',
+ uri: '/docs/terminal_shell_uri_2',
+ title: '/Users/alice/Documents',
+ },
+ ],
+ location: '/docs/terminal_shell_uri_1',
+ });
+ });
+});
+
+describe('state persistence', () => {
+ it('doc.authorize_web_session is not stored to disk', () => {
+ const cluster = makeRootCluster();
+ const workspacesState: WorkspacesState = {
+ rootClusterUri: cluster.uri,
+ isInitialized: true,
+ workspaces: {
+ [cluster.uri]: {
+ accessRequests: {
+ isBarCollapsed: true,
+ pending: getEmptyPendingAccessRequest(),
+ },
+ localClusterUri: cluster.uri,
+ documents: [
+ {
+ kind: 'doc.terminal_shell',
+ uri: '/docs/terminal_shell_uri',
+ title: '/Users/alice/Documents',
+ },
+ {
+ kind: 'doc.authorize_web_session',
+ uri: '/docs/authorize_web_session',
+ rootClusterUri: cluster.uri,
+ title: 'Authorize Web Session',
+ webSessionRequest: {
+ id: '',
+ token: '',
+ redirectUri: '',
+ },
+ },
+ ],
+ location: '/docs/authorize_web_session',
+ },
+ },
+ };
+ const { workspacesService, statePersistenceService } = getTestSetup({
+ cluster,
+ persistedWorkspaces: {},
+ });
+
+ workspacesService.setState(() => workspacesState);
+
+ expect(statePersistenceService.saveWorkspacesState).toHaveBeenCalledTimes(
+ 1
+ );
+ expect(statePersistenceService.saveWorkspacesState).toHaveBeenCalledWith({
+ rootClusterUri: cluster.uri,
+ workspaces: {
+ [cluster.uri]: expect.objectContaining({
+ documents: [
+ {
+ kind: 'doc.terminal_shell',
+ uri: '/docs/terminal_shell_uri',
+ title: '/Users/alice/Documents',
+ },
+ ],
+ location: '/docs/authorize_web_session',
+ }),
+ },
+ });
+ });
});
describe('setActiveWorkspace', () => {
@@ -301,5 +415,6 @@ function getTestSetup(options: {
clusterDocument,
modalsService,
notificationsService,
+ statePersistenceService,
};
}
diff --git a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts
index d2899b02df939..e3f38745bef83 100644
--- a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts
+++ b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts
@@ -430,7 +430,10 @@ export class WorkspacesService extends ImmutableStore {
currentDocuments: workspaceDefaultState.documents,
})
? {
- location: persistedWorkspace.location,
+ location: getLocationToRestore(
+ persistedWorkspaceDocuments,
+ persistedWorkspace.location
+ ),
documents: persistedWorkspaceDocuments,
}
: undefined,
@@ -570,10 +573,14 @@ export class WorkspacesService extends ImmutableStore {
};
for (let w in this.state.workspaces) {
const workspace = this.state.workspaces[w];
+ const documentsToPersist = getDocumentsToPersist(
+ workspace.previous?.documents || workspace.documents
+ );
+
stateToSave.workspaces[w] = {
localClusterUri: workspace.localClusterUri,
location: workspace.previous?.location || workspace.location,
- documents: workspace.previous?.documents || workspace.documents,
+ documents: documentsToPersist,
connectMyComputer: workspace.connectMyComputer,
unifiedResourcePreferences: workspace.unifiedResourcePreferences,
};
@@ -628,3 +635,20 @@ type UnifiedResourcePreferencesSchemaAsRequired = Required<
export const useWorkspaceServiceState = () => {
return useStoreSelector('workspacesService', identitySelector);
};
+
+function getDocumentsToPersist(documents: Document[]): Document[] {
+ return (
+ documents
+ // We don't persist 'doc.authorize_web_session' because we don't want to store
+ // a session token and id on disk.
+ // Moreover, the user would not be able to authorize a session at a later time anyway.
+ .filter(d => d.kind !== 'doc.authorize_web_session')
+ );
+}
+
+function getLocationToRestore(
+ documents: Document[],
+ location: DocumentUri
+): DocumentUri | undefined {
+ return documents.find(d => d.uri === location) ? location : documents[0]?.uri;
+}