diff --git a/lib/teleterm/autoupdate/service.go b/lib/teleterm/autoupdate/service.go index a548eb5dbc585..f124d8904618d 100644 --- a/lib/teleterm/autoupdate/service.go +++ b/lib/teleterm/autoupdate/service.go @@ -96,7 +96,7 @@ func (s *Service) GetClusterVersions(ctx context.Context, _ *api.GetClusterVersi mu.Lock() unreachableClusters = append(unreachableClusters, &api.UnreachableCluster{ ClusterUri: cluster.URI.String(), - ErrorMessage: err.Error(), + ErrorMessage: pingErr.Error(), }) mu.Unlock() return nil @@ -147,6 +147,7 @@ func (s *Service) GetDownloadBaseUrl(_ context.Context, _ *api.GetDownloadBaseUr func resolveBaseURL() (string, error) { envBaseURL := os.Getenv(autoupdate.BaseURLEnvVar) if envBaseURL != "" { + // TODO(gzdunek): Validate if it's correct URL. return envBaseURL, nil } diff --git a/web/packages/design/src/StepSlider/StepSlider.tsx b/web/packages/design/src/StepSlider/StepSlider.tsx index 7a85873fe2698..3ef43881032e4 100644 --- a/web/packages/design/src/StepSlider/StepSlider.tsx +++ b/web/packages/design/src/StepSlider/StepSlider.tsx @@ -61,6 +61,7 @@ export function StepSlider(props: Props) { defaultStepIndex = 0, tDuration = 500, wrapping = false, + className, // extraProps are the props required by our step components defined in our flows. ...extraProps } = props; @@ -274,7 +275,7 @@ export function StepSlider(props: Props) { const transitionRef = keyToNodeRef.current.get(key); return ( - + {preMount && {$preContent}} @@ -420,6 +421,8 @@ type Props = * one and backwards from the first one to the last one. */ wrapping?: boolean; + /** Allows styling of the container element. */ + className?: string; } & ExtraProps // Extra props that are passed to each step component. Each step of each flow needs to accept the same set of extra props. : any; diff --git a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts index c4f5c0b07db7e..e0eec52f45a88 100644 --- a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts +++ b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts @@ -177,6 +177,26 @@ export class MockMainProcessClient implements MainProcessClient { async selectDirectoryForDesktopSession() { return ''; } + + supportsAppUpdates() { + return true; + } + async changeAppUpdatesManagingCluster() {} + async maybeRemoveAppUpdatesManagingCluster() {} + async checkForAppUpdates() {} + async downloadAppUpdate() {} + async cancelAppUpdateDownload() {} + async quitAndInstallAppUpdate() {} + subscribeToAppUpdateEvents(): { + cleanup: () => void; + } { + return { cleanup: () => undefined }; + } + subscribeToOpenAppUpdateDialog(): { + cleanup: () => void; + } { + return { cleanup: () => undefined }; + } } export const makeRuntimeSettings = ( diff --git a/web/packages/teleterm/src/mainProcess/ipcSerializer.ts b/web/packages/teleterm/src/mainProcess/ipcSerializer.ts new file mode 100644 index 0000000000000..42555704147c5 --- /dev/null +++ b/web/packages/teleterm/src/mainProcess/ipcSerializer.ts @@ -0,0 +1,43 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export type SerializedError = { + name: string; + message: string; + stack?: string; + cause?: unknown; +}; + +/** Serializes an Error into a plain object for transport through Electron IPC. */ +export function serializeError(error: Error): SerializedError { + return { + name: error.name, + message: error.message, + cause: error.cause, + stack: error.stack, + }; +} + +/** Deserializes a plain object back into an Error instance. */ +export function deserializeError(serialized: SerializedError): Error { + const error = new Error(serialized.message); + error.name = serialized.name; + error.cause = serialized.cause; + error.stack = serialized.stack; + return error; +} diff --git a/web/packages/teleterm/src/mainProcess/mainProcess.ts b/web/packages/teleterm/src/mainProcess/mainProcess.ts index 4933040f09220..45e01b8571280 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcess.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcess.ts @@ -83,6 +83,7 @@ import { removeAgentDirectory, type CreateAgentConfigFileArgs, } from './createAgentConfigFile'; +import { serializeError } from './ipcSerializer'; import { ResolveError, resolveNetworkAddress } from './resolveNetworkAddress'; import { terminateWithTimeout } from './terminateWithTimeout'; import { WindowsManager } from './windowsManager'; @@ -161,7 +162,15 @@ export default class MainProcess { this.appUpdater = new AppUpdater( makeAppUpdaterStorage(this.appStateFileStorage), getClusterVersions, - getDownloadBaseUrl + getDownloadBaseUrl, + event => { + if (event.kind === 'error') { + event.error = serializeError(event.error); + } + this.windowsManager + .getWindow() + .webContents.send(RendererIpc.AppUpdateEvent, event); + } ); } @@ -180,8 +189,8 @@ export default class MainProcess { async dispose(): Promise { this.windowsManager.dispose(); - this.appUpdater.dispose(); await Promise.all([ + this.appUpdater.dispose(), // sending usage events on tshd shutdown has 10-seconds timeout terminateWithTimeout(this.tshdProcess, 10_000, () => { this.gracefullyKillTshdProcess(); @@ -639,6 +648,46 @@ export default class MainProcess { } ); + ipcMain.on(MainProcessIpc.SupportsAppUpdates, event => { + event.returnValue = this.appUpdater.supportsUpdates(); + }); + + ipcMain.handle(MainProcessIpc.CheckForAppUpdates, () => + this.appUpdater.checkForUpdates() + ); + + ipcMain.handle( + MainProcessIpc.ChangeAppUpdatesManagingCluster, + ( + event, + args: { + clusterUri: RootClusterUri | undefined; + } + ) => this.appUpdater.changeManagingCluster(args.clusterUri) + ); + + ipcMain.handle( + MainProcessIpc.MaybeRemoveAppUpdatesManagingCluster, + ( + event, + args: { + clusterUri: RootClusterUri; + } + ) => this.appUpdater.maybeRemoveManagingCluster(args.clusterUri) + ); + + ipcMain.handle(MainProcessIpc.DownloadAppUpdate, () => + this.appUpdater.download() + ); + + ipcMain.handle(MainProcessIpc.CancelAppUpdateDownload, () => + this.appUpdater.cancelDownload() + ); + + ipcMain.handle(MainProcessIpc.QuiteAndInstallAppUpdate, () => + this.appUpdater.quitAndInstall() + ); + subscribeToTerminalContextMenuEvent(this.configService); subscribeToTabContextMenuEvent( this.settings.availableShells, @@ -673,7 +722,28 @@ export default class MainProcess { }; const macTemplate: MenuItemConstructorOptions[] = [ - { role: 'appMenu' }, + { + role: 'appMenu', + submenu: [ + { role: 'about' }, + { + label: 'Check for Updates…', + click: () => { + this.windowsManager + .getWindow() + .webContents.send(RendererIpc.OpenAppUpdateDialog); + }, + }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, { role: 'editMenu' }, viewMenuTemplate, { diff --git a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts index 67b21fd49507e..72f747a9d37cc 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts @@ -19,6 +19,7 @@ import { ipcRenderer } from 'electron'; import { CreateAgentConfigFileArgs } from 'teleterm/mainProcess/createAgentConfigFile'; +import { AppUpdateEvent } from 'teleterm/services/appUpdater'; import { createFileStorageClient } from 'teleterm/services/fileStorage'; import { RootClusterUri } from 'teleterm/ui/uri'; @@ -199,5 +200,54 @@ export default function createMainProcessClient(): MainProcessClient { args ); }, + supportsAppUpdates() { + return ipcRenderer.sendSync(MainProcessIpc.SupportsAppUpdates); + }, + checkForAppUpdates() { + return ipcRenderer.invoke(MainProcessIpc.CheckForAppUpdates); + }, + downloadAppUpdate() { + return ipcRenderer.invoke(MainProcessIpc.DownloadAppUpdate); + }, + cancelAppUpdateDownload() { + return ipcRenderer.invoke(MainProcessIpc.CancelAppUpdateDownload); + }, + quitAndInstallAppUpdate() { + return ipcRenderer.invoke(MainProcessIpc.QuiteAndInstallAppUpdate); + }, + changeAppUpdatesManagingCluster(clusterUri) { + return ipcRenderer.invoke( + MainProcessIpc.ChangeAppUpdatesManagingCluster, + { + clusterUri, + } + ); + }, + maybeRemoveAppUpdatesManagingCluster(clusterUri) { + return ipcRenderer.invoke( + MainProcessIpc.MaybeRemoveAppUpdatesManagingCluster, + { + clusterUri, + } + ); + }, + subscribeToAppUpdateEvents: listener => { + const ipcListener = (_, updateEvent: AppUpdateEvent) => { + listener(updateEvent); + }; + + ipcRenderer.addListener(RendererIpc.AppUpdateEvent, ipcListener); + return { + cleanup: () => + ipcRenderer.removeListener(RendererIpc.AppUpdateEvent, ipcListener), + }; + }, + subscribeToOpenAppUpdateDialog: listener => { + ipcRenderer.addListener(RendererIpc.OpenAppUpdateDialog, listener); + return { + cleanup: () => + ipcRenderer.removeListener(RendererIpc.OpenAppUpdateDialog, listener), + }; + }, }; } diff --git a/web/packages/teleterm/src/mainProcess/types.ts b/web/packages/teleterm/src/mainProcess/types.ts index 6ce4613069fad..a8145a8fd8e1b 100644 --- a/web/packages/teleterm/src/mainProcess/types.ts +++ b/web/packages/teleterm/src/mainProcess/types.ts @@ -18,6 +18,7 @@ import { DeepLinkParseResult } from 'teleterm/deepLinks'; import { CreateAgentConfigFileArgs } from 'teleterm/mainProcess/createAgentConfigFile'; +import { AppUpdateEvent } from 'teleterm/services/appUpdater'; import { FileStorage } from 'teleterm/services/fileStorage'; import { Document } from 'teleterm/ui/services/workspacesService'; import { RootClusterUri } from 'teleterm/ui/uri'; @@ -146,10 +147,10 @@ export type MainProcessClient = { * Tells the OS to focus the window. If wait is true, polls periodically for window status and * resolves when it's focused or after a short timeout. * - * Most of the time wait shouldn't be used, it's there for use cases where it's important for the - * app to be focused (e.g., the business logic needs to use the clipboard API). Even in that case, - * the logic must handle a scenario where focus wasn't received as focus cannot be guaranteed. - * Any app can steal focus at any time. + * Most of the time wait shouldn't be used. It's for use cases where the app must be focused + * before carrying out the rest of the logic (e.g., the clipboard API requires focus). Even in + * those cases, the logic must handle a scenario where focus wasn't received as focus cannot be + * guaranteed. Any app can steal focus at any time. */ forceFocusWindow( args?: { wait?: false } | { wait: true; signal?: AbortSignal } @@ -206,6 +207,23 @@ export type MainProcessClient = { desktopUri: string; login: string; }): Promise; + changeAppUpdatesManagingCluster( + clusterUri: RootClusterUri | undefined + ): Promise; + maybeRemoveAppUpdatesManagingCluster( + clusterUri: RootClusterUri + ): Promise; + supportsAppUpdates(): boolean; + checkForAppUpdates(): Promise; + downloadAppUpdate(): Promise; + cancelAppUpdateDownload(): Promise; + quitAndInstallAppUpdate(): Promise; + subscribeToAppUpdateEvents(listener: (args: AppUpdateEvent) => void): { + cleanup: () => void; + }; + subscribeToOpenAppUpdateDialog(listener: () => void): { + cleanup: () => void; + }; }; export type ChildProcessAddresses = { @@ -311,6 +329,8 @@ export enum RendererIpc { NativeThemeUpdate = 'renderer-native-theme-update', ConnectMyComputerAgentUpdate = 'renderer-connect-my-computer-agent-update', DeepLinkLaunch = 'renderer-deep-link-launch', + OpenAppUpdateDialog = 'renderer-open-app-update-dialog', + AppUpdateEvent = 'renderer-app-update-event', } export enum MainProcessIpc { @@ -322,6 +342,13 @@ export enum MainProcessIpc { SaveTextToFile = 'main-process-save-text-to-file', ForceFocusWindow = 'main-process-force-focus-window', SelectDirectoryForDesktopSession = 'main-process-select-directory-for-desktop-session', + CheckForAppUpdates = 'main-process-check-for-app-updates', + DownloadAppUpdate = 'main-process-download-app-update', + CancelAppUpdateDownload = 'main-process-cancel-app-update-download', + QuiteAndInstallAppUpdate = 'main-process-quit-and-install-app-update', + ChangeAppUpdatesManagingCluster = 'main-process-change-app-updates-managing-cluster', + MaybeRemoveAppUpdatesManagingCluster = 'main-process-maybe-remove-app-updates-managing-cluster', + SupportsAppUpdates = 'main-process-supports-app-updates', } export enum WindowsManagerIpc { diff --git a/web/packages/teleterm/src/services/appUpdater/appUpdater.test.ts b/web/packages/teleterm/src/services/appUpdater/appUpdater.test.ts new file mode 100644 index 0000000000000..eabaa8c34ae18 --- /dev/null +++ b/web/packages/teleterm/src/services/appUpdater/appUpdater.test.ts @@ -0,0 +1,320 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { createHash } from 'node:crypto'; + +import { MacUpdater } from 'electron-updater'; + +import type { GetClusterVersionsResponse } from 'gen-proto-ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb'; +import { wait } from 'shared/utils/wait'; + +import Logger, { NullService } from 'teleterm/logger'; + +import { AppUpdateEvent, AppUpdater, AppUpdaterStorage } from './appUpdater'; + +const mockedAppVersion = '15.0.0'; + +jest.mock('electron', () => ({ + app: { + // Should be false to avoid removing real Squirrel directory in `dispose`. + isPackaged: false, + getVersion: () => mockedAppVersion, + }, + autoUpdater: { on: () => {} }, +})); + +beforeAll(() => { + Logger.init(new NullService()); + + // Mock fetching checksum. + // Creates hash from the passed URL. + global.fetch = jest.fn(async url => ({ + ok: true, + text: async () => + `${createHash('sha-512').update(url).digest('hex')} Teleport Connect-17.5.4-mac.zip`, + })) as jest.Mock; +}); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +function makeUpdaterStorage( + initialValue: { managingClusterUri?: string } = {} +): AppUpdaterStorage { + return { + get: () => initialValue, + put: newValue => (initialValue = newValue), + }; +} + +// MacUpdater with mocked download and install features. +class MockedMacUpdater extends MacUpdater { + constructor() { + super(undefined, { + appUpdateConfigPath: __dirname, + baseCachePath: __dirname, + isPackaged: false, + userDataPath: '', + onQuit(): void {}, + quit(): void {}, + relaunch(): void {}, + version: mockedAppVersion, + whenReady: () => Promise.resolve(), + name: 'Teleport Connect', + }); + // Prevents electron-updater from creating .updaterId file. + this.stagingUserIdPromise.value = Promise.resolve( + '153432a8-93de-577c-a76a-3a042f1d7580' + ); + } + + protected override async doDownloadUpdate(): Promise { + // Simulate download. + await wait(10); + this.dispatchUpdateDownloaded({ + ...this.updateInfoAndProvider.info, + downloadedFile: 'some-update', + }); + return ['some-update']; + } + + override quitAndInstall() {} +} + +function setUpAppUpdater(options: { + clusters: GetClusterVersionsResponse; + storage?: AppUpdaterStorage; +}) { + const clusterGetter = async () => { + return options.clusters; + }; + + const nativeUpdater = new MockedMacUpdater(); + + const checkForUpdatesSpy = jest.spyOn(nativeUpdater, 'checkForUpdates'); + const downloadUpdateSpy = jest.spyOn(nativeUpdater, 'downloadUpdate'); + let lastEvent: { value?: AppUpdateEvent } = {}; + const appUpdater = new AppUpdater( + options.storage || makeUpdaterStorage(), + clusterGetter, + async () => 'https://cdn.teleport.dev', + event => { + lastEvent.value = event; + }, + nativeUpdater + ); + + return { + appUpdater, + nativeUpdater, + checkForUpdatesSpy, + downloadUpdateSpy, + lastEvent, + }; +} + +test('auto-downloads update when all clusters are reachable', async () => { + const setup = setUpAppUpdater({ + clusters: { + reachableClusters: [ + { + clusterUri: '/clusters/foo', + toolsAutoUpdate: true, + toolsVersion: '19.0.0', + minToolsVersion: '18.0.0-aa', + }, + ], + unreachableClusters: [], + }, + }); + + await setup.appUpdater.checkForUpdates(); + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-available', + autoDownload: true, + }) + ); + expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(1); + + await setup.downloadUpdateSpy.mock.results[0].value; + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-downloaded', + }) + ); +}); + +test('does not auto-download update when there are unreachable clusters', async () => { + const setup = setUpAppUpdater({ + clusters: { + reachableClusters: [ + { + clusterUri: '/clusters/foo', + toolsAutoUpdate: true, + toolsVersion: '19.0.0', + minToolsVersion: '18.0.0-aa', + }, + ], + unreachableClusters: [ + { + clusterUri: '/clusters/bar', + errorMessage: 'Network issue', + }, + ], + }, + }); + + await setup.appUpdater.checkForUpdates(); + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-available', + autoDownload: false, + }) + ); + expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(0); +}); + +test('does not auto-download update when all clusters are reachable and noAutoDownload is set', async () => { + const setup = setUpAppUpdater({ + clusters: { + reachableClusters: [ + { + clusterUri: '/clusters/foo', + toolsAutoUpdate: true, + toolsVersion: '19.0.0', + minToolsVersion: '18.0.0-aa', + }, + ], + unreachableClusters: [], + }, + }); + + await setup.appUpdater.checkForUpdates({ noAutoDownload: true }); + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-available', + autoDownload: false, + }) + ); + expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(0); +}); + +test('discards previous update if a new one is found that should not auto-download', async () => { + const clusters = { + reachableClusters: [ + { + clusterUri: '/clusters/foo', + toolsAutoUpdate: true, + toolsVersion: '19.0.0', + minToolsVersion: '18.0.0-aa', + }, + ], + unreachableClusters: [], + }; + + const setup = setUpAppUpdater({ + clusters, + }); + + await setup.appUpdater.checkForUpdates(); + expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(1); + await setup.downloadUpdateSpy.mock.results[0].value; + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-downloaded', + update: expect.objectContaining({ + version: '19.0.0', + }), + }) + ); + + clusters.reachableClusters = [ + { + clusterUri: '/clusters/foo', + toolsAutoUpdate: true, + toolsVersion: '19.0.0', + minToolsVersion: '18.0.0-aa', + }, + + // This cluster is on newer version, so it will be providing updates. + { + clusterUri: '/clusters/bar', + toolsAutoUpdate: true, + toolsVersion: '19.0.1', + minToolsVersion: '18.0.0-aa', + }, + ]; + await setup.appUpdater.checkForUpdates({ noAutoDownload: true }); + expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(1); + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + autoDownload: false, + kind: 'update-available', + update: expect.objectContaining({ + version: '19.0.1', + }), + }) + ); + await setup.appUpdater.dispose(); + // Check if the app is set to discard the first downloaded update on close. + expect(setup.nativeUpdater.autoInstallOnAppQuit).toBeFalsy(); +}); + +test('discards previous update if the latest check returns no update', async () => { + const clusters = { + reachableClusters: [ + { + clusterUri: '/clusters/foo', + toolsAutoUpdate: true, + toolsVersion: '19.0.0', + minToolsVersion: '18.0.0-aa', + }, + ], + unreachableClusters: [], + }; + + const setup = setUpAppUpdater({ + clusters, + }); + + await setup.appUpdater.checkForUpdates(); + expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(1); + await setup.downloadUpdateSpy.mock.results[0].value; + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-downloaded', + update: expect.objectContaining({ + version: '19.0.0', + }), + }) + ); + + clusters.reachableClusters = []; + await setup.appUpdater.checkForUpdates(); + expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(1); + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-not-available', + }) + ); + await setup.appUpdater.dispose(); + // Check if the app is set to discard the first downloaded update on close. + expect(setup.nativeUpdater.autoInstallOnAppQuit).toBeFalsy(); +}); diff --git a/web/packages/teleterm/src/services/appUpdater/appUpdater.ts b/web/packages/teleterm/src/services/appUpdater/appUpdater.ts index 409995f6ff26e..28e07d2cf214f 100644 --- a/web/packages/teleterm/src/services/appUpdater/appUpdater.ts +++ b/web/packages/teleterm/src/services/appUpdater/appUpdater.ts @@ -16,12 +16,21 @@ * along with this program. If not, see . */ +import { rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import process from 'process'; +import { app } from 'electron'; import { autoUpdater, - AppUpdater as ElectronAppUpdater, + DebUpdater, + MacUpdater, + AppUpdater as NativeUpdater, + NsisUpdater, ProgressInfo, + RpmUpdater, + UpdateCheckResult, UpdateInfo, } from 'electron-updater'; import { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider'; @@ -30,6 +39,7 @@ import type { GetClusterVersionsResponse } from 'gen-proto-ts/teleport/lib/telet import { AbortError } from 'shared/utils/error'; import Logger from 'teleterm/logger'; +import { RootClusterUri } from 'teleterm/ui/uri'; import { AutoUpdatesEnabled, @@ -48,11 +58,19 @@ export class AppUpdater { private readonly logger = new Logger('AppUpdater'); private readonly unregisterEventHandlers: () => void; private autoUpdatesStatus: AutoUpdatesStatus | undefined; + private updateCheckResult: UpdateCheckResult | undefined; + private checkForUpdatesPromise: Promise | undefined; + private downloadPromise: Promise | undefined; + private isUpdateDownloaded = false; + private forceNoAutoDownload = false; constructor( - private storage: AppUpdaterStorage, - private getClusterVersions: () => Promise, - getDownloadBaseUrl: () => Promise + private readonly storage: AppUpdaterStorage, + private readonly getClusterVersions: () => Promise, + readonly getDownloadBaseUrl: () => Promise, + private readonly emit: (event: AppUpdateEvent) => void, + /** Allows overring autoUpdater in tests. */ + private nativeUpdater: NativeUpdater = autoUpdater ) { const getClientToolsVersion: ClientToolsVersionGetter = async () => { await this.refreshAutoUpdatesStatus(); @@ -65,13 +83,13 @@ export class AppUpdater { } }; - autoUpdater.setFeedURL({ + this.nativeUpdater.setFeedURL({ provider: 'custom', // Wraps ClientToolsUpdateProvider to allow passing getClientToolsVersion. updateProvider: class extends ClientToolsUpdateProvider { constructor( options: unknown, - updater: ElectronAppUpdater, + updater: NativeUpdater, runtimeOptions: ProviderRuntimeOptions ) { super(getClientToolsVersion, updater, runtimeOptions); @@ -79,23 +97,230 @@ export class AppUpdater { }, }); - autoUpdater.logger = this.logger; - autoUpdater.allowDowngrade = true; - autoUpdater.autoInstallOnAppQuit = true; + this.nativeUpdater.logger = this.logger; + this.nativeUpdater.allowDowngrade = true; + this.nativeUpdater.autoDownload = false; + // Must be set to true before any download starts. + // electron-updater registers a listener to install the update when + // the app quits, after the download has completed. + // It can be then set to false, it the update shouldn't be installed + // (except macOS). + this.nativeUpdater.autoInstallOnAppQuit = true; // Enables checking for updates and downloading them in dev mode. // It makes testing this feature easier. // Only installing updates requires the packaged app. // Downloads are saved to the path specified in dev-app-update.yml. - autoUpdater.forceDevUpdateConfig = true; + this.nativeUpdater.forceDevUpdateConfig = true; this.unregisterEventHandlers = registerEventHandlers( - () => {}, //TODO: send the events to the window. - () => this.autoUpdatesStatus + this.nativeUpdater, + this.emit, + () => this.autoUpdatesStatus, + () => this.shouldAutoDownload() ); } - dispose(): void { + /** Must be called before `quit` event is emitted. */ + async dispose(): Promise { this.unregisterEventHandlers(); + await this.preventInstallingOutdatedUpdates(); + } + + /** + * Determines whether updates are supported for the current distribution. + * Note: Updating `.tar.gz` archives is not supported, but `electron-updater` + * incorrectly treats them as AppImage packages. + */ + supportsUpdates(): boolean { + return ( + this.nativeUpdater instanceof MacUpdater || + this.nativeUpdater instanceof NsisUpdater || + this.nativeUpdater instanceof DebUpdater || + this.nativeUpdater instanceof RpmUpdater + ); + } + + /** + * Checks for app updates. + * + * This method enhances the standard autoUpdater.checkForUpdates() by adding + * the following behaviors: + * - It allows update checks during an ongoing download process. + * If a new update is found (or no update is available), the current download + * is canceled. + * - If downloading the update requires user confirmation, but the update has + * already been downloaded, checking for updates will transition the updater + * to the `update-downloaded` state (instead of staying in `update-available` + * state). + */ + async checkForUpdates( + options: { noAutoDownload?: boolean } = {} + ): Promise { + if (this.checkForUpdatesPromise) { + this.logger.info('Check for updates already in progress.'); + return this.checkForUpdatesPromise; + } + + this.checkForUpdatesPromise = this.doCheckForUpdates(options); + try { + await this.checkForUpdatesPromise; + } catch (error) { + // The error from autoUpdater.checkForUpdates is surfaced to the UI through error event. + this.logger.error('Failed to check for updates.', error); + } finally { + this.checkForUpdatesPromise = undefined; + } + } + + /** Not safe for concurrent use. */ + private async doCheckForUpdates( + opts: { + noAutoDownload?: boolean; + /** + * Whether this is a retry attempt. + * Used as a guard to prevent infinite loops. + */ + hasRetried?: boolean; + } = {} + ): Promise { + if (!this.supportsUpdates()) { + return; + } + + this.forceNoAutoDownload = opts.noAutoDownload; + + const result = await this.nativeUpdater.checkForUpdates(); + + const newSha = result.updateInfo?.files[0]?.sha512; + const oldSha = this.updateCheckResult?.updateInfo.files[0]?.sha512; + const isSameUpdate = newSha && oldSha && newSha === oldSha; + + this.updateCheckResult = result; + + const updateUnavailable = !result.isUpdateAvailable; + const updateChanged = !isSameUpdate; + + let downloadCanceled = false; + if (updateUnavailable || updateChanged) { + downloadCanceled = await this.cancelDownload(); + } + + if ( + result.isUpdateAvailable && + (this.shouldAutoDownload() || + // This can occur if the user manually downloads an update + // and then triggers another check for updates. + // Since the update is already downloaded, the updater should transition + // to the `update-downloaded` state automatically. + // The update file will be read from the local cache. + this.isUpdateDownloaded) + ) { + void this.download(); + return; + } + + // Retry to refresh the state so that the UI won't be showing + // a cancellation error. + if (downloadCanceled && !opts.hasRetried) { + await this.doCheckForUpdates({ ...opts, hasRetried: true }); + } + } + + /** Starts download. */ + async download(): Promise { + if (this.downloadPromise) { + this.logger.info('Download already in progress.'); + return this.downloadPromise; + } + + this.downloadPromise = this.nativeUpdater.downloadUpdate(); + try { + await this.downloadPromise; + this.isUpdateDownloaded = true; + } catch (error) { + // The error from autoUpdater.download is surfaced to the UI through error event. + this.logger.error('Failed to download update.', error); + } finally { + this.downloadPromise = undefined; + } + } + + /** Cancels download. Returns true if aborted the network request. */ + async cancelDownload(): Promise { + if (!this.downloadPromise) { + this.isUpdateDownloaded = false; + return false; + } + + // Due to a bug in electron-updater, we can't cancel downloads using cancellation + // token passed to autoUpdater.download(). + // Repeatedly starting and canceling downloads causes the updater to go + // into a broken state where it becomes unresponsive. + // To avoid this, we instead close the network connections to abort + // the current download. + await this.nativeUpdater.netSession.closeAllConnections(); + try { + await this.downloadPromise; + return false; + } catch { + return true; + } finally { + this.isUpdateDownloaded = false; + } + } + + /** + * Sets given cluster as managing app version. + * When `undefined` is passed, the managing cluster is cleared. + * + * Immediately cancels an in-progress download and then checks for updates. + */ + async changeManagingCluster( + clusterUri: RootClusterUri | undefined + ): Promise { + this.storage.put({ managingClusterUri: clusterUri }); + await this.cancelDownload(); + await this.checkForUpdates(); + } + + /** + * Removes the managing cluster if it matches the given cluster URI. + * Cancels any in-progress update that was triggered by this cluster. + */ + async maybeRemoveManagingCluster(clusterUri: RootClusterUri): Promise { + const { managingClusterUri } = this.storage.get(); + if (managingClusterUri === clusterUri) { + this.storage.put({ managingClusterUri: undefined }); + } + + // checkForUpdates will discard any update triggered by the removed managing + // cluster. If a different update is found, do not download it automatically. + // Currently, updates aren't checked in the background, and on Windows and Linux, + // users may be surprised by an admin prompt when there is an update to install + // after quitting the app. + // We may revisit this behavior in the future. For example, if we introduce + // a UI notification indicating that there's an update to be installed. + await this.checkForUpdates({ noAutoDownload: true }); + } + + /** + * Restarts the app and installs the update after it has been downloaded. + * It should only be called after update-downloaded has been emitted. + */ + quitAndInstall(): void { + try { + this.nativeUpdater.quitAndInstall(); + } catch (error) { + this.logger.error('Failed to quit and install update', error); + } + } + + private shouldAutoDownload(): boolean { + return ( + !this.forceNoAutoDownload && + this.autoUpdatesStatus?.enabled && + shouldAutoDownload(this.autoUpdatesStatus) + ); } private async refreshAutoUpdatesStatus(): Promise { @@ -107,11 +332,50 @@ export class AppUpdater { managingClusterUri, getClusterVersions: this.getClusterVersions, }); - if (this.autoUpdatesStatus.enabled) { - autoUpdater.autoDownload = shouldAutoDownload(this.autoUpdatesStatus); - } this.logger.info('Resolved auto updates status', this.autoUpdatesStatus); } + + /** + * Workaround to prevent installing outdated updates. + * electron-updater lacks support for this: once an update is downloaded, + * it will be installed on quit—even if subsequent update checks report + * no new updates. + */ + private async preventInstallingOutdatedUpdates(): Promise { + if (this.isUpdateDownloaded) { + return; + } + + // Workaround for Windows and Linux. + this.nativeUpdater.autoInstallOnAppQuit = false; + + // macOS-specific workaround: + // On macOS, electron-updater downloads the update file and, if + // `autoUpdater.autoInstallOnAppQuit` is true, passes it to the native Electron + // autoUpdater via a local server. The update is then handed off to the Squirrel + // framework for installation (either on demand or after quitting the app). + // Unfortunately, once Squirrel gets the update, it is always installed + // on quit, regardless of the `autoInstallOnAppQuit` value. + // The only workaround I've found is to manually delete the ShipItState.plist + // file so Squirrel cannot apply the update. + // The downloaded update will be overwritten with the next update. + if (this.nativeUpdater instanceof MacUpdater && app.isPackaged) { + const squirrelPlistFilePath = path.join( + os.homedir(), + 'Library', + 'Caches', + 'gravitational.teleport.connect.ShipIt', + 'ShipItState.plist' + ); + try { + await rm(squirrelPlistFilePath, { + force: true, + }); + } catch (error) { + this.logger.error(error); + } + } + } } export interface AppUpdaterStorage< @@ -125,15 +389,16 @@ export interface AppUpdaterStorage< } function registerEventHandlers( + nativeUpdater: NativeUpdater, emit: (event: AppUpdateEvent) => void, - getAutoUpdatesStatus: () => AutoUpdatesStatus + getAutoUpdatesStatus: () => AutoUpdatesStatus, + getAutoDownload: () => boolean ): () => void { // updateInfo becomes defined when an update is available (see onUpdateAvailable). // It is later attached to other events, like 'download-progress' or 'error'. let updateInfo: UpdateInfo | undefined; const onCheckingForUpdate = () => { - updateInfo = undefined; emit({ kind: 'checking-for-update', autoUpdatesStatus: getAutoUpdatesStatus(), @@ -144,30 +409,24 @@ function registerEventHandlers( emit({ kind: 'update-available', update, - autoDownload: autoUpdater.autoDownload, + autoDownload: getAutoDownload(), autoUpdatesStatus: getAutoUpdatesStatus() as AutoUpdatesEnabled, }); }; - const onUpdateNotAvailable = () => + const onUpdateNotAvailable = () => { + updateInfo = undefined; emit({ kind: 'update-not-available', autoUpdatesStatus: getAutoUpdatesStatus(), }); - const onUpdateCancelled = (update: UpdateInfo) => { - emit({ - kind: 'error', - error: new AbortError('Update download was canceled'), - update, - autoUpdatesStatus: getAutoUpdatesStatus() as AutoUpdatesEnabled, - }); }; const onError = (error: Error) => { - // Functions can't be sent through IPC. - delete error.toString; - + if (error.message.includes('net::ERR_ABORTED')) { + error = new AbortError('Update download was canceled'); + } emit({ kind: 'error', - error: error, + error, update: updateInfo, autoUpdatesStatus: getAutoUpdatesStatus() as AutoUpdatesEnabled, }); @@ -186,22 +445,20 @@ function registerEventHandlers( autoUpdatesStatus: getAutoUpdatesStatus() as AutoUpdatesEnabled, }); - autoUpdater.on('checking-for-update', onCheckingForUpdate); - autoUpdater.on('update-available', onUpdateAvailable); - autoUpdater.on('update-not-available', onUpdateNotAvailable); - autoUpdater.on('update-cancelled', onUpdateCancelled); - autoUpdater.on('error', onError); - autoUpdater.on('download-progress', onDownloadProgress); - autoUpdater.on('update-downloaded', onUpdateDownloaded); + nativeUpdater.on('checking-for-update', onCheckingForUpdate); + nativeUpdater.on('update-available', onUpdateAvailable); + nativeUpdater.on('update-not-available', onUpdateNotAvailable); + nativeUpdater.on('error', onError); + nativeUpdater.on('download-progress', onDownloadProgress); + nativeUpdater.on('update-downloaded', onUpdateDownloaded); return () => { - autoUpdater.off('checking-for-update', onCheckingForUpdate); - autoUpdater.off('update-available', onUpdateAvailable); - autoUpdater.off('update-not-available', onUpdateNotAvailable); - autoUpdater.off('update-cancelled', onUpdateCancelled); - autoUpdater.off('error', onError); - autoUpdater.off('download-progress', onDownloadProgress); - autoUpdater.off('update-downloaded', onUpdateDownloaded); + nativeUpdater.off('checking-for-update', onCheckingForUpdate); + nativeUpdater.off('update-available', onUpdateAvailable); + nativeUpdater.off('update-not-available', onUpdateNotAvailable); + nativeUpdater.off('error', onError); + nativeUpdater.off('download-progress', onDownloadProgress); + nativeUpdater.off('update-downloaded', onUpdateDownloaded); }; } diff --git a/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts b/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts index afc5053d8b1e3..247ac117c06b7 100644 --- a/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts +++ b/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts @@ -29,6 +29,10 @@ import { } from 'electron-updater'; import { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider'; +import { compare, major } from 'shared/utils/semVer'; + +import { UnsupportedVersionError } from './errors'; + const CHECKSUM_FETCH_TIMEOUT = 5_000; // Example: 99a2fe26681073de56de4229dd9cd6655fef22759579b7b9bc359e018ea1007099a2fe26681073de56de4229dd9cd6655fef22759579b7b9bc359e018ea10070 Teleport Connect-17.5.4-mac.zip const CHECKSUM_FORMAT = /^.+\s+.+$/; @@ -68,7 +72,12 @@ export class ClientToolsUpdateProvider extends Provider { } const { baseUrl, version } = clientTools; - const fileUrl = `https://${baseUrl}/${makeDownloadFilename(this.nativeUpdater, version)}`; + const updatesSupport = areManagedUpdatesSupportedInConnect(version); + if (updatesSupport.supported === false) { + throw new UnsupportedVersionError(version, updatesSupport.minVersion); + } + + const fileUrl = `${baseUrl}/${makeDownloadFilename(this.nativeUpdater, version)}`; const sha512 = await fetchChecksum(fileUrl); return { @@ -138,10 +147,36 @@ async function fetchChecksum(fileUrl: string): Promise { `Could not retrieve checksum from "${response.url}" (${response.status} ${response.statusText}).` ); } - const checksumText = await response.text(); + // Trim the response which ends with a new line. + const checksumText = (await response.text()).trim(); if (!CHECKSUM_FORMAT.test(checksumText)) { throw new Error(`Invalid checksum format ${checksumText}`); } return checksumText.split(' ').at(0); } + +// TODO(gzdunek) DELETE IN v20.0.0 +function areManagedUpdatesSupportedInConnect( + version: string +): { supported: true } | { supported: false; minVersion: string } { + const thresholds = { + 18: '18.2.0', + 17: '17.7.3', + }; + + const majorVersion = major(version); + if (majorVersion >= 19) { + return { supported: true }; + } + + const minVersion = thresholds[majorVersion]; + if (!minVersion) { + // For any lower version show v17 as the min supported version. + return { supported: false, minVersion: thresholds['17'] }; + } + + return compare(version, minVersion) >= 0 + ? { supported: true } + : { supported: false, minVersion }; +} diff --git a/web/packages/teleterm/src/services/appUpdater/errors.ts b/web/packages/teleterm/src/services/appUpdater/errors.ts new file mode 100644 index 0000000000000..2d50e05348f42 --- /dev/null +++ b/web/packages/teleterm/src/services/appUpdater/errors.ts @@ -0,0 +1,31 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Thrown when the app cannot be updated to unsupported version. + * + * Kept in a separate file to allow importing in the renderer process. + */ +export class UnsupportedVersionError extends Error { + constructor(wantedVersion: string, minVersion: string) { + super( + `Teleport Connect cannot update to version ${wantedVersion}. Managed updates are supported in version ${minVersion} and later.` + ); + this.name = 'UnsupportedVersionError'; + } +} diff --git a/web/packages/teleterm/src/services/tshd/types.ts b/web/packages/teleterm/src/services/tshd/types.ts index bd9a702a8ab78..0cfeec6135b5d 100644 --- a/web/packages/teleterm/src/services/tshd/types.ts +++ b/web/packages/teleterm/src/services/tshd/types.ts @@ -16,10 +16,6 @@ * along with this program. If not, see . */ -import { SortType } from 'design/DataTable/types'; - -import type * as uri from 'teleterm/ui/uri'; - /* * * Do not add new imports to this file, we're trying to get rid of types.ts files. @@ -74,12 +70,6 @@ export { */ Label, } from 'gen-proto-ts/teleport/lib/teleterm/v1/label_pb'; -export { - /** - * @deprecated Import directly from gen-proto-ts instead. - */ - AuthProvider, -} from 'gen-proto-ts/teleport/lib/teleterm/v1/auth_settings_pb'; export { /** * @deprecated Import directly from gen-proto-ts instead. @@ -115,19 +105,3 @@ export type GatewayProtocol = | 'cockroachdb' | 'redis' | 'sqlserver'; - -/** @deprecated Move to a better suited file. */ -export type GetResourcesParams = { - clusterUri: uri.ClusterUri; - // sort is a required field because it has direct implications on performance of ListResources. - sort: SortType | null; - // limit cannot be omitted and must be greater than zero, otherwise ListResources is going to - // return an error. - limit: number; - // search is used for regular search. - search?: string; - searchAsRoles?: string; - startKey?: string; - // query is used for advanced search. - query?: string; -}; diff --git a/web/packages/teleterm/src/ui/App.tsx b/web/packages/teleterm/src/ui/App.tsx index ba05e9cf37950..5356514b244e7 100644 --- a/web/packages/teleterm/src/ui/App.tsx +++ b/web/packages/teleterm/src/ui/App.tsx @@ -21,6 +21,7 @@ import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { AppInitializer } from 'teleterm/ui/AppInitializer'; +import { AppUpdaterContextProvider } from 'teleterm/ui/AppUpdater'; import AppContext from './appContext'; import AppContextProvider from './appContextProvider'; @@ -41,17 +42,19 @@ export const App: React.FC<{ - - - - - + + + + + + - - - - - + + + + + + diff --git a/web/packages/teleterm/src/ui/AppUpdater/AppUpdater.story.tsx b/web/packages/teleterm/src/ui/AppUpdater/AppUpdater.story.tsx index 0839c366d5425..c8245a2a5b163 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/AppUpdater.story.tsx +++ b/web/packages/teleterm/src/ui/AppUpdater/AppUpdater.story.tsx @@ -291,7 +291,7 @@ function WidgetAndDetails(storyProps: StoryProps) { return ( - +

Widget View

The component is rendered in the login form. diff --git a/web/packages/teleterm/src/ui/AppUpdater/AppUpdaterContext.tsx b/web/packages/teleterm/src/ui/AppUpdater/AppUpdaterContext.tsx new file mode 100644 index 0000000000000..afdf56fe91220 --- /dev/null +++ b/web/packages/teleterm/src/ui/AppUpdater/AppUpdaterContext.tsx @@ -0,0 +1,92 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { + createContext, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import { deserializeError } from 'teleterm/mainProcess/ipcSerializer'; +import { type AppUpdateEvent } from 'teleterm/services/appUpdater'; +import { useAppContext } from 'teleterm/ui/appContextProvider'; + +interface AppUpdaterContext { + updateEvent: AppUpdateEvent; + openDialog(): void; +} + +const AppUpdaterContext = createContext(null); + +export function AppUpdaterContextProvider(props: PropsWithChildren) { + const appContext = useAppContext(); + const [updateEvent, setUpdateEvent] = useState({ + kind: 'checking-for-update', + }); + + const openDialog = useCallback(() => { + appContext.modalsService.openRegularDialog({ kind: 'app-updates' }); + }, [appContext]); + + useEffect(() => { + const { cleanup: cleanUpEvents } = + appContext.mainProcessClient.subscribeToAppUpdateEvents(event => { + if (event.kind === 'error') { + event.error = deserializeError(event.error); + } + setUpdateEvent(event); + }); + const { cleanup: cleanUpDialog } = + appContext.mainProcessClient.subscribeToOpenAppUpdateDialog(openDialog); + + return () => { + cleanUpEvents(); + cleanUpDialog(); + }; + }, [appContext, openDialog]); + + const value = useMemo( + () => ({ + updateEvent, + openDialog, + }), + [updateEvent, openDialog] + ); + + return ( + + {props.children} + + ); +} + +export const useAppUpdaterContext = () => { + const context = useContext(AppUpdaterContext); + + if (!context) { + throw new Error( + 'useAppUpdaterContext must be used within an AppUpdaterContext' + ); + } + + return context; +}; diff --git a/web/packages/teleterm/src/ui/AppUpdater/AutoUpdatesManagement.tsx b/web/packages/teleterm/src/ui/AppUpdater/AutoUpdatesManagement.tsx index f50ee88770ac3..6245760b43790 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/AutoUpdatesManagement.tsx +++ b/web/packages/teleterm/src/ui/AppUpdater/AutoUpdatesManagement.tsx @@ -19,12 +19,11 @@ import { ReactNode, useState } from 'react'; import { Alert } from 'design/Alert'; -import { CheckboxInput } from 'design/Checkbox'; -import { Stack } from 'design/Flex'; +import Flex, { Stack } from 'design/Flex'; import { Cog } from 'design/Icon'; import Link from 'design/Link'; import { RadioGroup } from 'design/RadioGroup'; -import Text from 'design/Text'; +import { H3, P3 } from 'design/Text'; import { pluralize } from 'shared/utils/text'; import { @@ -35,11 +34,7 @@ import { } from 'teleterm/services/appUpdater'; import { RootClusterUri } from 'teleterm/ui/uri'; -import { - ClusterGetter, - clusterNameGetter, - makeUnreachableClusterText, -} from './common'; +import { ClusterGetter, clusterNameGetter } from './common'; const listFormatter = new Intl.ListFormat('en', { style: 'long', @@ -55,36 +50,19 @@ export function AutoUpdatesManagement(props: { }) { const { status } = props; - const { unreachableClusters } = status.options; const getClusterName = clusterNameGetter(props.clusterGetter); - const unreachableClustersText = makeUnreachableClusterText( - unreachableClusters, - getClusterName - ); const content = status.enabled === true ? makeContentForEnabledAutoUpdates(status, getClusterName) - : makeContentForDisabledAutoUpdates(status, unreachableClustersText); - const hasUnreachableClusters = unreachableClusters.length > 0; - const refreshButton = { - content: 'Refresh', + : makeContentForDisabledAutoUpdates(status); + const retryButton = { + content: 'Retry', onClick: props.onCheckForUpdates, disabled: props.updateEventKind === 'download-progress', }; return ( <> - {hasUnreachableClusters && !content.isUnreachableError && ( - - Unreachable clusters - - )} {content && ( {'title' in content ? content.title : ''} @@ -102,6 +80,7 @@ export function AutoUpdatesManagement(props: { changeManagingCluster={props.changeManagingCluster} isCheckingForUpdates={props.updateEventKind === 'checking-for-update'} getClusterName={getClusterName} + onRetry={props.onCheckForUpdates} // Resets localIsAutoManaged checkbox. key={JSON.stringify(status)} /> @@ -114,89 +93,56 @@ function ManagingClusterSelector({ isCheckingForUpdates, changeManagingCluster, getClusterName, + onRetry, }: { autoUpdatesStatus: AutoUpdatesStatus; isCheckingForUpdates: boolean; changeManagingCluster(clusterUri: RootClusterUri | undefined): void; getClusterName(clusterUri: RootClusterUri): string; + onRetry(): void; }) { - const isAutoManaged = - autoUpdatesStatus.enabled && - autoUpdatesStatus.source === 'highest-compatible'; - // A local state allows us to unselect the checkbox without choosing any managing cluster. - // Additionally, the cluster can be selected in the UI optimistically, without waiting for - // autoUpdatesStatus refresh. - // True means selected checkbox, false - unselected, string value - selected cluster. + // Allows optimistic UI updates without waiting for autoUpdatesStatus. const [optimisticManagingCluster, setOptimisticManagingCluster] = useState< - boolean | RootClusterUri - >(isAutoManaged || autoUpdatesStatus.options.managingClusterUri || false); + '' | RootClusterUri + >(autoUpdatesStatus.options.managingClusterUri || ''); - const isMostCompatibleCheckboxDisabled = - isCheckingForUpdates || !autoUpdatesStatus.options.highestCompatibleVersion; - const disabledClusterSelection = - optimisticManagingCluster === true || isCheckingForUpdates; const options = makeOptions({ status: autoUpdatesStatus, - getClusterName: getClusterName, - disabled: disabledClusterSelection, + getClusterName, + disabled: isCheckingForUpdates, + highestCompatibleVersion: + autoUpdatesStatus.options.highestCompatibleVersion, + onRetry, }); return ( <> - {(options.length > 1 || autoUpdatesStatus.options.managingClusterUri) && ( - - - <> - - Choose which cluster should manage updates: - - { - setOptimisticManagingCluster(clusterUri); - changeManagingCluster(clusterUri); - }} - options={options} - /> - -
props.theme.colors.spotBackground[2]}; - border-style: solid; - width: 100%; - margin-bottom: 0; - margin-top: ${p => p.theme.space[2]}px; - `} + {/* Two because there is always 'Use highest compatible version' option */} + {(options.length > 2 || autoUpdatesStatus.options.managingClusterUri) && ( + + p.theme.colors.interactive.tonal.neutral[0]}; + `} + > + +

Updates source

+ Choose which cluster to follow for updates. +
+ { + setOptimisticManagingCluster(clusterUri); + changeManagingCluster(clusterUri || undefined); + }} + options={options} />
)} @@ -207,25 +153,43 @@ function ManagingClusterSelector({ function makeOptions({ status, getClusterName, + highestCompatibleVersion, disabled, + onRetry, }: { status: AutoUpdatesStatus; getClusterName: (clusterUri: RootClusterUri) => string; disabled: boolean; + highestCompatibleVersion: string; + onRetry(): void; }) { + const highestCompatible = { + label: 'Use the highest compatible version from your clusters', + helperText: !highestCompatibleVersion ? ( + + ) : ( + `Teleport Connect ${highestCompatibleVersion} · Compatible with all clusters.` + ), + disabled: disabled, + value: '', + }; + const candidateClusters = status.options.clusters .filter(c => c.toolsAutoUpdate) .map(c => { - const otherNames = c.otherCompatibleClusters.map(c => getClusterName(c)); - const compatibility = - otherNames.length === 0 - ? 'only compatible with this cluster' - : `also compatible with ${listFormatter.format(otherNames)}`; + const otherCompatibleClusters = c.otherCompatibleClusters.map(c => + getClusterName(c) + ); + const compatibility = otherCompatibleClusters.length + ? `Also compatible with ${pluralize(otherCompatibleClusters.length, 'cluster')} ${listFormatter.format(otherCompatibleClusters.toSorted())}.` + : ''; return { disabled, label: getClusterName(c.clusterUri), - helperText: `${c.toolsVersion} client, ${compatibility}.`, + helperText: [`Teleport Connect ${c.toolsVersion}`, compatibility] + .filter(Boolean) + .join(' · '), value: c.clusterUri, }; }); @@ -238,10 +202,9 @@ function makeOptions({ label: getClusterName(c.clusterUri), helperText: ( <> - {c.toolsVersion} client. + Teleport Connect {c.toolsVersion}
- ⚠︎ Cannot provide updates, automatic client tools updates are - disabled on this cluster. + ), value: c.clusterUri, @@ -253,30 +216,83 @@ function makeOptions({ disabled, label: getClusterName(cluster.clusterUri), helperText: ( - <> - ⚠︎ Cannot provide updates, cluster is unreachable. -
- {cluster.errorMessage} - + ), value: cluster.clusterUri, }) ); return [ + highestCompatible, ...candidateClusters, ...nonCandidateClusters, ...unreachableClusters, ]; } +function TextWithWarning(props: { text: string }) { + return ( + + props.theme.colors.error.main}; + `} + > + ⚠︎︎ + {' '} + {props.text} + + ); +} + +function UnreachableClusterHelper(props: { error: string; onRetry(): void }) { + const [showsMore, setShowsMore] = useState(false); + + return ( + + + {showsMore ? {props.error} : ''} + + {/* These buttons unfortunately trigger hover of the entire radio option */} + {/* because they are inside the label element.*/} + { + e.stopPropagation(); + e.preventDefault(); + setShowsMore(v => !v); + }} + > + {showsMore ? 'Show Less' : 'Show More'} + + { + e.stopPropagation(); + e.preventDefault(); + props.onRetry(); + }} + > + Retry + + + + ); +} + function makeContentForEnabledAutoUpdates( status: AutoUpdatesEnabled, getClusterName: (clusterUri: RootClusterUri) => string ): { description: string; kind: 'neutral' | 'warning'; - isUnreachableError?: boolean; + showRetry?: boolean; } { switch (status.source) { case 'env-var': @@ -285,40 +301,29 @@ function makeContentForEnabledAutoUpdates( description: `The app is set to stay on version ${status.version} by your device settings.`, }; case 'managing-cluster': - return { - kind: 'neutral', - description: `App updates are managed by the cluster ${getClusterName(status.options.managingClusterUri)}, which requires client version ${status.version}.`, - }; + return; case 'highest-compatible': const providingClusters = status.options.clusters .filter(c => c.toolsAutoUpdate && c.toolsVersion === status.version) .map(c => getClusterName(c.clusterUri)); - // There's only one cluster. - if (status.options.clusters.length === 1) { + // Show info if there's only one cluster. + if ( + status.options.clusters.length === 1 && + status.options.unreachableClusters.length === 0 + ) { return { kind: 'neutral', description: `App updates are managed by the cluster ${providingClusters}, which requires client version ${status.version}.`, }; } - - return { - kind: 'neutral', - description: - `Version ${status.version} from the ${pluralize(providingClusters.length, 'cluster')}` + - ` ${listFormatter.format(providingClusters.map(c => c))} was chosen as the highest compatible.`, - }; } } -function makeContentForDisabledAutoUpdates( - updateSource: AutoUpdatesDisabled, - unreachableClustersText: string -): { +function makeContentForDisabledAutoUpdates(updateSource: AutoUpdatesDisabled): { title?: string; description?: ReactNode; kind: 'danger' | 'neutral'; - /** Determines if the notification shows an unreachable error so it shouldn't be shown separately. */ - isUnreachableError?: boolean; + showRetry?: boolean; } { switch (updateSource.reason) { case 'disabled-by-env-var': @@ -327,7 +332,7 @@ function makeContentForDisabledAutoUpdates( description: 'App updates are disabled by your device settings.', }; case 'no-cluster-with-auto-update': - // There's only one cluster and it's unreachable. + // There's only one cluster and it's unreachable, show error inline. if ( updateSource.options.unreachableClusters.length === 1 && updateSource.options.clusters.length === 0 @@ -335,18 +340,16 @@ function makeContentForDisabledAutoUpdates( return { kind: 'danger', title: 'App updates are disabled, the cluster is unreachable', - isUnreachableError: true, description: updateSource.options.unreachableClusters.at(0).errorMessage, + showRetry: true, }; } // There is no cluster with updates enabled and some clusters cannot be reached. if (updateSource.options.unreachableClusters.length > 1) { return { kind: 'danger', - title: 'App updates are disabled', - isUnreachableError: true, - description: unreachableClustersText, + title: 'App updates are disabled.', }; } // All clusters have updates disabled. @@ -364,19 +367,9 @@ function makeContentForDisabledAutoUpdates( ), }; case 'managing-cluster-unable-to-manage': - const isManagingClusterUnreachable = - updateSource.options.unreachableClusters.some( - c => c.clusterUri === updateSource.options.managingClusterUri - ); return { kind: 'danger', - title: 'The chosen cluster cannot provide app updates', - // If managing cluster cannot provide updates because it's unreachable, - // the error needs to be shown here, instead of in a separate alert. - isUnreachableError: isManagingClusterUnreachable, - description: isManagingClusterUnreachable - ? unreachableClustersText - : undefined, + title: 'App updates are disabled.', }; case 'no-compatible-version': return { diff --git a/web/packages/teleterm/src/ui/AppUpdater/DetailsView.test.tsx b/web/packages/teleterm/src/ui/AppUpdater/DetailsView.test.tsx index d2ad82c0edf38..c79c0900105e2 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/DetailsView.test.tsx +++ b/web/packages/teleterm/src/ui/AppUpdater/DetailsView.test.tsx @@ -65,7 +65,7 @@ test('download button is available when autoDownload is false', async () => { ).toBeInTheDocument(); }); -test('when there is no compatible client version, user needs to select cluster', async () => { +test('when there are multiple clusters available, managing cluster can be selected', async () => { const changeManagingClusterSpy = jest.fn(); render( undefined, }} - platform={'darwin'} + platform="darwin" onCheckForUpdates={() => {}} onDownload={() => {}} onCancelDownload={() => {}} @@ -112,30 +112,24 @@ test('when there is no compatible client version, user needs to select cluster', 'Your clusters require incompatible client versions. To enable app updates, select which cluster should manage them.' ) ).toBeInTheDocument(); - expect( - await screen.findByText( - 'Unable to retrieve accepted client versions from the cluster baz.' - ) - ).toBeInTheDocument(); - expect( - await screen.findByRole('checkbox', { - name: 'Use the highest compatible version from your clusters', - }) - ).not.toBeChecked(); + const radioOptions = await screen.findAllByRole('radio'); - expect(radioOptions).toHaveLength(3); + expect(radioOptions).toHaveLength(4); expect(radioOptions.at(0).closest('label')).toHaveTextContent( // The cluster name and the helper text are normally in separate lines. - 'foo16.0.0 client, only compatible with this cluster.' + 'Use the highest compatible version from your clusters⚠︎︎ No cluster provides a version compatible with all other clusters.' ); expect(radioOptions.at(1).closest('label')).toHaveTextContent( - 'bar18.0.0 client.⚠︎ Cannot provide updates, automatic client tools updates are disabled on this cluster.' + 'fooTeleport Connect 16.0.0' ); expect(radioOptions.at(2).closest('label')).toHaveTextContent( - 'baz⚠︎ Cannot provide updates, cluster is unreachable.NET_ERR' + 'barTeleport Connect 18.0.0⚠︎︎ Automatic client tools updates are disabled on this cluster.' + ); + expect(radioOptions.at(3).closest('label')).toHaveTextContent( + 'baz⚠︎︎ Version unavailable · Cluster is unreachable.Show MoreRetry' ); - await userEvent.click(radioOptions.at(0)); + await userEvent.click(radioOptions.at(1)); expect(changeManagingClusterSpy).toHaveBeenCalledWith('/clusters/foo'); }); diff --git a/web/packages/teleterm/src/ui/AppUpdater/DetailsView.tsx b/web/packages/teleterm/src/ui/AppUpdater/DetailsView.tsx index 8c49998bc96c4..6658770abaab9 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/DetailsView.tsx +++ b/web/packages/teleterm/src/ui/AppUpdater/DetailsView.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { useId } from 'react'; +import { useId, useState } from 'react'; import { Alert, @@ -34,6 +34,7 @@ import { Checks, Info } from 'design/Icon'; import { Platform } from 'teleterm/mainProcess/types'; import { AppUpdateEvent, UpdateInfo } from 'teleterm/services/appUpdater'; +import { UnsupportedVersionError } from 'teleterm/services/appUpdater/errors'; import { RootClusterUri } from 'teleterm/ui/uri'; import { AutoUpdatesManagement } from './AutoUpdatesManagement'; @@ -89,6 +90,7 @@ export function DetailsView({ onDownload={onDownload} onCancelDownload={onCancelDownload} onInstall={onInstall} + key={JSON.stringify(updateEvent)} />
); @@ -109,6 +111,7 @@ function UpdaterState({ onCancelDownload(): void; onInstall(): void; }) { + const [downloadStarted, setDownloadStarted] = useState(false); switch (event.kind) { case 'checking-for-update': return ( @@ -126,12 +129,18 @@ function UpdaterState({ return ( - {event.autoDownload ? ( + {event.autoDownload || downloadStarted ? ( Starting Download… ) : ( - + { + setDownloadStarted(true); + onDownload(); + }} + > Download )} @@ -160,12 +169,16 @@ function UpdaterState({ case 'error': return ( - - An error occurred - {event.update && ( )} + + {event.update + ? 'Update failed' + : event.error.name === UnsupportedVersionError.name + ? 'Incompatible managed update version' + : 'Unable to check for app updates'} + Try Again @@ -211,6 +224,7 @@ function AvailableUpdate(props: { update: UpdateInfo; platform: Platform }) { return ( + A new version is available. {props.platform === 'darwin' ? ( App icon diff --git a/web/packages/teleterm/src/ui/AppUpdater/WidgetView.tsx b/web/packages/teleterm/src/ui/AppUpdater/WidgetView.tsx index 90f1972cc3b31..086881c7dc3e1 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/WidgetView.tsx +++ b/web/packages/teleterm/src/ui/AppUpdater/WidgetView.tsx @@ -18,18 +18,27 @@ import { ComponentType } from 'react'; -import { ButtonPrimary, ButtonSecondary, Flex, P3, Stack, Text } from 'design'; +import { + ButtonBorder, + ButtonPrimary, + ButtonSecondary, + Flex, + P3, + Stack, + Text, +} from 'design'; import { Alert } from 'design/Alert'; -import { Info, Warning } from 'design/Icon'; +import { Info } from 'design/Icon'; import { IconProps } from 'design/Icon/Icon'; +import { SpaceProps } from 'design/system'; import { UnreachableCluster } from 'gen-proto-ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb'; -import { getErrorMessage } from 'shared/utils/error'; import { Platform } from 'teleterm/mainProcess/types'; import { AppUpdateEvent, AutoUpdatesStatus, } from 'teleterm/services/appUpdater'; +import { UnsupportedVersionError } from 'teleterm/services/appUpdater/errors'; import { RootClusterUri } from 'teleterm/ui/uri'; import { @@ -50,34 +59,42 @@ import { * Hidden for `update-not-available` and `checking-for-update` events, * unless there's an issue that prevents autoupdates from working. */ -export function WidgetView(props: { +export function WidgetView({ + clusterGetter, + onDownload, + onInstall, + onMore, + platform, + updateEvent, + ...rest +}: { updateEvent: AppUpdateEvent; platform: Platform; clusterGetter: ClusterGetter; onMore(): void; onDownload(): void; onInstall(): void; -}) { - const getClusterName = clusterNameGetter(props.clusterGetter); - const { updateEvent } = props; +} & SpaceProps) { + const getClusterName = clusterNameGetter(clusterGetter); const { autoUpdatesStatus } = updateEvent; - const issueRequiringAttention = findAutoUpdatesIssuesRequiringAttention( - autoUpdatesStatus, - getClusterName - ); + const issueRequiringAttention = + autoUpdatesStatus && + findAutoUpdatesIssuesRequiringAttention(autoUpdatesStatus, getClusterName); if (issueRequiringAttention) { return ( + {issueRequiringAttention} + {/*TODO(gzdunek): Allow Alert to show buttons at the bottom. */} + Resolve + + } > App updates are disabled @@ -89,15 +106,19 @@ export function WidgetView(props: { return ( + {updateEvent.error.message} + {/*TODO(gzdunek): Allow Alert to show buttons at the bottom. */} + More + + } > - Unable to check for app updates + {updateEvent.error.name === UnsupportedVersionError.name + ? 'Incompatible managed update version' + : 'Unable to check for app updates'} ); } @@ -111,8 +132,8 @@ export function WidgetView(props: { const { description, button } = makeUpdaterContent({ updateEvent, - onDownload: props.onDownload, - onInstall: props.onInstall, + onDownload, + onInstall, }); const unreachableClusters = @@ -125,22 +146,32 @@ export function WidgetView(props: { return ( ); } -function AvailableUpdate(props: { +function AvailableUpdate({ + description, + downloadHost, + onMore, + platform, + primaryButton, + unreachableClusters, + version, + ...rest +}: { version: string; - description: string | { Icon: ComponentType; text: string }; + description: string; unreachableClusters: UnreachableCluster[]; downloadHost: string; platform: Platform; @@ -150,17 +181,16 @@ function AvailableUpdate(props: { name: string; onClick(): void; }; -}) { - const hasUnreachableClusters = !!props.unreachableClusters.length; +} & SpaceProps) { + const hasUnreachableClusters = !!unreachableClusters.length; const isNonTeleportServer = - props.downloadHost && !isTeleportDownloadHost(props.downloadHost); + downloadHost && !isTeleportDownloadHost(downloadHost); return ( // Mimics a neutral alert. props.theme.colors.text.disabled}; background: ${props => props.theme.colors.interactive.tonal.neutral[0]}; @@ -168,10 +198,11 @@ function AvailableUpdate(props: { borderRadius={3} px={3} py="12px" + {...rest} > - {props.platform === 'darwin' ? ( + {platform === 'darwin' ? ( App icon ) : ( )} - Teleport Connect {props.version} - {typeof props.description === 'object' ? ( - - - {props.description.text} - - ) : ( - {props.description} - )} + Teleport Connect {version} + {description} - {props.primaryButton && ( - - {props.primaryButton.name} + {primaryButton && ( + + {primaryButton.name} )} - + More @@ -208,14 +232,14 @@ function AvailableUpdate(props: { {hasUnreachableClusters && ( )} {isNonTeleportServer && ( )} @@ -248,7 +272,7 @@ function makeUpdaterContent({ onDownload(): void; onInstall(): void; }): { - description: string | { Icon: ComponentType; text: string }; + description: string; button?: { name: string; action(): void; @@ -283,10 +307,7 @@ function makeUpdaterContent({ }; case 'error': return { - description: { - Icon: Warning, - text: getErrorMessage(updateEvent.error), - }, + description: 'Update failed', }; } } diff --git a/web/packages/teleterm/src/ui/AppUpdater/common.ts b/web/packages/teleterm/src/ui/AppUpdater/common.ts index 1fa188c14ed42..1ed2790685bc6 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/common.ts +++ b/web/packages/teleterm/src/ui/AppUpdater/common.ts @@ -38,7 +38,9 @@ export function getDownloadHost(event: AppUpdateEvent): string { case 'update-available': case 'download-progress': case 'update-downloaded': - return new URL(event.update.files.at(0).url).host; + case 'error': + const url = event.update?.files?.at(0)?.url; + return url && new URL(url).host; default: return ''; } diff --git a/web/packages/teleterm/src/ui/AppUpdater/index.ts b/web/packages/teleterm/src/ui/AppUpdater/index.ts index 81de53b7c4c90..60baf477f7e4f 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/index.ts +++ b/web/packages/teleterm/src/ui/AppUpdater/index.ts @@ -18,3 +18,5 @@ export * from './DetailsView'; export * from './WidgetView'; +export * from './AppUpdaterContext'; +export { type ClusterGetter } from './common'; diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.story.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.story.tsx index a6ec4f912b95a..e29d668944984 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.story.tsx +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.story.tsx @@ -32,6 +32,7 @@ const meta: Meta = { argTypes: compatibilityArgType, args: { compatibility: 'compatible', + showUpdate: true, }, }; export default meta; @@ -130,7 +131,18 @@ export const SsoOnly = (storyProps: StoryProps) => { export const SsoPrompt = (storyProps: StoryProps) => { const props = makeProps(storyProps); props.loginAttempt.status = 'processing'; - props.shouldPromptSsoStatus = true; + props.ssoPrompt = 'follow-browser-steps'; + return ( + + + + ); +}; + +export const SsoPromptWaitForSync = (storyProps: StoryProps) => { + const props = makeProps(storyProps); + props.loginAttempt.status = 'processing'; + props.ssoPrompt = 'wait-for-sync'; return ( @@ -154,15 +166,19 @@ export const SsoError = (storyProps: StoryProps) => { }; export const LocalWithPasswordless = (storyProps: StoryProps) => { + const props = makeProps(storyProps); + props.initAttempt.data.allowPasswordless = true; + return ( - + ); }; export const LocalLoggedInUserWithPasswordless = (storyProps: StoryProps) => { const props = makeProps(storyProps); + props.initAttempt.data.allowPasswordless = true; props.loggedInUserName = 'llama'; return ( diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.test.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.test.tsx index c42974ed4cac6..3d5cd873e40fc 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.test.tsx +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.test.tsx @@ -17,11 +17,18 @@ */ import userEvent from '@testing-library/user-event'; +import { act } from 'react'; import { render, screen } from 'design/utils/testing'; +import { ClientVersionStatus } from 'gen-proto-ts/teleport/lib/teleterm/v1/auth_settings_pb'; +import { TshdClient } from 'teleterm/services/tshd'; import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient'; -import { makeRootCluster } from 'teleterm/services/tshd/testHelpers'; +import { + makeAuthSettings, + makeRootCluster, +} from 'teleterm/services/tshd/testHelpers'; +import { AppUpdaterContextProvider } from 'teleterm/ui/AppUpdater'; import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; @@ -41,12 +48,14 @@ it('keeps the focus on the password field on submission error', async () => { render( - {}} - prefill={{ username: 'alice' }} - reason={undefined} - /> + + {}} + prefill={{ username: 'alice' }} + reason={undefined} + /> + ); @@ -59,3 +68,143 @@ it('keeps the focus on the password field on submission error', async () => { await screen.findByText('whoops something went wrong'); expect(passwordField).toHaveFocus(); }); + +it('shows go to updates button in compatibility warning if there are clusters providing updates', async () => { + const clusterFoo = makeRootCluster({ uri: '/clusters/foo' }); + const clusterBar = makeRootCluster({ uri: '/clusters/bar' }); + const appContext = new MockAppContext(); + appContext.addRootCluster(clusterFoo); + appContext.addRootCluster(clusterBar); + + jest.spyOn(appContext.tshd, 'getAuthSettings').mockResolvedValue( + new MockedUnaryCall({ + localAuthEnabled: true, + authProviders: [], + hasMessageOfTheDay: false, + authType: 'local', + allowPasswordless: false, + localConnectorName: '', + clientVersionStatus: ClientVersionStatus.TOO_NEW, + versions: { + minClient: '16.0.0-aa', + client: '17.0.0', + server: '17.0.0', + }, + }) + ); + + jest + .spyOn(appContext.mainProcessClient, 'subscribeToAppUpdateEvents') + .mockImplementation(callback => { + callback({ + kind: 'update-not-available', + autoUpdatesStatus: { + enabled: true, + source: 'managing-cluster', + version: '19.0.0', + options: { + highestCompatibleVersion: '', + managingClusterUri: clusterBar.uri, + unreachableClusters: [], + clusters: [ + { + clusterUri: clusterFoo.uri, + toolsAutoUpdate: true, + toolsVersion: '19.0.0', + minToolsVersion: '18.0.0-aa', + otherCompatibleClusters: [], + }, + { + clusterUri: clusterBar.uri, + toolsAutoUpdate: true, + toolsVersion: '17.0.0', + minToolsVersion: '16.0.0-aa', + otherCompatibleClusters: [], + }, + ], + }, + }, + }); + return { cleanup: () => {} }; + }); + + render( + + + {}} + prefill={{ username: 'alice' }} + reason={undefined} + /> + + + ); + + expect( + await screen.findByText('Detected potentially incompatible version') + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: 'Go to Auto Updates' }) + ).toBeVisible(); +}); + +it('shows two separate prompt texts during SSO login', async () => { + const user = userEvent.setup(); + const cluster = makeRootCluster(); + const appContext = new MockAppContext(); + appContext.addRootCluster(cluster); + + jest.spyOn(appContext.tshd, 'getAuthSettings').mockReturnValue( + new MockedUnaryCall( + makeAuthSettings({ + authType: 'github', + authProviders: [ + { displayName: 'GitHub', name: 'github', type: 'github' }, + ], + }) + ) + ); + + const { resolve: resolveLoginPromise, promise: loginPromise } = + Promise.withResolvers>(); + jest + .spyOn(appContext.tshd, 'login') + .mockImplementation(async () => loginPromise); + + const { resolve: resolveGetClusterPromise, promise: getClusterPromise } = + Promise.withResolvers>(); + jest + .spyOn(appContext.tshd, 'getCluster') + .mockImplementation(async () => getClusterPromise); + + render( + + + {}} + prefill={{ username: 'alice' }} + reason={undefined} + /> + + + ); + + await user.click(await screen.findByText('GitHub')); + + expect( + screen.getByText(/follow the steps in the browser/) + ).toBeInTheDocument(); + + await act(async () => { + resolveLoginPromise(new MockedUnaryCall({})); + }); + + expect(screen.getByText(/Login successful/)).toBeInTheDocument(); + + await act(async () => { + // Resolve the promise to avoid leaving a hanging promise around. + resolveGetClusterPromise(new MockedUnaryCall(cluster)); + }); +}); diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.tsx index 1dcc9ec99d0ea..9e730a04ccade 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.tsx +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.tsx @@ -25,21 +25,25 @@ import { Flex, H2, Indicator, + StepSlider, Text, } from 'design'; import * as Alerts from 'design/Alert'; import { DialogContent, DialogHeader } from 'design/Dialog'; import * as Icons from 'design/Icon'; +import { ArrowBack } from 'design/Icon'; +import type { StepComponentProps } from 'design/StepSlider'; import { AuthSettings } from 'gen-proto-ts/teleport/lib/teleterm/v1/auth_settings_pb'; import { PrimaryAuthType } from 'shared/services'; import { publicAddrWithTargetPort } from 'teleterm/services/tshd/app'; import { getTargetNameFromUri } from 'teleterm/services/tshd/gateway'; +import { DetailsView } from 'teleterm/ui/AppUpdater'; import { ClusterConnectReason } from 'teleterm/ui/services/modals'; import { outermostPadding } from '../spacing'; import LoginForm from './FormLogin'; -import useClusterLogin, { Props, State } from './useClusterLogin'; +import { Props, State, useClusterLogin } from './useClusterLogin'; export function ClusterLogin(props: Props & { reason: ClusterConnectReason }) { const { reason, ...otherProps } = props; @@ -47,11 +51,28 @@ export function ClusterLogin(props: Props & { reason: ClusterConnectReason }) { return ; } +export const ClusterLoginPresentation = ( + props: ClusterLoginPresentationProps +) => { + return ( + + ); +}; + export type ClusterLoginPresentationProps = State & { reason: ClusterConnectReason; }; -export function ClusterLoginPresentation({ +function ClusterLoginForm({ title, initAttempt, init, @@ -63,15 +84,24 @@ export function ClusterLoginPresentation({ onCloseDialog, onAbort, loggedInUserName, - shouldPromptSsoStatus, + ssoPrompt, passwordlessLoginState, reason, shouldSkipVersionCheck, disableVersionCheck, platform, -}: ClusterLoginPresentationProps) { + next, + refCallback, + clusterGetter, + changeAppUpdatesManagingCluster, + appUpdateEvent, + cancelAppUpdateDownload, + quitAndInstallAppUpdate, + downloadAppUpdate, + checkForAppUpdates, +}: ClusterLoginPresentationProps & StepComponentProps) { return ( - <> +

Log in to {title} @@ -120,18 +150,72 @@ export function ClusterLoginPresentation({ onAbort={onAbort} loginAttempt={loginAttempt} clearLoginAttempt={clearLoginAttempt} - shouldPromptSsoStatus={shouldPromptSsoStatus} + ssoPrompt={ssoPrompt} passwordlessLoginState={passwordlessLoginState} shouldSkipVersionCheck={shouldSkipVersionCheck} disableVersionCheck={disableVersionCheck} platform={platform} + clusterGetter={clusterGetter} + checkForAppUpdates={checkForAppUpdates} + changeAppUpdatesManagingCluster={changeAppUpdatesManagingCluster} + appUpdateEvent={appUpdateEvent} + cancelAppUpdateDownload={cancelAppUpdateDownload} + downloadAppUpdate={downloadAppUpdate} + quitAndInstallAppUpdate={quitAndInstallAppUpdate} + switchToAppUpdateDetails={next} /> )} - + ); } +const AppUpdateDetails = ({ + refCallback, + platform, + downloadAppUpdate, + checkForAppUpdates, + cancelAppUpdateDownload, + quitAndInstallAppUpdate, + changeAppUpdatesManagingCluster, + appUpdateEvent, + clusterGetter, + onCloseDialog, + prev, +}: ClusterLoginPresentationProps & StepComponentProps) => { + return ( + + + + + + +

App Updates

+
+ + + +
+ + quitAndInstallAppUpdate()} + platform={platform} + changeManagingCluster={clusterUri => + changeAppUpdatesManagingCluster(clusterUri) + } + updateEvent={appUpdateEvent} + onDownload={() => downloadAppUpdate()} + onCancelDownload={() => cancelAppUpdateDownload()} + clusterGetter={clusterGetter} + onCheckForUpdates={() => checkForAppUpdates()} + /> + +
+ ); +}; + +const loginViews = { default: [ClusterLoginForm, AppUpdateDetails] }; + function getPrimaryAuthType(auth: AuthSettings): PrimaryAuthType { if (auth.localConnectorName === 'passwordless') { return 'passwordless'; diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/CompatibilityWarning.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/CompatibilityWarning.tsx index 8fe44bae929d2..541c9ced7af9f 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/CompatibilityWarning.tsx +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/CompatibilityWarning.tsx @@ -27,10 +27,12 @@ import { import { Platform } from 'teleterm/mainProcess/types'; export function CompatibilityWarning(props: { + isAnyClusterProvidingUpdates: boolean; authSettings: AuthSettings; platform: Platform; shouldSkipVersionCheck: boolean; disableVersionCheck(): void; + onSwitchToAppUpdateDetails(): void; mx?: number; }) { if (props.shouldSkipVersionCheck) { @@ -56,15 +58,22 @@ export function CompatibilityWarning(props: { fill="border" intent="neutral" inputAlignment - action={{ - content: ( - <> - Download in Browser - - - ), - href: buildDownloadUrl(props.platform), - }} + action={ + props.isAnyClusterProvidingUpdates + ? { + content: 'Go to Auto Updates', + onClick: props.onSwitchToAppUpdateDetails, + } + : { + content: ( + <> + Download in Browser + + + ), + href: buildDownloadUrl(props.platform), + } + } /> - + ); } @@ -68,6 +73,19 @@ export default function LoginForm(props: Props) { shouldSkipVersionCheck: props.shouldSkipVersionCheck, disableVersionCheck: props.disableVersionCheck, platform: props.platform, + isAnyClusterProvidingUpdates: + props.appUpdateEvent.autoUpdatesStatus?.options.clusters.some( + c => c.toolsAutoUpdate + ), + onSwitchToAppUpdateDetails: props.switchToAppUpdateDetails, + }; + const appUpdateWidgetViewProps = { + updateEvent: props.appUpdateEvent, + onDownload: () => props.downloadAppUpdate(), + onInstall: () => props.quitAndInstallAppUpdate(), + platform: props.platform, + onMore: () => props.switchToAppUpdateDetails(), + clusterGetter: props.clusterGetter, }; const ssoEnabled = authProviders?.length > 0; @@ -82,6 +100,7 @@ export default function LoginForm(props: Props) { )} + ); @@ -97,6 +116,7 @@ export default function LoginForm(props: Props) { Login has not been enabled + ); } @@ -120,6 +140,7 @@ export default function LoginForm(props: Props) { mx={outermostPadding} {...compatibilityWarningProps} /> + flows={loginViews} currFlow={'default'} @@ -307,20 +328,28 @@ type LoginAttempt = Attempt; export type Props = { authSettings: AuthSettings; - shouldPromptSsoStatus: boolean; + ssoPrompt: SsoPrompt; passwordlessLoginState: PasswordlessLoginState; loginAttempt: LoginAttempt; clearLoginAttempt(): void; primaryAuthType: PrimaryAuthType; loggedInUserName?: string; onAbort(): void; - onLoginWithSso(provider: types.AuthProvider): void; + onLoginWithSso(provider: AuthProvider): void; onLoginWithPasswordless(): void; onLogin(username: string, password: string): void; autoFocus?: boolean; shouldSkipVersionCheck: boolean; disableVersionCheck(): void; platform: Platform; + switchToAppUpdateDetails(): void; + appUpdateEvent: AppUpdateEvent; + downloadAppUpdate(): Promise; + cancelAppUpdateDownload(): Promise; + checkForAppUpdates(): Promise; + quitAndInstallAppUpdate(): void; + changeAppUpdatesManagingCluster(clusterUri: RootClusterUri): Promise; + clusterGetter: ClusterGetter; }; const OutermostPadding = styled(Box).attrs({ px: outermostPadding })``; diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/FormSso/SsoButtons.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/FormSso/SsoButtons.tsx index db8887f8fffd2..50063aee5442c 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/FormSso/SsoButtons.tsx +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/FormLogin/FormSso/SsoButtons.tsx @@ -17,9 +17,9 @@ */ import { Flex, Text } from 'design'; +import { AuthProvider } from 'gen-proto-ts/teleport/lib/teleterm/v1/auth_settings_pb'; import ButtonSso, { guessProviderType } from 'shared/components/ButtonSso'; - -import * as types from 'teleterm/ui/services/clusters/types'; +import { AuthProviderType } from 'shared/services'; const SSOBtnList = ({ providers, @@ -31,7 +31,7 @@ const SSOBtnList = ({ const $btns = providers.map((item, index) => { let { name, type, displayName } = item; const title = displayName || `${prefixText} ${name}`; - const ssoType = guessProviderType(title, type as types.AuthProviderType); + const ssoType = guessProviderType(title, type as AuthProviderType); return ( ; +}) { + const { onCancel, ssoPrompt } = props; return ( - + - Please follow the steps in the new browser window to authenticate. + {ssoPrompt === 'follow-browser-steps' && ( + <>Please follow the steps in the browser to authenticate. + )} + + {ssoPrompt === 'wait-for-sync' && ( + <>Login successful, connecting to the cluster… + )} - {props.onCancel && ( - Cancel + {ssoPrompt === 'follow-browser-steps' && onCancel && ( + Cancel )} ); } - -export type Props = { - onCancel?(): void; -}; diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/storyHelpers.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/storyHelpers.tsx index ed40e03f43deb..ae887af504026 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/storyHelpers.tsx +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/storyHelpers.tsx @@ -35,6 +35,7 @@ export const TestContainer: FC = ({ children }) => ( export interface StoryProps { compatibility: 'compatible' | 'client-too-old' | 'client-too-new'; + showUpdate: boolean; } export const compatibilityArgType: ArgTypes = { @@ -43,13 +44,17 @@ export const compatibilityArgType: ArgTypes = { options: ['compatible', 'client-too-old', 'client-too-new'], description: 'Client compatibility', }, + showUpdate: { + type: 'boolean', + description: 'Show app update', + }, }; export function makeProps( storyProps: StoryProps ): ClusterLoginPresentationProps { const props: ClusterLoginPresentationProps = { - shouldPromptSsoStatus: false, + ssoPrompt: 'no-prompt', title: 'localhost', loginAttempt: { status: '', @@ -75,6 +80,27 @@ export function makeProps( shouldSkipVersionCheck: false, disableVersionCheck: () => {}, platform: 'darwin', + changeAppUpdatesManagingCluster: async () => {}, + checkForAppUpdates: async () => {}, + downloadAppUpdate: async () => {}, + clusterGetter: { + findCluster: () => undefined, + }, + quitAndInstallAppUpdate: async () => {}, + cancelAppUpdateDownload: async () => {}, + appUpdateEvent: { + kind: 'update-not-available', + autoUpdatesStatus: { + enabled: false, + reason: 'no-cluster-with-auto-update', + options: { + unreachableClusters: [], + clusters: [], + highestCompatibleVersion: '', + managingClusterUri: '', + }, + }, + }, }; switch (storyProps.compatibility) { @@ -99,5 +125,43 @@ export function makeProps( } } + if (storyProps.showUpdate) { + props.appUpdateEvent = { + kind: 'update-available', + update: { + version: '19.0.0', + files: [ + { + url: 'https://cdn.teleport.dev/connect-update', + sha512: '', + }, + ], + path: '', + releaseDate: '', + sha512: '', + }, + autoDownload: true, + autoUpdatesStatus: { + enabled: true, + source: 'highest-compatible', + version: '19.0.0', + options: { + unreachableClusters: [], + managingClusterUri: '', + clusters: [ + { + clusterUri: '/clusters/foo', + toolsAutoUpdate: true, + minToolsVersion: '18.0.0', + toolsVersion: '19.0.0', + otherCompatibleClusters: [], + }, + ], + highestCompatibleVersion: '19.0.0', + }, + }, + }; + } + return props; } diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.test.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.test.tsx new file mode 100644 index 0000000000000..5d5a0c4db23db --- /dev/null +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.test.tsx @@ -0,0 +1,80 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import { act } from 'react'; + +import { makeRootCluster } from 'teleterm/services/tshd/testHelpers'; +import { AppUpdaterContextProvider } from 'teleterm/ui/AppUpdater'; +import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; + +import { Props, useClusterLogin } from './useClusterLogin'; + +test('login into cluster and sync cluster', async () => { + const appContext = new MockAppContext(); + const rootCluster = makeRootCluster(); + appContext.addRootCluster(rootCluster); + + jest.spyOn(appContext.tshd, 'login'); + + const { result } = renderHook((props: Props) => useClusterLogin(props), { + initialProps: { + clusterUri: rootCluster.uri, + prefill: { username: '' }, + onCancel: () => {}, + onSuccess: () => {}, + }, + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => { + expect(result.current.initAttempt.status).toEqual('success'); + }); + + await act(async () => { + result.current.onLoginWithLocal('user', 'password'); + }); + + await waitFor(() => { + expect(result.current.loginAttempt.status).toEqual('success'); + }); + + expect(appContext.tshd.login).toHaveBeenCalledWith( + { + clusterUri: rootCluster.uri, + params: { + oneofKind: 'local', + local: { + user: 'user', + password: 'password', + }, + }, + }, + { + abort: expect.objectContaining({ canBePassedThroughContextBridge: true }), + } + ); + const foundCluster = appContext.clustersService.findCluster(rootCluster.uri); + expect(foundCluster).not.toBeUndefined(); + expect(foundCluster.connected).toBe(true); +}); diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.ts b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.ts index b19bbaa50648c..219e6a5b615cc 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.ts +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.ts @@ -18,54 +18,74 @@ import { useEffect, useRef, useState } from 'react'; +import { AuthProvider } from 'gen-proto-ts/teleport/lib/teleterm/v1/auth_settings_pb'; +import { + CredentialInfo, + PasswordlessPrompt, +} from 'gen-proto-ts/teleport/lib/teleterm/v1/service_pb'; import { useAsync } from 'shared/hooks/useAsync'; -import { cloneAbortSignal } from 'teleterm/services/tshd/cloneableClient'; +import { + CloneableAbortSignal, + cloneAbortSignal, +} from 'teleterm/services/tshd/cloneableClient'; import { useAppContext } from 'teleterm/ui/appContextProvider'; -import type * as types from 'teleterm/ui/services/clusters/types'; +import { useAppUpdaterContext } from 'teleterm/ui/AppUpdater'; +import { IAppContext } from 'teleterm/ui/types'; +import * as uri from 'teleterm/ui/uri'; import { RootClusterUri } from 'teleterm/ui/uri'; -import { assertUnreachable } from 'teleterm/ui/utils'; -export default function useClusterLogin(props: Props) { +export type SsoPrompt = + /** + * No prompt, SSO login not in use. + */ + | 'no-prompt' + /** + * The user is asked to follow the steps in the browser (the browser automatically opens). + */ + | 'follow-browser-steps' + /** + * After the user followed the steps in the browser and the login RPC returned but before the + * cluster sync RPC started. + */ + | 'wait-for-sync'; + +export function useClusterLogin(props: Props) { const { onSuccess, clusterUri } = props; - const { clustersService, tshd, configService, mainProcessClient } = - useAppContext(); + const ctx = useAppContext(); + const { clustersService, tshd, configService, mainProcessClient } = ctx; + const appUpdaterContext = useAppUpdaterContext(); const cluster = clustersService.findCluster(clusterUri); const refAbortCtrl = useRef(null); const loggedInUserName = props.prefill.username || cluster.loggedInUser?.name || null; - const [shouldPromptSsoStatus, promptSsoStatus] = useState(false); + const [ssoPrompt, setSsoPrompt] = useState('no-prompt'); const [passwordlessLoginState, setPasswordlessLoginState] = useState(); - const [initAttempt, init] = useAsync(() => - tshd.getAuthSettings({ clusterUri }).then(({ response }) => response) - ); + const [initAttempt, init] = useAsync(() => { + return Promise.all([ + tshd.getAuthSettings({ clusterUri }).then(({ response }) => response), + // checkForAppUpdates doesn't return a rejected promise, errors are + // surfaced in app updates widget and details view. + mainProcessClient.checkForAppUpdates(), + ]).then(([authSettings]) => authSettings); + }); - const [loginAttempt, login, setAttempt] = useAsync( - (params: types.LoginParams) => { - refAbortCtrl.current = new AbortController(); - switch (params.kind) { - case 'local': - return clustersService.loginLocal( - params, - cloneAbortSignal(refAbortCtrl.current.signal) - ); - case 'passwordless': - return clustersService.loginPasswordless( - params, - cloneAbortSignal(refAbortCtrl.current.signal) - ); - case 'sso': - return clustersService.loginSso( - params, - cloneAbortSignal(refAbortCtrl.current.signal) - ); - default: - assertUnreachable(params); - } + const [loginAttempt, login, setAttempt] = useAsync((params: LoginParams) => { + refAbortCtrl.current = new AbortController(); + const signal = cloneAbortSignal(refAbortCtrl.current.signal); + switch (params.kind) { + case 'local': + return loginLocal(ctx, params, signal); + case 'passwordless': + return loginPasswordless(ctx, params, signal); + case 'sso': + return loginSso(ctx, params, setSsoPrompt, signal); + default: + params satisfies never; } - ); + }); const onLoginWithLocal = (username: string, password: string) => { login({ @@ -80,7 +100,7 @@ export default function useClusterLogin(props: Props) { login({ kind: 'passwordless', clusterUri, - onPromptCallback: (prompt: types.PasswordlessLoginPrompt) => { + onPromptCallback: (prompt: PasswordlessLoginPrompt) => { const newState: PasswordlessLoginState = { prompt: prompt.type, processing: false, @@ -116,8 +136,8 @@ export default function useClusterLogin(props: Props) { }); }; - const onLoginWithSso = (provider: types.AuthProvider) => { - promptSsoStatus(true); + const onLoginWithSso = (provider: AuthProvider) => { + setSsoPrompt('follow-browser-steps'); login({ kind: 'sso', clusterUri, @@ -148,7 +168,7 @@ export default function useClusterLogin(props: Props) { useEffect(() => { if (loginAttempt.status !== 'processing') { setPasswordlessLoginState(null); - promptSsoStatus(false); + setSsoPrompt('no-prompt'); } if (loginAttempt.status === 'success') { @@ -168,7 +188,7 @@ export default function useClusterLogin(props: Props) { const { platform } = mainProcessClient.getRuntimeSettings(); return { - shouldPromptSsoStatus, + ssoPrompt, passwordlessLoginState, title: cluster?.name, loggedInUserName, @@ -184,6 +204,17 @@ export default function useClusterLogin(props: Props) { shouldSkipVersionCheck, disableVersionCheck, platform, + appUpdateEvent: appUpdaterContext.updateEvent, + downloadAppUpdate: mainProcessClient.downloadAppUpdate, + cancelAppUpdateDownload: mainProcessClient.cancelAppUpdateDownload, + checkForAppUpdates: mainProcessClient.checkForAppUpdates, + quitAndInstallAppUpdate: mainProcessClient.quitAndInstallAppUpdate, + changeAppUpdatesManagingCluster: + mainProcessClient.changeAppUpdatesManagingCluster, + clusterGetter: { + findCluster: (clusterUri: RootClusterUri) => + clustersService.findCluster(clusterUri), + }, }; } @@ -200,8 +231,201 @@ export type PasswordlessLoginState = { /** * prompt describes the current step, or prompt, shown to the user during the passwordless login. */ - prompt: types.PasswordlessLoginPrompt['type']; + prompt: PasswordlessLoginPrompt['type']; processing?: boolean; loginUsernames?: string[]; onUserResponse?(val: number | string): void; }; + +/* + * Login functions + */ + +export type LoginParams = + | LoginLocalParams + | LoginPasswordlessParams + | LoginSsoParams; + +export interface LoginLocalParams { + kind: 'local'; + clusterUri: uri.RootClusterUri; + username: string; + password: string; + token?: string; +} + +async function loginLocal( + { tshd, clustersService, usageService }: IAppContext, + params: LoginLocalParams, + abortSignal: CloneableAbortSignal +) { + await tshd.login( + { + clusterUri: params.clusterUri, + params: { + oneofKind: 'local', + local: { + user: params.username, + password: params.password, + token: params.token, + }, + }, + }, + { abort: abortSignal } + ); + // We explicitly use the `andCatchErrors` variant here. If loginLocal succeeds but syncing the + // cluster fails, we don't want to stop the user on the failed modal – we want to open the + // workspace and show an error state within the workspace. + await clustersService.syncAndWatchRootClusterWithErrorHandling( + params.clusterUri + ); + usageService.captureUserLogin(params.clusterUri, 'local'); +} + +export interface LoginSsoParams { + kind: 'sso'; + clusterUri: uri.RootClusterUri; + providerType: string; + providerName: string; +} + +async function loginSso( + { tshd, clustersService, usageService, mainProcessClient }: IAppContext, + params: LoginSsoParams, + setSsoPrompt: (prompt: SsoPrompt) => void, + abortSignal: CloneableAbortSignal +) { + await tshd.login( + { + clusterUri: params.clusterUri, + params: { + oneofKind: 'sso', + sso: { + providerType: params.providerType, + providerName: params.providerName, + }, + }, + }, + { abort: abortSignal } + ); + setSsoPrompt('wait-for-sync'); + + // Force once login finishes but before we await the cluster sync. This way the focus will go back + // to the app ASAP. The login modal will be shown until the cluster sync finishes. + void mainProcessClient.forceFocusWindow(); + + await clustersService.syncAndWatchRootClusterWithErrorHandling( + params.clusterUri + ); + usageService.captureUserLogin(params.clusterUri, params.providerType); +} + +export interface LoginPasswordlessParams { + kind: 'passwordless'; + clusterUri: uri.RootClusterUri; + onPromptCallback(res: PasswordlessLoginPrompt): void; +} + +export type PasswordlessLoginPrompt = + | { type: 'tap' } + | { type: 'retap' } + | { type: 'pin'; onUserResponse(pin: string): void } + | { + type: 'credential'; + data: { credentials: CredentialInfo[] }; + onUserResponse(index: number): void; + }; + +async function loginPasswordless( + { tshd, clustersService, usageService }: IAppContext, + params: LoginPasswordlessParams, + abortSignal: CloneableAbortSignal +) { + await new Promise((resolve, reject) => { + const stream = tshd.loginPasswordless({ + abort: abortSignal, + }); + + let hasDeviceBeenTapped = false; + + // Init the stream. + stream.requests.send({ + request: { + oneofKind: 'init', + init: { + clusterUri: params.clusterUri, + }, + }, + }); + + stream.responses.onMessage(function (response) { + switch (response.prompt) { + case PasswordlessPrompt.PIN: + const pinResponse = (pin: string) => { + stream.requests.send({ + request: { + oneofKind: 'pin', + pin: { pin }, + }, + }); + }; + + params.onPromptCallback({ + type: 'pin', + onUserResponse: pinResponse, + }); + return; + + case PasswordlessPrompt.CREDENTIAL: + const credResponse = (index: number) => { + stream.requests.send({ + request: { + oneofKind: 'credential', + credential: { index: BigInt(index) }, + }, + }); + }; + + params.onPromptCallback({ + type: 'credential', + onUserResponse: credResponse, + data: { credentials: response.credentials || [] }, + }); + return; + + case PasswordlessPrompt.TAP: + if (hasDeviceBeenTapped) { + params.onPromptCallback({ type: 'retap' }); + } else { + hasDeviceBeenTapped = true; + params.onPromptCallback({ type: 'tap' }); + } + return; + + // Following cases should never happen but just in case? + case PasswordlessPrompt.UNSPECIFIED: + stream.requests.complete(); + return reject(new Error('no passwordless prompt was specified')); + + default: + stream.requests.complete(); + return reject( + new Error(`passwordless prompt '${response.prompt}' not supported`) + ); + } + }); + + stream.responses.onComplete(function () { + resolve(); + }); + + stream.responses.onError(function (err: Error) { + reject(err); + }); + }); + + await clustersService.syncAndWatchRootClusterWithErrorHandling( + params.clusterUri + ); + usageService.captureUserLogin(params.clusterUri, 'passwordless'); +} diff --git a/web/packages/teleterm/src/ui/ClusterLogout/useClusterLogout.ts b/web/packages/teleterm/src/ui/ClusterLogout/useClusterLogout.ts index 560cd93dc563b..90683da79f324 100644 --- a/web/packages/teleterm/src/ui/ClusterLogout/useClusterLogout.ts +++ b/web/packages/teleterm/src/ui/ClusterLogout/useClusterLogout.ts @@ -18,6 +18,7 @@ import { useAsync } from 'shared/hooks/useAsync'; +import { useLogger } from 'teleterm/ui/hooks/useLogger'; import { RootClusterUri } from 'teleterm/ui/uri'; import { useAppContext } from '../appContextProvider'; @@ -28,8 +29,15 @@ export function useClusterLogout({ clusterUri: RootClusterUri; }) { const ctx = useAppContext(); + const logger = useLogger('useClusterLogout'); const [{ status, statusText }, removeCluster] = useAsync(async () => { await ctx.clustersService.logout(clusterUri); + // This function checks for updates, do not wait for it. + ctx.mainProcessClient + .maybeRemoveAppUpdatesManagingCluster(clusterUri) + .catch(err => { + logger.error('Failed to remove managing cluster', err); + }); if (ctx.workspacesService.getRootClusterUri() === clusterUri) { const [firstConnectedWorkspace] = diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx index 77cc1037883a1..4e4f8fc50c4aa 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx @@ -28,6 +28,7 @@ import { ClusterLogout } from '../ClusterLogout'; import { ResourceSearchErrors } from '../Search/ResourceSearchErrors'; import { assertUnreachable } from '../utils'; import { ConfigureSSHClients } from '../Vnet/ConfigureSSHClients'; +import { AppUpdates } from './modals/AppUpdates'; import { ChangeAccessRequestKind } from './modals/ChangeAccessRequestKind'; import { AskPin, ChangePin, OverwriteSlot, Touch } from './modals/HardwareKeys'; import { ReAuthenticate } from './modals/ReAuthenticate'; @@ -293,6 +294,9 @@ function renderDialog({ /> ); } + case 'app-updates': { + return