diff --git a/webapp/apps/gateway-ui/package.json b/webapp/apps/gateway-ui/package.json index a6fc4f178..9364c5fd2 100644 --- a/webapp/apps/gateway-ui/package.json +++ b/webapp/apps/gateway-ui/package.json @@ -17,22 +17,22 @@ }, "private": true, "dependencies": { - "@angular/animations": "20.3.5", + "@angular/animations": "20.3.18", "@angular/cdk": "20.2.14", - "@angular/common": "20.3.5", - "@angular/compiler": "20.3.5", - "@angular/core": "20.3.5", - "@angular/forms": "20.3.5", - "@angular/platform-browser": "20.3.5", - "@angular/platform-browser-dynamic": "20.3.5", - "@angular/router": "20.3.5", + "@angular/common": "20.3.18", + "@angular/compiler": "20.3.18", + "@angular/core": "20.3.18", + "@angular/forms": "20.3.18", + "@angular/platform-browser": "20.3.18", + "@angular/platform-browser-dynamic": "20.3.18", + "@angular/router": "20.3.18", "@devolutions/icons": "^5.0.10", "@devolutions/iron-remote-desktop": "^0.10.1", "@devolutions/iron-remote-desktop-rdp": "^0.6.1", "@devolutions/iron-remote-desktop-vnc": "^0.7.0", - "@devolutions/terminal-shared": "^1.4.0", - "@devolutions/web-ssh-gui": "^0.6.2", - "@devolutions/web-telnet-gui": "^0.4.0", + "@devolutions/terminal-shared": "^1.4.1", + "@devolutions/web-ssh-gui": "^0.7.0", + "@devolutions/web-telnet-gui": "^0.4.4", "@xterm/addon-clipboard": "^0.1.0", "@xterm/xterm": "^5.5.0", "i18next": "^25.5.3", @@ -43,7 +43,7 @@ "primeflex": "4.0.0", "primeicons": "^7.0.0", "primeng": "20.3.0", - "prismjs": "1.29.0", + "prismjs": "1.30.0", "rxjs": "^7.8.1", "tslib": "2.6.1", "ua-parser-js": "^2.0.4", @@ -52,15 +52,15 @@ "zone.js": "0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "20.3.13", - "@angular-devkit/core": "20.3.5", - "@angular/cli": "20.3.5", - "@angular/compiler-cli": "20.3.5", + "@angular-devkit/build-angular": "20.3.21", + "@angular-devkit/core": "20.3.21", + "@angular/cli": "20.3.21", + "@angular/compiler-cli": "20.3.18", "@biomejs/biome": "2.1.3", "@types/node": "^20.19.0", "@types/uuid": "^9.0.8", - "typescript": "~5.8.2", - "undici": "^7.16.0", + "typescript": "~5.9.0", + "undici": "7.24.0", "undici-types": "^6.21.0" }, "optionalDependencies": { diff --git a/webapp/apps/gateway-ui/src/client/app/app.component.html b/webapp/apps/gateway-ui/src/client/app/app.component.html index c102cbed8..394579c76 100644 --- a/webapp/apps/gateway-ui/src/client/app/app.component.html +++ b/webapp/apps/gateway-ui/src/client/app/app.component.html @@ -1,8 +1,10 @@
-
- -
+ @if (!outlet.isActivated) { +
+ +
+ }
diff --git a/webapp/apps/gateway-ui/src/client/app/modules/base/menu/app-menu.component.html b/webapp/apps/gateway-ui/src/client/app/modules/base/menu/app-menu.component.html index b68e159a1..83b2ed25d 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/base/menu/app-menu.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/base/menu/app-menu.component.html @@ -12,10 +12,12 @@ -
- Devolutions - Gateway -
+ @if (!isMenuSlim) { +
+ Devolutions + Gateway +
+ } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/base/menu/menu-list-active-sessions/menu-list-active-sessions.component.html b/webapp/apps/gateway-ui/src/client/app/modules/base/menu/menu-list-active-sessions/menu-list-active-sessions.component.html index e7950304d..7abe09954 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/base/menu/menu-list-active-sessions/menu-list-active-sessions.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/base/menu/menu-list-active-sessions/menu-list-active-sessions.component.html @@ -1,14 +1,15 @@ - +@for (activeWebSession of activeWebSessions; track activeWebSession.id) { - +} diff --git a/webapp/apps/gateway-ui/src/client/app/modules/base/menu/menu-list-item/menu-list-item.component.html b/webapp/apps/gateway-ui/src/client/app/modules/base/menu/menu-list-item/menu-list-item.component.html index dcd24b300..3827af878 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/base/menu/menu-list-item/menu-list-item.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/base/menu/menu-list-item/menu-list-item.component.html @@ -1,4 +1,6 @@ - +@if (icon) { + +} diff --git a/webapp/apps/gateway-ui/src/client/app/modules/login/login.component.html b/webapp/apps/gateway-ui/src/client/app/modules/login/login.component.html index 0a4c89561..5ac932fc0 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/login/login.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/login/login.component.html @@ -1,8 +1,11 @@ -
- -
+@if (!autoLoginAttempted) { +
+ +
+} -
+@if (autoLoginAttempted) { +
+} diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.html index 33eb213d9..925297d66 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.html @@ -1,12 +1,4 @@ -
-
- -
- +
-
-
- + @if (loading) { +
+
+ @if (!hideSpinnerOnly) { + + } +
-
+ } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.scss b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.scss index 949b3119f..c8368b31f 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.scss +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.scss @@ -1,11 +1,27 @@ :host { background: #F9F9F9 url('../../../../../assets/images/session_background.png'); display: flex; - height: 100%; + flex: 1; + min-height: 0; width: 100%; flex-direction: column; } +.session-ard-container { + position: relative; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; + + iron-remote-desktop { + flex: 1; + min-height: 0; + overflow: hidden; + } +} + :host ::ng-deep .separator { border: solid 1px var(--border-secondary-color); width: 0; @@ -17,70 +33,27 @@ display: flex; justify-content: center; align-items: center; - height: 100vh; + position: absolute; + inset: 0; + z-index: 1; + min-width: 0; + min-height: 0; } .loading-info { display: grid; place-items: center; - height: 100vh; - width: 100vw; -} - -.session-toolbar { - display: flex; + height: 100%; width: 100%; - height: 44px; - padding: 8px; - justify-content: space-between; - align-items: center; - background: var(--bg-secondary-color); + min-width: 0; + min-height: 0; } -.session-toolbar-middle-group, .session-toolbar-left-group, .session-toolbar-right-group { - display: flex; - align-items: center; -} -.session-toolbar ::ng-deep .p-button { - border: none; - border-radius: 4px; - background-color: transparent; +.toolbar-overlay-anchor { + position: absolute; + inset: 0; + z-index: 20; + pointer-events: none; // anchor is transparent — the toolbar children opt-in via pointer-events: auto } -.session-toolbar ::ng-deep .p-button:hover { - background: #0068C31A; -} - -.session-toolbar ::ng-deep .p-button .p-button-label { - color: #000000CC; - font-family: Open Sans; - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: normal; -} - -.session-toolbar ::ng-deep .p-button .p-button-icon-left { - display: flex; - align-items: center; - color: #0068C3; - margin-right: 8px; - height: 16px; - width: 16px; -} - -.session-toolbar-layer { - position: fixed; - top: 0; - left: 0; - width: 100%; - z-index: 1000; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.session-form { - display: flex; - flex-direction: column; - height: 100%; -} diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.ts index a2d4ebaa0..602677d61 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.ts +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.ts @@ -1,69 +1,36 @@ -import { - AfterViewInit, - Component, - ElementRef, - EventEmitter, - HostListener, - Input, - OnDestroy, - OnInit, - Output, - Renderer2, - ViewChild, -} from '@angular/core'; -import { IronError, SessionTerminationInfo, UserInteraction } from '@devolutions/iron-remote-desktop'; -import { WebClientBaseComponent } from '@shared/bases/base-web-client.component'; +import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, Renderer2, ViewChild } from '@angular/core'; +import { DesktopWebClientBaseComponent } from '@shared/bases/desktop-web-client-base.component'; import { GatewayAlertMessageService } from '@shared/components/gateway-alert-message/gateway-alert-message.service'; import { ScreenScale } from '@shared/enums/screen-scale.enum'; import { IronARDConnectionParameters } from '@shared/interfaces/connection-params.interfaces'; import { ArdFormDataInput } from '@shared/interfaces/forms.interfaces'; -import { ComponentStatus } from '@shared/models/component-status.model'; import { UtilsService } from '@shared/services/utils.service'; import { DefaultArdPort, WebClientService } from '@shared/services/web-client.service'; import { WebSessionService } from '@shared/services/web-session.service'; -import type { ToastMessageOptions } from 'primeng/api'; import { MessageService } from 'primeng/api'; -import { EMPTY, from, Observable, of, Subject } from 'rxjs'; -import { catchError, map, switchMap, takeUntil } from 'rxjs/operators'; +import { EMPTY, from, Observable, of } from 'rxjs'; +import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators'; import '@devolutions/iron-remote-desktop/iron-remote-desktop.js'; import { ardQualityMode, Backend, resolutionQuality, wheelSpeedFactor } from '@devolutions/iron-remote-desktop-vnc'; -import { DVL_ARD_ICON, DVL_WARNING_ICON, JET_ARD_URL } from '@gateway/app.constants'; +import { DVL_ARD_ICON, JET_ARD_URL } from '@gateway/app.constants'; import { AnalyticService, ProtocolString } from '@gateway/shared/services/analytic.service'; import { ExtractedHostnamePort } from '@shared/services/utils/string.service'; import { v4 as uuidv4 } from 'uuid'; -enum UserIronRdpErrorKind { - General = 0, - WrongPassword = 1, - LogonFailure = 2, - AccessDenied = 3, - RDCleanPath = 4, - ProxyConnect = 5, -} - @Component({ standalone: false, templateUrl: 'web-client-ard.component.html', styleUrls: ['web-client-ard.component.scss'], providers: [MessageService], }) -export class WebClientArdComponent extends WebClientBaseComponent implements OnInit, AfterViewInit, OnDestroy { - @Input() webSessionId: string; - @Output() componentStatus: EventEmitter = new EventEmitter(); - @Output() sizeChange: EventEmitter = new EventEmitter(); - +export class WebClientArdComponent + extends DesktopWebClientBaseComponent + implements OnInit, AfterViewInit, OnDestroy +{ @ViewChild('sessionArdContainer') sessionContainerElement: ElementRef; - @ViewChild('ironRemoteDesktopElement') ironRemoteDesktopElement: ElementRef; backendRef = Backend; - formData: ArdFormDataInput; - sessionTerminationMessage: ToastMessageOptions; - isFullScreenMode = false; - cursorOverrideActive = false; - - saveRemoteClipboardButtonEnabled = false; - middleToolbarButtons = [ { label: 'Full Screen', @@ -110,201 +77,28 @@ export class WebClientArdComponent extends WebClientBaseComponent implements OnI }, ]; - clipboardActionButtons: { - label: string; - tooltip: string; - icon: string; - action: () => Promise; - enabled: () => boolean; - }[] = []; - - private setupClipboardHandling(): void { - // Clipboard API is available only in secure contexts (HTTPS). - if (!window.isSecureContext) { - return; - } - - if (this.formData.autoClipboard === true) { - return; - } - - // We don't check for clipboard write support, as all recent browser versions support it. - this.clipboardActionButtons.push({ - label: 'Save Clipboard', - tooltip: 'Copy received clipboard content to your local clipboard.', - icon: 'dvl-icon dvl-icon-save', - action: () => this.saveRemoteClipboard(), - enabled: () => this.saveRemoteClipboardButtonEnabled, - }); - - // Check if the browser supports reading local clipboard. - if (navigator.clipboard.readText) { - this.clipboardActionButtons.push({ - label: 'Send Clipboard', - tooltip: 'Send your local clipboard content to the remote server.', - icon: 'dvl-icon dvl-icon-send', - action: () => this.sendClipboard(), - enabled: () => true, - }); - } - } - - protected removeElement = new Subject(); - private remoteClientEventListener: (event: Event) => void; - private remoteClient: UserInteraction; - constructor( - private renderer: Renderer2, + protected renderer: Renderer2, protected utils: UtilsService, protected gatewayAlertMessageService: GatewayAlertMessageService, - private webSessionService: WebSessionService, + protected webSessionService: WebSessionService, private webClientService: WebClientService, protected analyticService: AnalyticService, ) { - super(gatewayAlertMessageService, analyticService); - } - - @HostListener('document:fullscreenchange') - onFullScreenChange(): void { - this.handleOnFullScreenEvent(); + super(renderer, webSessionService, gatewayAlertMessageService, analyticService); } ngOnInit(): void { - this.removeWebClientGuiElement(); - this.setupClipboardHandling(); - } - - ngAfterViewInit(): void { - this.initiateRemoteClientListener(); - } - - ngOnDestroy(): void { - this.removeRemoteClientListener(); - this.removeWebClientGuiElement(); - super.ngOnDestroy(); - } - - startTerminationProcess(): void { - this.sendTerminateSessionCmd(); - this.currentStatus.isDisabledByUser = true; - this.disableComponentStatus(); - } - - sendTerminateSessionCmd(): void { - if (!this.currentStatus.isInitialized) { - return; - } - this.currentStatus.isInitialized = false; - // shutdowns the session, not the server. Jan 2024 KAH. - this.remoteClient.shutdown(); - } - - scaleTo(scale: ScreenScale): void { - this.remoteClient.setScale(scale.valueOf()); - } - - setKeyboardUnicodeMode(useUnicode: boolean): void { - this.remoteClient.setKeyboardUnicodeMode(useUnicode); - } - - async saveRemoteClipboard(): Promise { - try { - await this.remoteClient.saveRemoteClipboardData(); - - super.webClientSuccess('Clipboard content has been copied to your clipboard!'); - this.saveRemoteClipboardButtonEnabled = false; - } catch (err) { - this.handleSessionError(err); - } - } - - async sendClipboard(): Promise { - try { - await this.remoteClient.sendClipboardData(); - - super.webClientSuccess('Clipboard content has been sent to the remote server!'); - } catch (err) { - this.handleSessionError(err); - } - } - - toggleCursorKind(): void { - if (this.cursorOverrideActive) { - this.remoteClient.setCursorStyleOverride(null); - } else { - this.remoteClient.setCursorStyleOverride('url("assets/images/crosshair.png") 7 7, default'); - } + this.webSessionIcon = DVL_ARD_ICON; - this.cursorOverrideActive = !this.cursorOverrideActive; + super.ngOnInit(); } setWheelSpeedFactor(factor: number): void { this.remoteClient.invokeExtension(wheelSpeedFactor(factor)); } - removeWebClientGuiElement(): void { - this.removeElement.pipe(takeUntil(this.destroyed$)).subscribe({ - next: (): void => { - if (this.ironRemoteDesktopElement?.nativeElement) { - this.ironRemoteDesktopElement.nativeElement.remove(); - } - }, - error: (err): void => { - console.error('Error while removing element:', err); - }, - }); - } - - private initializeStatus(): void { - this.currentStatus = { - id: this.webSessionId, - isInitialized: true, - isDisabled: false, - isDisabledByUser: false, - }; - } - - private disableComponentStatus(): void { - this.currentStatus.isDisabled = true; - this.componentStatus.emit(this.currentStatus); - } - - private handleOnFullScreenEvent(): void { - if (!document.fullscreenElement) { - this.handleExitFullScreenEvent(); - } - } - - private toggleFullscreen(): void { - this.isFullScreenMode = !this.isFullScreenMode; - !document.fullscreenElement ? this.enterFullScreen() : this.exitFullScreen(); - - this.scaleTo(ScreenScale.Full); - } - - private async enterFullScreen(): Promise { - if (document.fullscreenElement) { - return; - } - - try { - const sessionContainerElement = this.sessionContainerElement.nativeElement; - await sessionContainerElement.requestFullscreen(); - } catch (err) { - this.isFullScreenMode = false; - console.error(`Error attempting to enable fullscreen mode: ${err.message} (${err.name})`); - } - } - - private exitFullScreen(): void { - if (document.fullscreenElement) { - document.exitFullscreen().catch((err) => { - console.error(`Error attempting to exit fullscreen: ${err}`); - }); - } - } - - private handleExitFullScreenEvent(): void { + protected handleExitFullScreenEvent(): void { this.isFullScreenMode = false; const sessionContainerElement = this.sessionContainerElement.nativeElement; @@ -317,40 +111,11 @@ export class WebClientArdComponent extends WebClientBaseComponent implements OnI this.scaleTo(ScreenScale.Fit); } - private initiateRemoteClientListener(): void { - this.remoteClientEventListener = (event: Event) => this.readyRemoteClientEventListener(event); - this.renderer.listen(this.ironRemoteDesktopElement.nativeElement, 'ready', this.remoteClientEventListener); - } - - private removeRemoteClientListener(): void { - if (this.ironRemoteDesktopElement && this.remoteClientEventListener) { - this.renderer.destroy(); - } - } - - private readyRemoteClientEventListener(event: Event): void { - const customEvent = event as CustomEvent; - this.remoteClient = customEvent.detail.irgUserInteraction; - - if (this.formData.autoClipboard !== true) { - this.remoteClient.setEnableAutoClipboard(false); - } - - // Register callbacks for events. - this.remoteClient.onWarningCallback((data: string) => { - this.webClientWarning(data); - }); - this.remoteClient.onClipboardRemoteUpdateCallback(() => { - this.saveRemoteClipboardButtonEnabled = true; - }); - - this.startConnectionProcess(); - } - - private startConnectionProcess(): void { + protected startConnectionProcess(): void { this.getFormData() .pipe( takeUntil(this.destroyed$), + tap(() => this.setupClipboardHandling(this.formData.autoClipboard)), switchMap(() => this.fetchParameters(this.formData)), switchMap((params) => this.fetchTokens(params)), catchError((error) => { @@ -376,8 +141,7 @@ export class WebClientArdComponent extends WebClientBaseComponent implements OnI const extractedData: ExtractedHostnamePort = this.utils.string.extractHostnameAndPort(hostname, DefaultArdPort); const sessionId: string = uuidv4(); - const gatewayHttpAddress: URL = new URL(JET_ARD_URL + `/${sessionId}`, window.location.href); - const gatewayAddress: string = gatewayHttpAddress.toString().replace('http', 'ws'); + const gatewayAddress = this.getGatewayWebSocketUrl(JET_ARD_URL, sessionId); const connectionParameters: IronARDConnectionParameters = { username, @@ -393,11 +157,11 @@ export class WebClientArdComponent extends WebClientBaseComponent implements OnI return of(connectionParameters); } - fetchTokens(params: IronARDConnectionParameters): Observable { + private fetchTokens(params: IronARDConnectionParameters): Observable { return this.webClientService.fetchArdToken(params); } - private callConnect(connectionParameters: IronARDConnectionParameters): void { + protected callConnect(connectionParameters: IronARDConnectionParameters): void { const configBuilder = this.remoteClient .configBuilder() .withUsername(connectionParameters.username) @@ -432,99 +196,6 @@ export class WebClientArdComponent extends WebClientBaseComponent implements OnI }); } - private handleSessionStarted(): void { - this.loading = false; - this.remoteClient.setVisibility(true); - void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_ARD_ICON); - this.webClientConnectionSuccess(); - this.initializeStatus(); - } - - private handleSessionTerminatedGracefully(sessionTerminationInfo: SessionTerminationInfo): void { - this.sessionTerminationMessage = { - summary: 'Session terminated gracefully', - detail: sessionTerminationInfo.reason(), - severity: 'success', - }; - - this.handleSessionTerminated(); - } - - private handleSessionTerminatedWithError(error: unknown): void { - if (this.isIronError(error)) { - this.sessionTerminationMessage = { - summary: this.getIronErrorMessageTitle(error), - detail: error.backtrace(), - severity: 'error', - }; - } else { - this.sessionTerminationMessage = { - summary: 'Unexpected error occurred', - detail: `${error}`, - severity: 'error', - }; - } - - void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_WARNING_ICON); - - this.handleSessionTerminated(); - } - - private handleSessionTerminated(): void { - if (document.fullscreenElement) { - this.exitFullScreen(); - } - - this.disableComponentStatus(); - super.webClientConnectionClosed(); - } - - private handleSessionError(err: unknown): void { - if (this.isIronError(err)) { - this.webClientError(err.backtrace()); - } else { - this.webClientError(`${err}`); - } - } - - private isIronError(error: unknown): error is IronError { - return ( - typeof error === 'object' && - error !== null && - typeof (error as IronError).backtrace === 'function' && - typeof (error as IronError).kind === 'function' - ); - } - - private handleError(error: string): void { - this.sessionTerminationMessage = { - summary: 'Unexpected error occurred', - detail: error, - severity: 'error', - }; - this.disableComponentStatus(); - } - - private getIronErrorMessageTitle(error: IronError): string { - const errorKind: UserIronRdpErrorKind = error.kind().valueOf(); - - //For translation 'UnknownError' - //For translation 'ConnectionErrorPleaseVerifyYourConnectionSettings' - //For translation 'AccessDenied' - //For translation 'ConnectionErrorPleaseVerifyYourConnectionSettings' - switch (errorKind) { - case UserIronRdpErrorKind.General: - return 'Unknown Error'; - case UserIronRdpErrorKind.WrongPassword: - case UserIronRdpErrorKind.LogonFailure: - return 'Connection error: Please verify your connection settings.'; - case UserIronRdpErrorKind.AccessDenied: - return 'Access denied'; - default: - return 'Connection error: Please verify your connection settings.'; - } - } - protected getProtocol(): ProtocolString { return 'ARD'; } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/ard/ard-form.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/ard/ard-form.component.html index c2e28b35f..aac8eeaf0 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/ard/ard-form.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/ard/ard-form.component.html @@ -11,8 +11,12 @@ @@ -28,10 +32,12 @@ [inputFormData]="inputFormData">
-
- -
+ @if (showAutoClipboardCheckbox) { +
+ +
+ }
diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/rdp/rdp-form.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/rdp/rdp-form.component.html index 8c6bbe9a3..b093d3032 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/rdp/rdp-form.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/rdp/rdp-form.component.html @@ -11,8 +11,12 @@
More Settings - + - - + @if (!isMoreSettingsOpened()) { + + + } + @if (isMoreSettingsOpened()) { + - + }
@@ -36,10 +40,12 @@ [inputFormData]="inputFormData">
-
- -
+ @if (showAutoClipboardCheckbox) { +
+ +
+ } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/vnc/vnc-form.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/vnc/vnc-form.component.html index bc4c15719..4a36b9853 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/vnc/vnc-form.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-components/vnc/vnc-form.component.html @@ -30,9 +30,13 @@
More Settings - + - - + >More Settings + @if (!showMoreSettings) { + + + } + @if (showMoreSettings) { + - + }
@@ -76,21 +80,24 @@ > -
- -
+ @if (showExtendedClipboardCheckbox) { +
+ +
+ } -
- -
+ @if (showAutoClipboardCheckbox) { +
+ +
+ } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/file-control/file-control.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/file-control/file-control.component.html index ff736d957..68b5562f4 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/file-control/file-control.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/file-control/file-control.component.html @@ -38,10 +38,9 @@
-
- {{ getErrorMessage() }} -
+ @if (privateKeyContent !== '' && !isValidFile()) { +
+ {{ getErrorMessage() }} +
+ }
diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/kdc-url-control/kdc-url-control.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/kdc-url-control/kdc-url-control.component.html index 564655336..12f57ae20 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/kdc-url-control/kdc-url-control.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/kdc-url-control/kdc-url-control.component.html @@ -8,8 +8,9 @@ placeholder="Enter KDC Url" formControlName="kdcUrl"/> -
- Invalid URL - Must start with tcp:// or udp:// . -
+ @if (parentForm.get('kdcUrl').hasError('invalidKdcProtocol') && parentForm.get('kdcUrl').touched) { +
+ Invalid URL - Must start with tcp:// or udp:// . +
+ } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/password-control/password-control.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/password-control/password-control.component.html index 67354c61f..bc8fe7834 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/password-control/password-control.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/password-control/password-control.component.html @@ -13,8 +13,9 @@ -
- {{label}} is required -
+ @if (parentForm.get(formKey).hasError('required') && parentForm.get(formKey).touched) { +
+ {{label}} is required +
+ } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/screen-size-control/screen-size-control.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/screen-size-control/screen-size-control.component.html index c820e2b41..4275e922d 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/screen-size-control/screen-size-control.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/screen-size-control/screen-size-control.component.html @@ -11,44 +11,50 @@
-
+ @if (showCustomSize) { +
- -
- + +
+ +
+ @if (parentForm.get('customWidth').hasError('max') && parentForm.get('customWidth').touched) { +
+ Maximum value is 4096 +
+ }
-
- Maximum value is 4096 -
-
+ } -
- -
- + @if (showCustomSize) { +
+ +
+ +
+ @if (parentForm.get('customHeight').hasError('max') && parentForm.get('customHeight').touched) { +
+ Maximum value is 2048 +
+ }
-
- Maximum value is 2048 -
-
+ }
diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/username-control/username-control.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/username-control/username-control.component.html index 9e62a8771..46cca7691 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/username-control/username-control.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/form-controls/username-control/username-control.component.html @@ -9,8 +9,9 @@ formControlName="username" required/>
-
- Username is required. -
+ @if (parentForm.get('username').hasError('required') && parentForm.get('username').touched) { +
+ Username is required. +
+ } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/web-client-form.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/web-client-form.component.html index 5b8c19ef1..4445b572a 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/web-client-form.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/form/web-client-form.component.html @@ -12,12 +12,14 @@ Remote Session Information
- + @for (msg of messages; track $index) { + + {{ msg.summary }} + @if (msg.detail) { +
+ } +
+ }
@@ -64,46 +66,46 @@ >
-
- Hostname is required. -
+ @if (connectSessionForm.get('autoComplete').hasError('required') && connectSessionForm.get('autoComplete').touched) { +
+ Hostname is required. +
+ }
- + @if (isSelectedProtocolRdp()) { + + } - + @if (isSelectedProtocolSsh()) { + + } - + @if (isSelectedProtocolVnc()) { + + } - + @if (isSelectedProtocolArd()) { + + }
{ this.addMessages([this.sessionTerminationMessage]); diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/net-scan/net-scan.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/net-scan/net-scan.component.html index f7bca5a41..64290e956 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/net-scan/net-scan.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/net-scan/net-scan.component.html @@ -2,45 +2,56 @@
Servers found ({{services.length}}) -
- - - -
- -
- - - -
-
- -
- - - diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/net-scan/net-scan.component.scss b/webapp/apps/gateway-ui/src/client/app/modules/web-client/net-scan/net-scan.component.scss index 53563e49e..341989f48 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/net-scan/net-scan.component.scss +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/net-scan/net-scan.component.scss @@ -3,6 +3,7 @@ @use '../../../../../assets/css/theme/theme-mode-variables' as *; :host { + display: flex; --box-background-color: white; --box-text-color: rgb(var(--base-color-rgb)); } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.html index dec762c25..7967b54ac 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.html @@ -1,12 +1,4 @@ -
-
- -
- +
-
-
- + @if (loading) { +
+
+ @if (!hideSpinnerOnly) { + + } +
-
+ } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.scss b/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.scss index 949b3119f..c19b29c32 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.scss +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.scss @@ -1,86 +1,56 @@ :host { background: #F9F9F9 url('../../../../../assets/images/session_background.png'); display: flex; - height: 100%; + flex: 1; + min-height: 0; width: 100%; flex-direction: column; } -:host ::ng-deep .separator { - border: solid 1px var(--border-secondary-color); - width: 0; - height: 14px; - margin: 0 8px; +// Wraps iron-remote-desktop and the floating toolbar overlay. +// position:relative is required so the toolbar's position:absolute / inset:0 +// is contained within the session area rather than the viewport. +.session-rdp-container { + position: relative; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; + + iron-remote-desktop { + flex: 1; + min-height: 0; + overflow: hidden; + } } .loading-info-container { display: flex; justify-content: center; align-items: center; - height: 100vh; + position: absolute; + inset: 0; + z-index: 1; + min-width: 0; + min-height: 0; } .loading-info { display: grid; place-items: center; - height: 100vh; - width: 100vw; -} - -.session-toolbar { - display: flex; + height: 100%; width: 100%; - height: 44px; - padding: 8px; - justify-content: space-between; - align-items: center; - background: var(--bg-secondary-color); -} - -.session-toolbar-middle-group, .session-toolbar-left-group, .session-toolbar-right-group { - display: flex; - align-items: center; + min-width: 0; + min-height: 0; } -.session-toolbar ::ng-deep .p-button { - border: none; - border-radius: 4px; - background-color: transparent; -} -.session-toolbar ::ng-deep .p-button:hover { - background: #0068C31A; -} -.session-toolbar ::ng-deep .p-button .p-button-label { - color: #000000CC; - font-family: Open Sans; - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: normal; +.toolbar-overlay-anchor { + position: absolute; + inset: 0; + z-index: 20; + pointer-events: none; // anchor is transparent — the toolbar children opt-in via pointer-events: auto } -.session-toolbar ::ng-deep .p-button .p-button-icon-left { - display: flex; - align-items: center; - color: #0068C3; - margin-right: 8px; - height: 16px; - width: 16px; -} - -.session-toolbar-layer { - position: fixed; - top: 0; - left: 0; - width: 100%; - z-index: 1000; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.session-form { - display: flex; - flex-direction: column; - height: 100%; -} diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.ts index d670e824e..a1046cc1e 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.ts +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/rdp/web-client-rdp.component.ts @@ -1,51 +1,27 @@ -import { - AfterViewInit, - Component, - ElementRef, - EventEmitter, - HostListener, - Input, - OnDestroy, - OnInit, - Output, - Renderer2, - ViewChild, -} from '@angular/core'; -import { IronError, SessionTerminationInfo, UserInteraction } from '@devolutions/iron-remote-desktop'; +import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, Renderer2, ViewChild } from '@angular/core'; import { Backend, displayControl, kdcProxyUrl, preConnectionBlob, RdpFile } from '@devolutions/iron-remote-desktop-rdp'; -import { WebClientBaseComponent } from '@shared/bases/base-web-client.component'; +import { DesktopWebClientBaseComponent } from '@shared/bases/desktop-web-client-base.component'; import { GatewayAlertMessageService } from '@shared/components/gateway-alert-message/gateway-alert-message.service'; import { ScreenScale } from '@shared/enums/screen-scale.enum'; import { ScreenSize } from '@shared/enums/screen-size.enum'; import { IronRDPConnectionParameters } from '@shared/interfaces/connection-params.interfaces'; import { RdpFormDataInput } from '@shared/interfaces/forms.interfaces'; -import { ComponentStatus } from '@shared/models/component-status.model'; import { DesktopSize } from '@shared/models/desktop-size'; import { ExtractedUsernameDomain } from '@shared/services/utils/string.service'; import { UtilsService } from '@shared/services/utils.service'; import { WebClientService } from '@shared/services/web-client.service'; import { WebSessionService } from '@shared/services/web-session.service'; -import type { ToastMessageOptions } from 'primeng/api'; import { MessageService } from 'primeng/api'; -import { debounceTime, EMPTY, from, noop, Observable, of, Subject, Subscription, throwError } from 'rxjs'; -import { catchError, map, switchMap, takeUntil } from 'rxjs/operators'; +import { debounceTime, EMPTY, from, noop, Observable, of, Subscription, throwError } from 'rxjs'; +import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators'; import '@devolutions/iron-remote-desktop/iron-remote-desktop.js'; import { ActivatedRoute } from '@angular/router'; -import { DVL_RDP_ICON, DVL_WARNING_ICON, JET_RDP_URL } from '@gateway/app.constants'; +import { SessionTerminationInfo } from '@devolutions/iron-remote-desktop'; +import { DVL_RDP_ICON, JET_RDP_URL } from '@gateway/app.constants'; import { AnalyticService, ProtocolString } from '@gateway/shared/services/analytic.service'; import { WebSession } from '@shared/models/web-session.model'; import { ComponentResizeObserverService } from '@shared/services/component-resize-observer.service'; import { NavigationService } from '@shared/services/navigation.service'; -import { UAParser } from 'ua-parser-js'; - -enum UserIronRdpErrorKind { - General = 0, - WrongPassword = 1, - LogonFailure = 2, - AccessDenied = 3, - RDCleanPath = 4, - ProxyConnect = 5, -} @Component({ standalone: false, @@ -53,28 +29,18 @@ enum UserIronRdpErrorKind { styleUrls: ['web-client-rdp.component.scss'], providers: [MessageService], }) -export class WebClientRdpComponent extends WebClientBaseComponent implements OnInit, AfterViewInit, OnDestroy { - @Input() webSessionId: string; - @Input() sessionsContainerElement: ElementRef; - @Output() componentStatus: EventEmitter = new EventEmitter(); - @Output() sizeChange: EventEmitter = new EventEmitter(); - +export class WebClientRdpComponent + extends DesktopWebClientBaseComponent + implements OnInit, AfterViewInit, OnDestroy +{ @ViewChild('sessionRdpContainer') sessionContainerElement: ElementRef; - @ViewChild('ironRemoteDesktopElement') ironRemoteDesktopElement: ElementRef; backendRef = Backend; - - formData: RdpFormDataInput; - sessionTerminationMessage: ToastMessageOptions; - isFullScreenMode = false; useUnicodeKeyboard = false; - cursorOverrideActive = false; dynamicResizeSupported = false; dynamicResizeEnabled = false; - saveRemoteClipboardButtonEnabled = false; - rdpConfig: string | null; leftToolbarButtons = [ @@ -145,97 +111,35 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI }, ]; - clipboardActionButtons: { - label: string; - tooltip: string; - icon: string; - action: () => Promise; - enabled: () => boolean; - }[] = []; - - private setupClipboardHandling(): void { - // Clipboard API is available only in secure contexts (HTTPS). - if (!window.isSecureContext) { - return; - } - - let autoClipboardMode: boolean; - - // If the user connects to the session via URL. - if (this.formData === undefined) { - autoClipboardMode = new UAParser().getEngine().name === 'Blink'; - } else autoClipboardMode = this.formData.autoClipboard; - - if (autoClipboardMode) { - return; - } - - // We don't check for clipboard write support, as all recent browser versions support it. - this.clipboardActionButtons.push({ - label: 'Save Clipboard', - tooltip: 'Copy received clipboard content to your local clipboard.', - icon: 'dvl-icon dvl-icon-save', - action: () => this.saveRemoteClipboard(), - enabled: () => this.saveRemoteClipboardButtonEnabled, - }); - - // Check if the browser supports reading local clipboard. - if (navigator.clipboard.readText) { - this.clipboardActionButtons.push({ - label: 'Send Clipboard', - tooltip: 'Send your local clipboard content to the remote server.', - icon: 'dvl-icon dvl-icon-send', - action: () => this.sendClipboard(), - enabled: () => true, - }); - } - } - - protected removeElement = new Subject(); - private remoteClientEventListener: (event: Event) => void; - private remoteClient: UserInteraction; - private componentResizeObserverDisconnect?: () => void; private dynamicComponentResizeSubscription?: Subscription; constructor( - private renderer: Renderer2, + protected renderer: Renderer2, protected utils: UtilsService, private activatedRoute: ActivatedRoute, private navigation: NavigationService, protected gatewayAlertMessageService: GatewayAlertMessageService, - private webSessionService: WebSessionService, + protected webSessionService: WebSessionService, private webClientService: WebClientService, private componentResizeService: ComponentResizeObserverService, protected analyticService: AnalyticService, ) { - super(gatewayAlertMessageService, analyticService); - } - - @HostListener('document:fullscreenchange') - onFullScreenChange(): void { - this.handleOnFullScreenEvent(); + super(renderer, webSessionService, gatewayAlertMessageService, analyticService); } ngOnInit(): void { - this.removeWebClientGuiElement(); - this.setupClipboardHandling(); + this.webSessionIcon = DVL_RDP_ICON; this.setRdpConfig(); // Navigate to /session route to clear query params. this.navigation.navigateToNewSession().then(noop); - } - ngAfterViewInit(): void { - this.initiateRemoteClientListener(); + super.ngOnInit(); } ngOnDestroy(): void { - this.removeRemoteClientListener(); - this.removeWebClientGuiElement(); - this.dynamicComponentResizeSubscription?.unsubscribe(); this.componentResizeObserverDisconnect?.(); - super.ngOnDestroy(); } @@ -252,60 +156,6 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI this.remoteClient.ctrlAltDel(); } - startTerminationProcess(): void { - this.sendTerminateSessionCmd(); - this.currentStatus.isDisabledByUser = true; - this.disableComponentStatus(); - } - - sendTerminateSessionCmd(): void { - if (!this.currentStatus.isInitialized) { - return; - } - this.currentStatus.isInitialized = false; - // shutdowns the session, not the server. Jan 2024 KAH. - this.remoteClient.shutdown(); - } - - scaleTo(scale: ScreenScale): void { - this.remoteClient.setScale(scale.valueOf()); - } - - setKeyboardUnicodeMode(useUnicode: boolean): void { - this.remoteClient.setKeyboardUnicodeMode(useUnicode); - } - - async saveRemoteClipboard(): Promise { - try { - await this.remoteClient.saveRemoteClipboardData(); - - super.webClientSuccess('Clipboard content has been copied to your clipboard!'); - this.saveRemoteClipboardButtonEnabled = false; - } catch (err) { - this.handleSessionError(err); - } - } - - async sendClipboard(): Promise { - try { - await this.remoteClient.sendClipboardData(); - - super.webClientSuccess('Clipboard content has been sent to the remote server!'); - } catch (err) { - this.handleSessionError(err); - } - } - - toggleCursorKind(): void { - if (this.cursorOverrideActive) { - this.remoteClient.setCursorStyleOverride(null); - } else { - this.remoteClient.setCursorStyleOverride('url("assets/images/crosshair.png") 7 7, default'); - } - - this.cursorOverrideActive = !this.cursorOverrideActive; - } - toggleDynamicResize(): void { const RESIZE_DEBOUNCE_TIME = 100; @@ -330,68 +180,7 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI } } - removeWebClientGuiElement(): void { - this.removeElement.pipe(takeUntil(this.destroyed$)).subscribe({ - next: (): void => { - if (this.ironRemoteDesktopElement?.nativeElement) { - this.ironRemoteDesktopElement.nativeElement.remove(); - } - }, - error: (err): void => { - console.error('Error while removing element:', err); - }, - }); - } - - private initializeStatus(): void { - this.currentStatus = { - id: this.webSessionId, - isInitialized: true, - isDisabled: false, - isDisabledByUser: false, - }; - } - - private disableComponentStatus(): void { - this.currentStatus.isDisabled = true; - this.componentStatus.emit(this.currentStatus); - } - - private handleOnFullScreenEvent(): void { - if (!document.fullscreenElement) { - this.handleExitFullScreenEvent(); - } - } - - private toggleFullscreen(): void { - this.isFullScreenMode = !this.isFullScreenMode; - !document.fullscreenElement ? this.enterFullScreen() : this.exitFullScreen(); - - this.scaleTo(ScreenScale.Full); - } - - private async enterFullScreen(): Promise { - if (document.fullscreenElement) { - return; - } - - try { - await this.sessionsContainerElement.nativeElement.requestFullscreen(); - } catch (err) { - this.isFullScreenMode = false; - console.error(`Error attempting to enable fullscreen mode: ${err.message} (${err.name})`); - } - } - - private exitFullScreen(): void { - if (document.fullscreenElement) { - document.exitFullscreen().catch((err) => { - console.error(`Error attempting to exit fullscreen: ${err}`); - }); - } - } - - private handleExitFullScreenEvent(): void { + protected handleExitFullScreenEvent(): void { this.isFullScreenMode = false; const sessionContainerElement = this.sessionContainerElement.nativeElement; @@ -404,48 +193,19 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI this.scaleTo(ScreenScale.Fit); } - private initiateRemoteClientListener(): void { - this.remoteClientEventListener = (event: Event) => this.readyRemoteClientEventListener(event); - this.renderer.listen(this.ironRemoteDesktopElement.nativeElement, 'ready', this.remoteClientEventListener); - } - - private removeRemoteClientListener(): void { - if (this.ironRemoteDesktopElement && this.remoteClientEventListener) { - this.renderer.destroy(); - } - } - - private readyRemoteClientEventListener(event: Event): void { - const customEvent = event as CustomEvent; - this.remoteClient = customEvent.detail.irgUserInteraction; - - // If the user connects to the session via URL. - if (this.formData === undefined) { - const autoClipboardMode = new UAParser().getEngine().name === 'Blink'; - this.remoteClient.setEnableAutoClipboard(autoClipboardMode); - } else if (this.formData.autoClipboard !== true) { - this.remoteClient.setEnableAutoClipboard(false); + protected startConnectionProcess(): void { + let parameters: Observable; + if (this.rdpConfig) { + this.setupClipboardHandling(); + parameters = this.parseRdpConfig(this.rdpConfig); + } else { + parameters = this.getFormData().pipe( + tap(() => this.setupClipboardHandling(this.formData.autoClipboard)), + switchMap(() => this.setScreenSizeScale(this.formData.screenSize)), + switchMap(() => this.fetchParameters(this.formData)), + ); } - // Register callbacks for events. - this.remoteClient.onWarningCallback((data: string) => { - this.webClientWarning(data); - }); - this.remoteClient.onClipboardRemoteUpdateCallback(() => { - this.saveRemoteClipboardButtonEnabled = true; - }); - - this.startConnectionProcess(); - } - - private startConnectionProcess(): void { - const parameters = this.rdpConfig - ? this.parseRdpConfig(this.rdpConfig) - : this.getFormData().pipe( - switchMap(() => this.setScreenSizeScale(this.formData.screenSize)), - switchMap(() => this.fetchParameters(this.formData)), - ); - parameters .pipe( takeUntil(this.destroyed$), @@ -473,6 +233,7 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI const { hostname, password, enableDisplayControl, preConnectionBlob, kdcUrl } = formData; const extractedData: ExtractedUsernameDomain = this.utils.string.extractDomain(this.formData.username); + const gatewayAddress = this.getGatewayWebSocketUrl(JET_RDP_URL); const desktopScreenSize: DesktopSize = this.webClientService.getDesktopSize(this.formData) ?? this.webSessionService.getWebSessionScreenSizeSnapshot(); @@ -482,7 +243,7 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI password, host: hostname, domain: extractedData.domain, - gatewayAddress: this.getWebSocketUrl(), + gatewayAddress: gatewayAddress, screenSize: desktopScreenSize, enableDisplayControl, preConnectionBlob, @@ -534,7 +295,7 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI return of(connectionParameters); } - fetchTokens(params: IronRDPConnectionParameters): Observable { + private fetchTokens(params: IronRDPConnectionParameters): Observable { return this.webClientService .fetchRdpToken(params) .pipe(switchMap((updatedParams) => this.webClientService.fetchKdcToken(updatedParams))); @@ -552,7 +313,7 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI return of(undefined); } - private callConnect(connectionParameters: IronRDPConnectionParameters): void { + protected callConnect(connectionParameters: IronRDPConnectionParameters): void { const configBuilder = this.remoteClient .configBuilder() .withUsername(connectionParameters.username) @@ -584,113 +345,31 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI const config = configBuilder.build(); - from(this.remoteClient.connect(config)) + // Guard against synchronous throws from the underlying WASM library before + // the promise is even returned (observed in some auth-failure edge cases). + let connectPromise: Promise; + try { + connectPromise = this.remoteClient.connect(config); + } catch (syncErr) { + this.handleSessionTerminatedWithError(syncErr); + return; + } + + from(connectPromise) .pipe( takeUntil(this.destroyed$), switchMap((newSessionInfo) => { this.handleSessionStarted(); - return from(newSessionInfo.run()); + return from((newSessionInfo as { run(): Promise }).run()); }), ) .subscribe({ - next: (sessionTerminationInfo) => this.handleSessionTerminatedGracefully(sessionTerminationInfo), + next: (sessionTerminationInfo) => + this.handleSessionTerminatedGracefully(sessionTerminationInfo as SessionTerminationInfo), error: (err) => this.handleSessionTerminatedWithError(err), }); } - private handleSessionStarted(): void { - this.loading = false; - this.remoteClient.setVisibility(true); - void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_RDP_ICON); - this.webClientConnectionSuccess(); - this.initializeStatus(); - } - - private handleSessionTerminatedGracefully(sessionTerminationInfo: SessionTerminationInfo): void { - this.sessionTerminationMessage = { - summary: 'Session terminated gracefully', - detail: sessionTerminationInfo.reason(), - severity: 'success', - }; - - this.handleSessionTerminated(); - } - - private handleSessionTerminatedWithError(error: unknown): void { - if (this.isIronError(error)) { - this.sessionTerminationMessage = { - summary: this.getIronErrorMessageTitle(error), - detail: error.backtrace(), - severity: 'error', - }; - } else { - this.sessionTerminationMessage = { - summary: 'Unexpected error occurred', - detail: `${error}`, - severity: 'error', - }; - } - - void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_WARNING_ICON); - - this.handleSessionTerminated(); - } - - private handleSessionTerminated(): void { - if (document.fullscreenElement) { - this.exitFullScreen(); - } - - this.disableComponentStatus(); - super.webClientConnectionClosed(); - } - - private handleSessionError(err: unknown): void { - if (this.isIronError(err)) { - this.webClientError(err.backtrace()); - } else { - this.webClientError(`${err}`); - } - } - - private isIronError(error: unknown): error is IronError { - return ( - typeof error === 'object' && - error !== null && - typeof (error as IronError).backtrace === 'function' && - typeof (error as IronError).kind === 'function' - ); - } - - private handleError(error: string): void { - this.sessionTerminationMessage = { - summary: 'Unexpected error occurred', - detail: error, - severity: 'error', - }; - this.disableComponentStatus(); - } - - private getIronErrorMessageTitle(error: IronError): string { - const errorKind: UserIronRdpErrorKind = error.kind().valueOf(); - - //For translation 'UnknownError' - //For translation 'ConnectionErrorPleaseVerifyYourConnectionSettings' - //For translation 'AccessDenied' - //For translation 'ConnectionErrorPleaseVerifyYourConnectionSettings' - switch (errorKind) { - case UserIronRdpErrorKind.General: - return 'Unknown Error'; - case UserIronRdpErrorKind.WrongPassword: - case UserIronRdpErrorKind.LogonFailure: - return 'Connection error: Please verify your connection settings.'; - case UserIronRdpErrorKind.AccessDenied: - return 'Access denied'; - default: - return 'Connection error: Please verify your connection settings.'; - } - } - protected getProtocol(): ProtocolString { return 'RDP'; } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.html index b549be352..bd57317ca 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.html @@ -1,22 +1,18 @@
-
- -
- -
-
- + @if (loading) { +
+
+ @if (!hideSpinnerOnly) { + + } +
-
+ }
diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.scss b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.scss index da3a51189..7b93b852e 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.scss +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.scss @@ -6,14 +6,8 @@ background: #F9F9F9 url('../../../../../assets/images/session_background.png'); } -:host ::ng-deep .separator { - border: solid 1px var(--border-secondary-color); - width: 0; - height: 14px; - margin: 0 8px; -} - #web-ssh-terminal-app { + position: relative; flex-grow: 1; display: flex; flex-direction: column; @@ -29,7 +23,11 @@ display: flex; justify-content: center; align-items: center; - height: 100vh; + position: absolute; + inset: 0; + z-index: 1; + min-width: 0; + min-height: 0; } .loading-info { @@ -37,15 +35,22 @@ place-items: center; height: 100vh; width: 100vw; + min-width: 0; + min-height: 0; } web-ssh-gui { height: 100vh; width: 100vw; + min-width: 0; + min-height: 0; } -.session-form { - display: flex; - flex-direction: column; - height: 100%; + +.toolbar-overlay-anchor { + position: absolute; + inset: 0; + z-index: 20; + pointer-events: none; // anchor is transparent — the toolbar children opt-in via pointer-events: auto } + diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.ts index fd284adc9..a5ed7cafa 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.ts +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/ssh/web-client-ssh.component.ts @@ -1,19 +1,20 @@ -import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; +import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { LoggingLevel } from '@devolutions/terminal-shared'; import { SSHTerminal, loggingService as sshLoggingService, TerminalConnectionStatus } from '@devolutions/web-ssh-gui'; -import { DVL_SSH_ICON, DVL_WARNING_ICON, JET_SSH_URL } from '@gateway/app.constants'; +import { DVL_SSH_ICON, JET_SSH_URL } from '@gateway/app.constants'; import { AnalyticService, ProtocolString } from '@gateway/shared/services/analytic.service'; -import { WebClientBaseComponent, WebComponentReady } from '@shared/bases/base-web-client.component'; +import { WebComponentReady } from '@shared/bases/base-web-client.component'; +import { TerminalWebClientBaseComponent } from '@shared/bases/terminal-web-client-base.component'; import { GatewayAlertMessageService } from '@shared/components/gateway-alert-message/gateway-alert-message.service'; import { SshConnectionParameters } from '@shared/interfaces/connection-params.interfaces'; import { SSHFormDataInput } from '@shared/interfaces/forms.interfaces'; -import { ComponentStatus } from '@shared/models/component-status.model'; +import { CanSendTerminateSessionCmd } from '@shared/models/web-session.model'; import { ExtractedHostnamePort } from '@shared/services/utils/string.service'; import { UtilsService } from '@shared/services/utils.service'; import { DefaultSshPort, WebClientService } from '@shared/services/web-client.service'; import { WebSessionService } from '@shared/services/web-session.service'; import { MessageService } from 'primeng/api'; -import { EMPTY, from, Observable, of, Subject, throwError } from 'rxjs'; +import { EMPTY, from, Observable, of, throwError } from 'rxjs'; import { catchError, map, switchMap, takeUntil } from 'rxjs/operators'; import { v4 as uuidv4 } from 'uuid'; @@ -24,33 +25,29 @@ import { v4 as uuidv4 } from 'uuid'; styleUrls: ['web-client-ssh.component.scss'], providers: [MessageService], }) -export class WebClientSshComponent extends WebClientBaseComponent implements WebComponentReady, OnInit, OnDestroy { +export class WebClientSshComponent + extends TerminalWebClientBaseComponent + implements WebComponentReady, CanSendTerminateSessionCmd, OnInit, OnDestroy +{ @Input() webSessionId: string; - @Output() componentStatus: EventEmitter = new EventEmitter(); - @Output() sizeChange: EventEmitter = new EventEmitter(); @ViewChild('sessionSshContainer') sessionContainerElement: ElementRef; @ViewChild('webSSHGuiTerminal') webGuiTerminal: ElementRef; formData: SSHFormDataInput; - clientError: string; - rightToolbarButtons = [ - { label: 'Close Session', icon: 'dvl-icon dvl-icon-close', action: () => this.startTerminationProcess() }, - ]; - - protected removeElement = new Subject(); private remoteTerminal: SSHTerminal; - private remoteTerminalEventListener: () => void; + // unsubscribeTerminalEvent, unsubscribeConnectionListener, removeRemoteTerminalListener() + // and ngOnDestroy live in TerminalWebClientBaseComponent constructor( protected utils: UtilsService, protected gatewayAlertMessageService: GatewayAlertMessageService, - private webSessionService: WebSessionService, - private webClientService: WebClientService, + protected webSessionService: WebSessionService, + protected webClientService: WebClientService, protected analyticService: AnalyticService, ) { - super(gatewayAlertMessageService, analyticService); + super(gatewayAlertMessageService, webSessionService, analyticService); } ngOnInit(): void { @@ -58,15 +55,8 @@ export class WebClientSshComponent extends WebClientBaseComponent implements Web this.removeWebClientGuiElement(); } - ngOnDestroy(): void { - this.removeRemoteTerminalListener(); - this.removeWebClientGuiElement(); - - if (this.currentStatus.isInitialized && !this.currentStatus.isDisabled) { - this.startTerminationProcess(); - } - - super.ngOnDestroy(); + protected teardownTerminalClient(): void { + this.remoteTerminal = null; } webComponentReady(event: CustomEvent, webSessionId: string): void { @@ -81,19 +71,19 @@ export class WebClientSshComponent extends WebClientBaseComponent implements Web startTerminationProcess(): void { this.currentStatus.isDisabledByUser = true; - this.handleSessionEndedOrError(TerminalConnectionStatus.failed); + this.handleSessionEnded(this.getMessage(TerminalConnectionStatus.failed)); this.sendTerminateSessionCmd(); this.disableComponentStatus(); } sendTerminateSessionCmd(): void { - if (!this.currentStatus.isInitialized) { + if (!this.currentStatus.isInitialized || !this.remoteTerminal) { return; } void this.remoteTerminal.close(); } - removeWebClientGuiElement(): void { + protected removeWebClientGuiElement(): void { this.removeElement.pipe(takeUntil(this.destroyed$)).subscribe({ next: (): void => { if (this.webGuiTerminal?.nativeElement) { @@ -106,28 +96,8 @@ export class WebClientSshComponent extends WebClientBaseComponent implements Web }); } - private removeRemoteTerminalListener(): void { - if (this.remoteTerminalEventListener) { - this.remoteTerminalEventListener(); - } - } - - private initializeStatus(): void { - this.currentStatus = { - id: this.webSessionId, - isInitialized: true, - isDisabled: false, - isDisabledByUser: false, - }; - } - - private disableComponentStatus(): void { - if (this.currentStatus.isDisabled) { - return; - } - - this.currentStatus.isDisabled = true; - this.componentStatus.emit(this.currentStatus); + protected getSuccessIcon(): string { + return DVL_SSH_ICON; } private startConnectionProcess(): void { @@ -135,9 +105,8 @@ export class WebClientSshComponent extends WebClientBaseComponent implements Web return; } - this.remoteTerminal.onStatusChange((v) => { + this.unsubscribeConnectionListener = this.remoteTerminal.onStatusChange((v) => { if (v === TerminalConnectionStatus.connected) { - // connected only indicates connection to Gateway is successful this.remoteTerminal.writeToTerminal('connecting... \r\n'); } }); @@ -149,7 +118,7 @@ export class WebClientSshComponent extends WebClientBaseComponent implements Web switchMap((params) => this.webClientService.fetchSshToken(params)), switchMap((params) => this.callConnect(params)), catchError((error) => { - this.handleSshError(error.message); + this.handleConnectionError(error.message); return EMPTY; }), ) @@ -163,7 +132,7 @@ export class WebClientSshComponent extends WebClientBaseComponent implements Web port: connectionParameters.port, username: connectionParameters.username, proxyUrl: connectionParameters.gatewayAddress + `?token=${connectionParameters.token}`, - passpharse: connectionParameters.privateKeyPassphrase ?? '', + passphrase: connectionParameters.privateKeyPassphrase ?? '', privateKey: connectionParameters.privateKey ?? '', password: connectionParameters.password ?? '', }), @@ -184,10 +153,10 @@ export class WebClientSshComponent extends WebClientBaseComponent implements Web const sessionId: string = uuidv4(); const extractedData: ExtractedHostnamePort = this.utils.string.extractHostnameAndPort(hostname, DefaultSshPort); - const gatewayHttpAddress: URL = new URL(JET_SSH_URL + `/${sessionId}`, window.location.href); - const gatewayAddress: string = gatewayHttpAddress.toString().replace('http', 'ws'); + const gatewayAddress = this.getGatewayWebSocketUrl(JET_SSH_URL, sessionId); const privateKey: string | null = formData.extraData?.sshPrivateKey || null; const privateKeyPassphrase: string = formData.passphrase || null; + const connectionParameters: SshConnectionParameters = { host: extractedData.hostname, username: username, @@ -207,14 +176,15 @@ export class WebClientSshComponent extends WebClientBaseComponent implements Web return; } - this.remoteTerminal.onStatusChange((status) => { + this.unsubscribeTerminalEvent = this.remoteTerminal.onStatusChange((status) => { switch (status) { case TerminalConnectionStatus.connected: - this.handleSessionStarted(); + this.handleClientConnectStarted(); + this.initializeStatus(); break; case TerminalConnectionStatus.failed: case TerminalConnectionStatus.closed: - this.handleSessionEndedOrError(status); + this.handleSessionEnded(this.getMessage(status)); break; default: break; @@ -222,43 +192,6 @@ export class WebClientSshComponent extends WebClientBaseComponent implements Web }); } - private handleSessionStarted(): void { - this.handleClientConnectStarted(); - this.initializeStatus(); - } - - private handleSessionEndedOrError(status: TerminalConnectionStatus): void { - if (document.fullscreenElement) { - document.exitFullscreen().catch((err) => { - console.error(`Error attempting to exit fullscreen: ${err}`); - }); - } - - this.notifyUser(status); - this.disableComponentStatus(); - super.webClientConnectionClosed(); - } - - private notifyUser(status: TerminalConnectionStatus): void { - this.clientError = this.getMessage(status); - - const icon: string = status !== TerminalConnectionStatus.connected ? DVL_WARNING_ICON : DVL_SSH_ICON; - void this.webSessionService.updateWebSessionIcon(this.webSessionId, icon); - } - - private handleClientConnectStarted(): void { - this.loading = false; - void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_SSH_ICON); - super.webClientConnectionSuccess(); - } - - private handleSshError(error: string): void { - this.clientError = typeof error === 'string' ? error : this.getMessage(error); - console.error(error); - this.disableComponentStatus(); - void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_WARNING_ICON); - } - private getMessage(status: TerminalConnectionStatus): string { //For translation 'UnknownError' //For translation 'ConnectionErrorPleaseVerifyYourConnectionSettings' diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.html index de1189ca1..33e948806 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.html @@ -1,23 +1,18 @@
- -
- -
- -
-
- + @if (loading) { +
+
+ @if (!hideSpinnerOnly) { + + } +
-
+ }
diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.scss b/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.scss index 66025b845..d47881c7b 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.scss +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.scss @@ -6,14 +6,8 @@ background: #F9F9F9 url('../../../../../assets/images/session_background.png'); } -:host ::ng-deep .separator { - border: solid 1px var(--border-secondary-color); - width: 0; - height: 14px; - margin: 0 8px; -} - #web-telnet-terminal-app { + position: relative; flex-grow: 1; display: flex; flex-direction: column; @@ -29,7 +23,11 @@ display: flex; justify-content: center; align-items: center; - height: 100vh; + position: absolute; + inset: 0; + z-index: 1; + min-width: 0; + min-height: 0; } .loading-info { @@ -37,15 +35,22 @@ place-items: center; height: 100vh; width: 100vw; + min-width: 0; + min-height: 0; } web-telnet-gui { height: 100vh; width: 100vw; + min-width: 0; + min-height: 0; } -.session-form { - display: flex; - flex-direction: column; - height: 100%; + +.toolbar-overlay-anchor { + position: absolute; + inset: 0; + z-index: 20; + pointer-events: none; // anchor is transparent — the toolbar children opt-in via pointer-events: auto } + diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.ts index 363a264fb..e8e9ef686 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.ts +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/telnet/web-client-telnet.component.ts @@ -1,14 +1,13 @@ -import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; -import { WebClientBaseComponent } from '@shared/bases/base-web-client.component'; +import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { GatewayAlertMessageService } from '@shared/components/gateway-alert-message/gateway-alert-message.service'; import { TelnetConnectionParameters } from '@shared/interfaces/connection-params.interfaces'; import { TelnetFormDataInput } from '@shared/interfaces/forms.interfaces'; -import { ComponentStatus } from '@shared/models/component-status.model'; +import { CanSendTerminateSessionCmd } from '@shared/models/web-session.model'; import { UtilsService } from '@shared/services/utils.service'; import { DefaultTelnetPort, WebClientService } from '@shared/services/web-client.service'; import { WebSessionService } from '@shared/services/web-session.service'; import { MessageService } from 'primeng/api'; -import { EMPTY, from, Observable, of, Subject, throwError } from 'rxjs'; +import { EMPTY, from, Observable, of, throwError } from 'rxjs'; import { catchError, map, switchMap, takeUntil } from 'rxjs/operators'; import { v4 as uuidv4 } from 'uuid'; import '@devolutions/web-telnet-gui/dist/web-telnet-gui.js'; @@ -18,8 +17,9 @@ import { TerminalConnectionStatus, loggingService as telnetLoggingService, } from '@devolutions/web-telnet-gui'; -import { DVL_TELNET_ICON, DVL_WARNING_ICON, JET_TELNET_URL } from '@gateway/app.constants'; +import { DVL_TELNET_ICON, JET_TELNET_URL } from '@gateway/app.constants'; import { AnalyticService, ProtocolString } from '@gateway/shared/services/analytic.service'; +import { TerminalWebClientBaseComponent } from '@shared/bases/terminal-web-client-base.component'; import { ExtractedHostnamePort } from '@shared/services/utils/string.service'; @Component({ @@ -28,33 +28,33 @@ import { ExtractedHostnamePort } from '@shared/services/utils/string.service'; styleUrls: ['web-client-telnet.component.scss'], providers: [MessageService], }) -export class WebClientTelnetComponent extends WebClientBaseComponent implements OnInit, OnDestroy { +export class WebClientTelnetComponent + extends TerminalWebClientBaseComponent + implements CanSendTerminateSessionCmd, OnInit, OnDestroy +{ @Input() webSessionId: string; - @Output() componentStatus: EventEmitter = new EventEmitter(); - @Output() sizeChange: EventEmitter = new EventEmitter(); @ViewChild('sessionTelnetContainer') sessionContainerElement: ElementRef; @ViewChild('webTelnetGuiTerminal') webGuiTerminal: ElementRef; formData: TelnetFormDataInput; - clientError: string; rightToolbarButtons = [ { label: 'Close Session', icon: 'dvl-icon dvl-icon-close', action: () => this.startTerminationProcess() }, ]; - protected removeElement = new Subject(); private remoteTerminal: TelnetTerminal; - private unsubscribeTerminalEvent: () => void; + // unsubscribeTerminalEvent, unsubscribeConnectionListener, removeRemoteTerminalListener() + // and ngOnDestroy live in TerminalWebClientBaseComponent constructor( protected utils: UtilsService, protected gatewayAlertMessageService: GatewayAlertMessageService, - private webSessionService: WebSessionService, - private webClientService: WebClientService, + protected webSessionService: WebSessionService, + protected webClientService: WebClientService, protected analyticService: AnalyticService, ) { - super(gatewayAlertMessageService, analyticService); + super(gatewayAlertMessageService, webSessionService, analyticService); } ngOnInit(): void { @@ -62,15 +62,8 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements this.removeWebClientGuiElement(); } - ngOnDestroy(): void { - this.removeRemoteTerminalListener(); - this.removeWebClientGuiElement(); - - if (this.currentStatus.isInitialized && !this.currentStatus.isDisabled) { - this.startTerminationProcess(); - } - - super.ngOnDestroy(); + protected teardownTerminalClient(): void { + this.remoteTerminal = null; } webComponentReady(event: CustomEvent, webSessionId: string): void { @@ -86,18 +79,18 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements startTerminationProcess(): void { this.currentStatus.isDisabledByUser = true; this.sendTerminateSessionCmd(); - this.handleSessionEndedOrError(TerminalConnectionStatus.closed); + this.handleSessionEnded(this.getMessage(TerminalConnectionStatus.closed), false); } sendTerminateSessionCmd(): void { - if (!this.currentStatus.isInitialized) { + if (!this.currentStatus.isInitialized || !this.remoteTerminal) { return; } this.currentStatus.isInitialized = false; this.remoteTerminal.close(); } - removeWebClientGuiElement(): void { + protected removeWebClientGuiElement(): void { this.removeElement.pipe(takeUntil(this.destroyed$)).subscribe({ next: (): void => { if (this.webGuiTerminal?.nativeElement) { @@ -110,26 +103,8 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements }); } - private removeRemoteTerminalListener(): void { - this.unsubscribeTerminalEvent(); - } - - private initializeStatus(): void { - this.currentStatus = { - id: this.webSessionId, - isInitialized: true, - isDisabled: false, - isDisabledByUser: false, - }; - } - - private disableComponentStatus(): void { - if (this.currentStatus.isDisabled) { - return; - } - - this.currentStatus.isDisabled = true; - this.componentStatus.emit(this.currentStatus); + protected getSuccessIcon(): string { + return DVL_TELNET_ICON; } private startConnectionProcess(): void { @@ -137,9 +112,8 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements return; } - this.remoteTerminal.onStatusChange((v) => { + this.unsubscribeConnectionListener = this.remoteTerminal.onStatusChange((v) => { if (v === TerminalConnectionStatus.connected) { - // connected only indicates connection to Gateway is successful this.remoteTerminal.writeToTerminal('connecting... \r\n'); } }); @@ -151,7 +125,7 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements switchMap((params) => this.webClientService.fetchTelnetToken(params)), switchMap((params) => this.callConnect(params)), catchError((error) => { - this.handleTelnetError(error.message); + this.handleConnectionError(error.message); return EMPTY; }), ) @@ -159,15 +133,21 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements } private callConnect(connectionParameters: TelnetConnectionParameters) { + const gatewayUrl = new URL(connectionParameters.gatewayAddress); + if (connectionParameters.token && !gatewayUrl.searchParams.has('token')) { + gatewayUrl.searchParams.set('token', connectionParameters.token); + } + const gatewayAddress: string = gatewayUrl.toString(); + return from( - this.remoteTerminal.connect( - connectionParameters.host, - connectionParameters.port, - null, - connectionParameters.gatewayAddress + `?token=${connectionParameters.token}`, - null, - ), - ).pipe(catchError((error) => throwError(error))); + this.remoteTerminal.connect({ + hostname: connectionParameters.host, + port: connectionParameters.port, + username: null, + password: null, + proxyUrl: gatewayAddress, + }), + ).pipe(catchError((error) => throwError(() => error))); } private getFormData() { @@ -180,11 +160,9 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements private fetchParameters(formData: TelnetFormDataInput): Observable { const { hostname } = formData; - const sessionId: string = uuidv4(); const extractedData: ExtractedHostnamePort = this.utils.string.extractHostnameAndPort(hostname, DefaultTelnetPort); - const gatewayHttpAddress: URL = new URL(JET_TELNET_URL + `/${sessionId}`, window.location.href); - const gatewayAddress: string = gatewayHttpAddress.toString().replace('http', 'ws'); + const gatewayAddress = this.getGatewayWebSocketUrl(JET_TELNET_URL, sessionId); const connectionParameters: TelnetConnectionParameters = { host: extractedData.hostname, @@ -201,16 +179,16 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements return; } - // Store the listener function for cleanup this.unsubscribeTerminalEvent = this.remoteTerminal.onStatusChange((status) => { switch (status) { case TerminalConnectionStatus.connected: - this.handleSessionStarted(); + this.handleClientConnectStarted(); + this.initializeStatus(); break; case TerminalConnectionStatus.failed: case TerminalConnectionStatus.closed: case TerminalConnectionStatus.timeout: - this.handleSessionEndedOrError(status); + this.handleSessionEnded(this.getMessage(status)); break; default: break; @@ -218,45 +196,6 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements }); } - private handleSessionStarted(): void { - this.handleClientConnectStarted(); - this.initializeStatus(); - } - - private handleSessionEndedOrError(status: TerminalConnectionStatus): void { - if (document.fullscreenElement) { - document.exitFullscreen().catch((err) => { - console.error(`Error attempting to exit fullscreen: ${err}`); - }); - } - - this.notifyUser(status); - this.disableComponentStatus(); - super.webClientConnectionClosed(); - } - - private notifyUser(status: TerminalConnectionStatus): void { - this.clientError = this.getMessage(status); - - const icon: string = status !== TerminalConnectionStatus.connected ? DVL_WARNING_ICON : DVL_TELNET_ICON; - - void this.webSessionService.updateWebSessionIcon(this.webSessionId, icon); - } - - private handleClientConnectStarted(): void { - this.loading = false; - void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_TELNET_ICON); - super.webClientConnectionSuccess(); - } - - private handleTelnetError(error: string): void { - this.clientError = typeof error === 'string' ? error : this.getMessage(error); - console.error(error); - this.disableComponentStatus(); - - void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_WARNING_ICON); - } - private getMessage(status: TerminalConnectionStatus): string { //For translation 'UnknownError' //For translation 'ConnectionErrorPleaseVerifyYourConnectionSettings' diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.html b/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.html index 1b70c4e6e..13f646df6 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.html +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.html @@ -1,12 +1,4 @@ -
-
- -
- +
-
-
- + @if (loading) { +
+
+ @if (!hideSpinnerOnly) { + + } +
-
+ } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.scss b/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.scss index 949b3119f..7429526ba 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.scss +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.scss @@ -1,11 +1,27 @@ :host { background: #F9F9F9 url('../../../../../assets/images/session_background.png'); display: flex; - height: 100%; + flex: 1; + min-height: 0; width: 100%; flex-direction: column; } +.session-vnc-container { + position: relative; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; + + iron-remote-desktop { + flex: 1; + min-height: 0; + overflow: hidden; + } +} + :host ::ng-deep .separator { border: solid 1px var(--border-secondary-color); width: 0; @@ -17,70 +33,27 @@ display: flex; justify-content: center; align-items: center; - height: 100vh; + position: absolute; + inset: 0; + z-index: 1; + min-width: 0; + min-height: 0; } .loading-info { display: grid; place-items: center; - height: 100vh; - width: 100vw; -} - -.session-toolbar { - display: flex; + height: 100%; width: 100%; - height: 44px; - padding: 8px; - justify-content: space-between; - align-items: center; - background: var(--bg-secondary-color); + min-width: 0; + min-height: 0; } -.session-toolbar-middle-group, .session-toolbar-left-group, .session-toolbar-right-group { - display: flex; - align-items: center; -} -.session-toolbar ::ng-deep .p-button { - border: none; - border-radius: 4px; - background-color: transparent; +.toolbar-overlay-anchor { + position: absolute; + inset: 0; + z-index: 20; + pointer-events: none; // anchor is transparent — the toolbar children opt-in via pointer-events: auto } -.session-toolbar ::ng-deep .p-button:hover { - background: #0068C31A; -} - -.session-toolbar ::ng-deep .p-button .p-button-label { - color: #000000CC; - font-family: Open Sans; - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: normal; -} - -.session-toolbar ::ng-deep .p-button .p-button-icon-left { - display: flex; - align-items: center; - color: #0068C3; - margin-right: 8px; - height: 16px; - width: 16px; -} - -.session-toolbar-layer { - position: fixed; - top: 0; - left: 0; - width: 100%; - z-index: 1000; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.session-form { - display: flex; - flex-direction: column; - height: 100%; -} diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts index 27fb26d6d..d98ec16ae 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/vnc/web-client-vnc.component.ts @@ -1,32 +1,17 @@ -import { - AfterViewInit, - Component, - ElementRef, - EventEmitter, - HostListener, - Input, - OnDestroy, - OnInit, - Output, - Renderer2, - ViewChild, -} from '@angular/core'; -import { IronError, SessionTerminationInfo, UserInteraction } from '@devolutions/iron-remote-desktop'; -import { WebClientBaseComponent } from '@shared/bases/base-web-client.component'; +import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, Renderer2, ViewChild } from '@angular/core'; +import { DesktopWebClientBaseComponent } from '@shared/bases/desktop-web-client-base.component'; import { GatewayAlertMessageService } from '@shared/components/gateway-alert-message/gateway-alert-message.service'; import { ScreenScale } from '@shared/enums/screen-scale.enum'; import { ScreenSize } from '@shared/enums/screen-size.enum'; import { IronVNCConnectionParameters } from '@shared/interfaces/connection-params.interfaces'; import { VncFormDataInput } from '@shared/interfaces/forms.interfaces'; -import { ComponentStatus } from '@shared/models/component-status.model'; import { DesktopSize } from '@shared/models/desktop-size'; import { UtilsService } from '@shared/services/utils.service'; import { DefaultVncPort, WebClientService } from '@shared/services/web-client.service'; import { WebSessionService } from '@shared/services/web-session.service'; -import type { ToastMessageOptions } from 'primeng/api'; import { MessageService } from 'primeng/api'; -import { debounceTime, EMPTY, from, Observable, of, Subject, Subscription } from 'rxjs'; -import { catchError, map, switchMap, takeUntil } from 'rxjs/operators'; +import { debounceTime, EMPTY, from, Observable, of, Subscription } from 'rxjs'; +import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators'; import '@devolutions/iron-remote-desktop/iron-remote-desktop.js'; import { Backend, @@ -39,7 +24,7 @@ import { ultraVirtualDisplay, wheelSpeedFactor, } from '@devolutions/iron-remote-desktop-vnc'; -import { DVL_VNC_ICON, DVL_WARNING_ICON, JET_VNC_URL } from '@gateway/app.constants'; +import { DVL_VNC_ICON, JET_VNC_URL } from '@gateway/app.constants'; import { AnalyticService, ProtocolString } from '@gateway/shared/services/analytic.service'; import { Encoding } from '@shared/enums/encoding.enum'; import { WebSession } from '@shared/models/web-session.model'; @@ -47,42 +32,22 @@ import { ComponentResizeObserverService } from '@shared/services/component-resiz import { ExtractedHostnamePort } from '@shared/services/utils/string.service'; import { v4 as uuidv4 } from 'uuid'; -enum UserIronRdpErrorKind { - General = 0, - WrongPassword = 1, - LogonFailure = 2, - AccessDenied = 3, - RDCleanPath = 4, - ProxyConnect = 5, -} - @Component({ standalone: false, templateUrl: 'web-client-vnc.component.html', styleUrls: ['web-client-vnc.component.scss'], providers: [MessageService], }) -export class WebClientVncComponent extends WebClientBaseComponent implements OnInit, AfterViewInit, OnDestroy { - @Input() webSessionId: string; - @Input() sessionsContainerElement: ElementRef; - @Output() componentStatus: EventEmitter = new EventEmitter(); - @Output() sizeChange: EventEmitter = new EventEmitter(); - +export class WebClientVncComponent + extends DesktopWebClientBaseComponent + implements OnInit, AfterViewInit, OnDestroy +{ @ViewChild('sessionVncContainer') sessionContainerElement: ElementRef; - @ViewChild('ironRemoteDesktopElement') ironRemoteDesktopElement: ElementRef; backendRef = Backend; - - formData: VncFormDataInput; - sessionTerminationMessage: ToastMessageOptions; - isFullScreenMode = false; - cursorOverrideActive = false; - dynamicResizeSupported = false; dynamicResizeEnabled = false; - saveRemoteClipboardButtonEnabled = false; - leftToolbarButtons = [ { label: 'Start', @@ -152,85 +117,30 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI }, ]; - clipboardActionButtons: { - label: string; - tooltip: string; - icon: string; - action: () => Promise; - enabled: () => boolean; - }[] = []; - - private setupClipboardHandling(): void { - // Clipboard API is available only in secure contexts (HTTPS). - if (!window.isSecureContext) { - return; - } - - if (this.formData.autoClipboard === true) { - return; - } - - // We don't check for clipboard write support, as all recent browser versions support it. - this.clipboardActionButtons.push({ - label: 'Save Clipboard', - tooltip: 'Copy received clipboard content to your local clipboard.', - icon: 'dvl-icon dvl-icon-save', - action: () => this.saveRemoteClipboard(), - enabled: () => this.saveRemoteClipboardButtonEnabled, - }); - - // Check if the browser supports reading local clipboard. - if (navigator.clipboard.readText) { - this.clipboardActionButtons.push({ - label: 'Send Clipboard', - tooltip: 'Send your local clipboard content to the remote server.', - icon: 'dvl-icon dvl-icon-send', - action: () => this.sendClipboard(), - enabled: () => true, - }); - } - } - - protected removeElement: Subject = new Subject(); - private remoteClient: UserInteraction; - private remoteClientEventListener: (event: Event) => void; - private componentResizeObserverDisconnect?: () => void; private dynamicComponentResizeSubscription?: Subscription; constructor( - private renderer: Renderer2, + protected renderer: Renderer2, protected utils: UtilsService, protected gatewayAlertMessageService: GatewayAlertMessageService, - private webSessionService: WebSessionService, + protected webSessionService: WebSessionService, private webClientService: WebClientService, private componentResizeService: ComponentResizeObserverService, protected analyticService: AnalyticService, ) { - super(gatewayAlertMessageService, analyticService); - } - - @HostListener('document:fullscreenchange') - onFullScreenChange(): void { - this.handleOnFullScreenEvent(); + super(renderer, webSessionService, gatewayAlertMessageService, analyticService); } ngOnInit(): void { - this.removeWebClientGuiElement(); - this.setupClipboardHandling(); - } + this.webSessionIcon = DVL_VNC_ICON; - ngAfterViewInit(): void { - this.initiateRemoteClientListener(); + super.ngOnInit(); } ngOnDestroy(): void { - this.removeRemoteClientListener(); - this.removeWebClientGuiElement(); - this.dynamicComponentResizeSubscription?.unsubscribe(); this.componentResizeObserverDisconnect?.(); - super.ngOnDestroy(); } @@ -242,60 +152,6 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI this.remoteClient.ctrlAltDel(); } - startTerminationProcess(): void { - this.sendTerminateSessionCmd(); - this.currentStatus.isDisabledByUser = true; - this.disableComponentStatus(); - } - - sendTerminateSessionCmd(): void { - if (!this.currentStatus.isInitialized) { - return; - } - this.currentStatus.isInitialized = false; - // shutdowns the session, not the server. Jan 2024 KAH. - this.remoteClient.shutdown(); - } - - scaleTo(scale: ScreenScale): void { - this.remoteClient.setScale(scale.valueOf()); - } - - setKeyboardUnicodeMode(useUnicode: boolean): void { - this.remoteClient.setKeyboardUnicodeMode(useUnicode); - } - - async saveRemoteClipboard(): Promise { - try { - await this.remoteClient.saveRemoteClipboardData(); - - super.webClientSuccess('Clipboard content has been copied to your clipboard!'); - this.saveRemoteClipboardButtonEnabled = false; - } catch (err) { - this.handleSessionError(err); - } - } - - async sendClipboard(): Promise { - try { - await this.remoteClient.sendClipboardData(); - - super.webClientSuccess('Clipboard content has been sent to the remote server!'); - } catch (err) { - this.handleSessionError(err); - } - } - - toggleCursorKind(): void { - if (this.cursorOverrideActive) { - this.remoteClient.setCursorStyleOverride(null); - } else { - this.remoteClient.setCursorStyleOverride('url("assets/images/crosshair.png") 7 7, default'); - } - - this.cursorOverrideActive = !this.cursorOverrideActive; - } - setWheelSpeedFactor(factor: number): void { this.remoteClient.invokeExtension(wheelSpeedFactor(factor)); } @@ -324,69 +180,7 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI } } - removeWebClientGuiElement(): void { - this.removeElement.pipe(takeUntil(this.destroyed$)).subscribe({ - next: (): void => { - if (this.ironRemoteDesktopElement?.nativeElement) { - this.ironRemoteDesktopElement.nativeElement.remove(); - } - }, - error: (err): void => { - console.error('Error while removing element:', err); - }, - }); - } - - private initializeStatus(): void { - this.currentStatus = { - id: this.webSessionId, - isInitialized: true, - isDisabled: false, - isDisabledByUser: false, - }; - } - - private disableComponentStatus(): void { - this.currentStatus.isDisabled = true; - this.componentStatus.emit(this.currentStatus); - } - - private handleOnFullScreenEvent(): void { - if (!document.fullscreenElement) { - this.handleExitFullScreenEvent(); - } - } - - private toggleFullscreen(): void { - this.isFullScreenMode = !this.isFullScreenMode; - !document.fullscreenElement ? this.enterFullScreen() : this.exitFullScreen(); - - this.scaleTo(ScreenScale.Full); - } - - private async enterFullScreen(): Promise { - if (document.fullscreenElement) { - return; - } - - try { - const sessionsContainerElement = this.sessionsContainerElement.nativeElement; - await sessionsContainerElement.requestFullscreen(); - } catch (err) { - this.isFullScreenMode = false; - console.error(`Error attempting to enable fullscreen mode: ${err.message} (${err.name})`); - } - } - - private exitFullScreen(): void { - if (document.fullscreenElement) { - document.exitFullscreen().catch((err) => { - console.error(`Error attempting to exit fullscreen: ${err}`); - }); - } - } - - private handleExitFullScreenEvent(): void { + protected handleExitFullScreenEvent(): void { this.isFullScreenMode = false; const sessionContainerElement = this.sessionContainerElement.nativeElement; @@ -399,40 +193,11 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI this.scaleTo(ScreenScale.Fit); } - private initiateRemoteClientListener(): void { - this.remoteClientEventListener = (event: Event) => this.readyRemoteClientEventListener(event); - this.renderer.listen(this.ironRemoteDesktopElement.nativeElement, 'ready', this.remoteClientEventListener); - } - - private removeRemoteClientListener(): void { - if (this.ironRemoteDesktopElement && this.remoteClientEventListener) { - this.renderer.destroy(); - } - } - - private readyRemoteClientEventListener(event: Event): void { - const customEvent = event as CustomEvent; - this.remoteClient = customEvent.detail.irgUserInteraction; - - if (this.formData.autoClipboard !== true) { - this.remoteClient.setEnableAutoClipboard(false); - } - - // Register callbacks for events. - this.remoteClient.onWarningCallback((data: string) => { - this.webClientWarning(data); - }); - this.remoteClient.onClipboardRemoteUpdateCallback(() => { - this.saveRemoteClipboardButtonEnabled = true; - }); - - this.startConnectionProcess(); - } - - private startConnectionProcess(): void { + protected startConnectionProcess(): void { this.getFormData() .pipe( takeUntil(this.destroyed$), + tap(() => this.setupClipboardHandling(this.formData.autoClipboard)), switchMap(() => this.setScreenSizeScale(this.formData.screenSize)), switchMap(() => this.fetchParameters(this.formData)), switchMap((params) => this.fetchTokens(params)), @@ -497,7 +262,7 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI return of(connectionParameters); } - fetchTokens(params: IronVNCConnectionParameters): Observable { + private fetchTokens(params: IronVNCConnectionParameters): Observable { return this.webClientService.fetchVncToken(params); } @@ -508,7 +273,7 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI return of(undefined); } - private callConnect(connectionParameters: IronVNCConnectionParameters): void { + protected callConnect(connectionParameters: IronVNCConnectionParameters): void { const configBuilder = this.remoteClient .configBuilder() .withDestination(connectionParameters.host) @@ -567,99 +332,6 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI }); } - private handleSessionStarted(): void { - this.loading = false; - this.remoteClient.setVisibility(true); - void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_VNC_ICON); - this.webClientConnectionSuccess(); - this.initializeStatus(); - } - - private handleSessionTerminatedGracefully(sessionTerminationInfo: SessionTerminationInfo): void { - this.sessionTerminationMessage = { - summary: 'Session terminated gracefully', - detail: sessionTerminationInfo.reason(), - severity: 'success', - }; - - this.handleSessionTerminated(); - } - - private handleSessionTerminatedWithError(error: unknown): void { - if (this.isIronError(error)) { - this.sessionTerminationMessage = { - summary: this.getIronErrorMessageTitle(error), - detail: error.backtrace(), - severity: 'error', - }; - } else { - this.sessionTerminationMessage = { - summary: 'Unexpected error occurred', - detail: `${error}`, - severity: 'error', - }; - } - - void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_WARNING_ICON); - - this.handleSessionTerminated(); - } - - private handleSessionTerminated(): void { - if (document.fullscreenElement) { - this.exitFullScreen(); - } - - this.disableComponentStatus(); - super.webClientConnectionClosed(); - } - - private handleSessionError(err: unknown): void { - if (this.isIronError(err)) { - this.webClientError(err.backtrace()); - } else { - this.webClientError(`${err}`); - } - } - - private isIronError(error: unknown): error is IronError { - return ( - typeof error === 'object' && - error !== null && - typeof (error as IronError).backtrace === 'function' && - typeof (error as IronError).kind === 'function' - ); - } - - private handleError(error: string): void { - this.sessionTerminationMessage = { - summary: 'Unexpected error occurred', - detail: error, - severity: 'error', - }; - this.disableComponentStatus(); - } - - private getIronErrorMessageTitle(error: IronError): string { - const errorKind: UserIronRdpErrorKind = error.kind().valueOf(); - - //For translation 'UnknownError' - //For translation 'ConnectionErrorPleaseVerifyYourConnectionSettings' - //For translation 'AccessDenied' - //For translation 'ConnectionErrorPleaseVerifyYourConnectionSettings' - switch (errorKind) { - case UserIronRdpErrorKind.General: - return 'Unknown Error'; - case UserIronRdpErrorKind.WrongPassword: - case UserIronRdpErrorKind.LogonFailure: - return 'Connection error: Please verify your connection settings.'; - case UserIronRdpErrorKind.AccessDenied: - return 'Access denied'; - default: - return 'Connection error: Please verify your connection settings.'; - } - } - protected getProtocol(): ProtocolString { return 'VNC'; } diff --git a/webapp/apps/gateway-ui/src/client/app/modules/web-client/web-client.module.ts b/webapp/apps/gateway-ui/src/client/app/modules/web-client/web-client.module.ts index 3ab8f41f6..8bdcb19df 100644 --- a/webapp/apps/gateway-ui/src/client/app/modules/web-client/web-client.module.ts +++ b/webapp/apps/gateway-ui/src/client/app/modules/web-client/web-client.module.ts @@ -23,6 +23,8 @@ import { DynamicTabComponent } from '@shared/components/dynamic-tab/dynamic-tab. import { MainPanelComponent } from '@shared/components/main-panel/main-panel.component'; import { SessionToolbarComponent } from '@shared/components/session-toolbar/session-toolbar.component'; import { TabViewComponent } from '@shared/components/tab-view/tab-view.component'; +import { Protocol } from '@shared/enums/web-client-protocol.enum'; +import { WebSessionService } from '@shared/services/web-session.service'; import { SharedModule } from '@shared/shared.module'; import { CheckboxModule } from 'primeng/checkbox'; import { KeyFilterModule } from 'primeng/keyfilter'; @@ -108,4 +110,14 @@ const routes: Routes = [ exports: [DynamicTabComponent, WebClientFormComponent, NetScanComponent], providers: [], }) -export class WebClientModule {} +export class WebClientModule { + constructor(webSessionService: WebSessionService) { + webSessionService.registerProtocolComponentMap({ + [Protocol.RDP]: WebClientRdpComponent, + [Protocol.Telnet]: WebClientTelnetComponent, + [Protocol.SSH]: WebClientSshComponent, + [Protocol.VNC]: WebClientVncComponent, + [Protocol.ARD]: WebClientArdComponent, + }); + } +} diff --git a/webapp/apps/gateway-ui/src/client/app/shared/bases/base-web-client.component.ts b/webapp/apps/gateway-ui/src/client/app/shared/bases/base-web-client.component.ts index 0b9dc91ef..f7e459172 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/bases/base-web-client.component.ts +++ b/webapp/apps/gateway-ui/src/client/app/shared/bases/base-web-client.component.ts @@ -1,6 +1,7 @@ import { Directive } from '@angular/core'; import { GatewayAlertMessageService } from '@shared/components/gateway-alert-message/gateway-alert-message.service'; import { ComponentStatus } from '@shared/models/component-status.model'; +import { ToastMessageOptions } from 'primeng/api'; import { BaseSessionComponent } from '../models/web-session.model'; import { AnalyticService, ConnectionIdentifier, ProtocolString } from '../services/analytic.service'; @@ -13,6 +14,7 @@ export abstract class WebClientBaseComponent extends BaseSessionComponent { hideSpinnerOnly = false; error: string; loading = true; + sessionTerminationMessage: ToastMessageOptions; analyticHandle: ConnectionIdentifier; @@ -22,7 +24,6 @@ export abstract class WebClientBaseComponent extends BaseSessionComponent { isDisabled: false, isDisabledByUser: false, }; - protected constructor( protected gatewayAlertMessageService: GatewayAlertMessageService, protected analyticService: AnalyticService, @@ -30,7 +31,7 @@ export abstract class WebClientBaseComponent extends BaseSessionComponent { super(); } - abstract removeWebClientGuiElement(): void; + // ── Session lifecycle helpers ────────────────────────────────────────────── //For translation 'ConnectionSuccessful protected webClientConnectionSuccess(message = 'Connection successful'): void { @@ -59,5 +60,14 @@ export abstract class WebClientBaseComponent extends BaseSessionComponent { } } + protected getGatewayWebSocketUrl(baseUrl: string, sessionId?: string): string { + const normalizedBasePath = baseUrl.replace(/\/+$/, ''); + const path = sessionId ? `${normalizedBasePath}/${sessionId}` : normalizedBasePath; + const gatewayUrl: URL = new URL(path, window.location.href); + + gatewayUrl.protocol = gatewayUrl.protocol === 'https:' ? 'wss:' : 'ws:'; + return gatewayUrl.toString(); + } + protected abstract getProtocol(): ProtocolString; } diff --git a/webapp/apps/gateway-ui/src/client/app/shared/bases/desktop-web-client-base.component.ts b/webapp/apps/gateway-ui/src/client/app/shared/bases/desktop-web-client-base.component.ts new file mode 100644 index 000000000..4628d3c33 --- /dev/null +++ b/webapp/apps/gateway-ui/src/client/app/shared/bases/desktop-web-client-base.component.ts @@ -0,0 +1,400 @@ +import { + Directive, + ElementRef, + EventEmitter, + HostListener, + Input, + OnDestroy, + Output, + Renderer2, + ViewChild, +} from '@angular/core'; +import { IronError, SessionTerminationInfo, UserInteraction } from '@devolutions/iron-remote-desktop'; +import { DVL_WARNING_ICON } from '@gateway/app.constants'; +import { GatewayAlertMessageService } from '@shared/components/gateway-alert-message/gateway-alert-message.service'; +import { ScreenScale } from '@shared/enums/screen-scale.enum'; +import { + IronARDConnectionParameters, + IronRDPConnectionParameters, + IronVNCConnectionParameters, +} from '@shared/interfaces/connection-params.interfaces'; +import { DesktopFormDataInput } from '@shared/interfaces/forms.interfaces'; +import { ComponentStatus } from '@shared/models/component-status.model'; +import { WebSessionService } from '@shared/services/web-session.service'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { UAParser } from 'ua-parser-js'; +import { AnalyticService } from '../services/analytic.service'; +import { WebClientBaseComponent } from './base-web-client.component'; + +enum IronErrorKind { + General = 0, + WrongPassword = 1, + LogonFailure = 2, + AccessDenied = 3, + RDCleanPath = 4, + ProxyConnect = 5, +} + +@Directive() +export abstract class DesktopWebClientBaseComponent + extends WebClientBaseComponent + implements OnDestroy +{ + // ── Clipboard state — shared by desktop protocol components only ────────── + formData: TFormData; + + protected removeElement: Subject = new Subject(); + private remoteClientEventListener: (event: Event) => void; + // unlistenRemoteClient and removeRemoteClientListener() live in DesktopWebClientBaseComponent + + protected remoteClient: UserInteraction; + saveRemoteClipboardButtonEnabled = false; + + protected webSessionIcon: string; + + clipboardActionButtons: { + label: string; + tooltip: string; + icon: string; + action: () => Promise; + enabled: () => boolean; + }[] = []; + + isFullScreenMode = false; + cursorOverrideActive = false; + + @Input() webSessionId: string; + @Input() sessionsContainerElement: ElementRef; + + @Output() readonly componentStatus = new EventEmitter(); + @Output() readonly sizeChange = new EventEmitter(); + + @ViewChild('ironRemoteDesktopElement') ironRemoteDesktopElement: ElementRef; + + /** Stored so it can be called to remove the 'ready' event listener on destroy. */ + protected unlistenRemoteClient: (() => void) | null = null; + + protected abstract startConnectionProcess(): void; + protected abstract handleExitFullScreenEvent(): void; + protected abstract callConnect( + connectionParameters: IronVNCConnectionParameters | IronARDConnectionParameters | IronRDPConnectionParameters, + ): void; + + protected constructor( + protected renderer: Renderer2, + protected webSessionService: WebSessionService, + protected gatewayAlertMessageService: GatewayAlertMessageService, + protected analyticService: AnalyticService, + ) { + super(gatewayAlertMessageService, analyticService); + } + + @HostListener('document:fullscreenchange') + onFullScreenChange(): void { + this.handleOnFullScreenEvent(); + } + + ngOnInit(): void { + this.removeWebClientGuiElement(); + } + + ngAfterViewInit(): void { + this.initiateRemoteClientListener(); + } + + ngOnDestroy(): void { + this.removeRemoteClientListener(); + this.removeWebClientGuiElement(); + // Break the reference cycle: + // protocol component → this.remoteClient → UserInteraction → callback closures → protocol component + this.remoteClient = null; + super.ngOnDestroy(); + } + + protected handleOnFullScreenEvent(): void { + if (!document.fullscreenElement) { + this.handleExitFullScreenEvent(); + } + } + + protected toggleFullscreen(): void { + this.isFullScreenMode = !this.isFullScreenMode; + !document.fullscreenElement ? this.enterFullScreen() : this.exitFullScreen(); + + this.scaleTo(ScreenScale.Full); + } + + protected async enterFullScreen(): Promise { + if (document.fullscreenElement) { + return; + } + + try { + const sessionsContainerElement = this.sessionsContainerElement.nativeElement; + await sessionsContainerElement.requestFullscreen(); + } catch (err) { + this.isFullScreenMode = false; + console.error(`Error attempting to enable fullscreen mode: ${err.message} (${err.name})`); + } + } + + protected exitFullScreen(): void { + if (document.fullscreenElement) { + document.exitFullscreen().catch((err) => { + console.error(`Error attempting to exit fullscreen: ${err}`); + }); + } + } + + /** True when the remote client should handle clipboard automatically. + * For form-based connections the explicit autoClipboard field is used. + * When autoClipboard is undefined (URL/config-based RDP connections) the + * Blink engine is used as a reliable heuristic. */ + protected isAutoClipboardMode(autoClipboard?: boolean): boolean { + if (autoClipboard !== undefined) return autoClipboard; + return new UAParser().getEngine().name === 'Blink'; + } + + /** Populates clipboardActionButtons for manual clipboard workflows. + * Call after the component knows whether auto-clipboard is enabled. + * No-ops when in a non-secure context or when auto-clipboard is active. */ + protected setupClipboardHandling(autoClipboard?: boolean): void { + if (!window.isSecureContext || this.isAutoClipboardMode(autoClipboard)) { + return; + } + + // We don't check for clipboard write support, as all recent browser versions support it. + this.clipboardActionButtons.push({ + label: 'Save Clipboard', + tooltip: 'Copy received clipboard content to your local clipboard.', + icon: 'dvl-icon dvl-icon-save', + action: () => this.saveRemoteClipboard(), + enabled: () => this.saveRemoteClipboardButtonEnabled, + }); + + // Check if the browser supports reading local clipboard. + if (typeof navigator.clipboard?.readText === 'function') { + this.clipboardActionButtons.push({ + label: 'Send Clipboard', + tooltip: 'Send your local clipboard content to the remote server.', + icon: 'dvl-icon dvl-icon-send', + action: () => this.sendClipboard(), + enabled: () => true, + }); + } + } + + async saveRemoteClipboard(): Promise { + try { + await this.remoteClient.saveRemoteClipboardData(); + this.webClientSuccess('Clipboard content has been copied to your clipboard!'); + this.saveRemoteClipboardButtonEnabled = false; + } catch (err) { + this.handleSessionError(err); + } + } + + async sendClipboard(): Promise { + try { + await this.remoteClient.sendClipboardData(); + this.webClientSuccess('Clipboard content has been sent to the remote server!'); + } catch (err) { + this.handleSessionError(err); + } + } + + protected handleSessionError(err: unknown): void { + if (this.isIronError(err)) { + this.webClientError(err.backtrace()); + } else { + this.webClientError(`${err}`); + } + } + + protected isIronError(error: unknown): error is IronError { + return ( + typeof error === 'object' && + error !== null && + typeof (error as IronError).backtrace === 'function' && + typeof (error as IronError).kind === 'function' + ); + } + + protected getIronErrorMessageTitle(error: IronError): string { + //For translation 'UnknownError' + //For translation 'ConnectionErrorPleaseVerifyYourConnectionSettings' + //For translation 'AccessDenied' + const errorKind: IronErrorKind = error.kind().valueOf(); + switch (errorKind) { + case IronErrorKind.General: + return 'Unknown Error'; + case IronErrorKind.WrongPassword: + case IronErrorKind.LogonFailure: + return 'Connection error: Please verify your connection settings.'; + case IronErrorKind.AccessDenied: + return 'Access denied'; + default: + return 'Connection error: Please verify your connection settings.'; + } + } + + protected initializeStatus(): void { + this.currentStatus = { + id: this.webSessionId, + isInitialized: true, + isDisabled: false, + isDisabledByUser: false, + }; + } + + protected disableComponentStatus(): void { + // Pre-connect close/error paths can run before initializeStatus(). + // Backfill id so dynamic tab removal receives a valid session id. + this.currentStatus.id ??= this.webSessionId; + this.currentStatus.isDisabled = true; + if (!this.currentStatus.id) { + return; + } + this.currentStatus.terminationMessage = this.sessionTerminationMessage; + this.componentStatus.emit(this.currentStatus); + } + + /** Removes the 'ready' event listener added by the subclass. */ + protected removeRemoteClientListener(): void { + if (this.unlistenRemoteClient) { + this.unlistenRemoteClient(); + this.unlistenRemoteClient = null; + } + } + + protected removeWebClientGuiElement(): void { + this.removeElement.pipe(takeUntil(this.destroyed$)).subscribe({ + next: (): void => { + if (this.ironRemoteDesktopElement?.nativeElement) { + this.ironRemoteDesktopElement.nativeElement.remove(); + } + }, + error: (err): void => { + console.error('Error while removing element:', err); + }, + }); + } + + protected initiateRemoteClientListener(): void { + this.remoteClientEventListener = (event: Event) => this.readyRemoteClientEventListener(event); + this.unlistenRemoteClient = this.renderer.listen( + this.ironRemoteDesktopElement.nativeElement, + 'ready', + this.remoteClientEventListener, + ); + } + + protected startTerminationProcess(): void { + this.sendTerminateSessionCmd(); + this.currentStatus.isDisabledByUser = true; + this.disableComponentStatus(); + } + + sendTerminateSessionCmd(): void { + if (!this.currentStatus.isInitialized || !this.remoteClient) { + return; + } + this.currentStatus.isInitialized = false; + this.remoteClient.shutdown(); + } + + scaleTo(scale: ScreenScale): void { + this.remoteClient.setScale(scale.valueOf()); + } + + setKeyboardUnicodeMode(useUnicode: boolean): void { + this.remoteClient.setKeyboardUnicodeMode(useUnicode); + } + + toggleCursorKind(): void { + if (this.cursorOverrideActive) { + this.remoteClient.setCursorStyleOverride(null); + } else { + this.remoteClient.setCursorStyleOverride('url("assets/images/crosshair.png") 7 7, default'); + } + + this.cursorOverrideActive = !this.cursorOverrideActive; + } + + protected readyRemoteClientEventListener(event: Event): void { + const customEvent = event as CustomEvent; + this.remoteClient = customEvent.detail.irgUserInteraction; + + this.remoteClient.setEnableAutoClipboard(this.isAutoClipboardMode(this.formData?.autoClipboard)); + + // Register callbacks for events. + this.remoteClient.onWarningCallback((data: string) => { + this.webClientWarning(data); + }); + this.remoteClient.onClipboardRemoteUpdateCallback(() => { + this.saveRemoteClipboardButtonEnabled = true; + }); + + this.startConnectionProcess(); + } + + protected handleSessionStarted(): void { + this.loading = false; + this.remoteClient.setVisibility(true); + void this.webSessionService.updateWebSessionIcon(this.webSessionId, this.webSessionIcon); + this.webClientConnectionSuccess(); + this.initializeStatus(); + } + + protected handleSessionTerminatedWithError(error: unknown): void { + if (this.isIronError(error)) { + this.sessionTerminationMessage = { + summary: this.getIronErrorMessageTitle(error), + detail: error.backtrace(), + severity: 'error', + }; + } else { + this.sessionTerminationMessage = { + summary: 'Unexpected error occurred', + detail: `${error}`, + severity: 'error', + }; + } + + void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_WARNING_ICON); + + this.handleSessionTerminated(); + } + + protected handleError(error: string): void { + this.loading = false; + this.sessionTerminationMessage = { + summary: 'Unexpected error occurred', + detail: error, + severity: 'error', + }; + + this.disableComponentStatus(); + } + + protected handleSessionTerminatedGracefully(sessionTerminationInfo: SessionTerminationInfo): void { + this.sessionTerminationMessage = { + summary: 'Session terminated gracefully', + detail: sessionTerminationInfo.reason(), + severity: 'success', + }; + + this.handleSessionTerminated(); + } + + protected handleSessionTerminated(): void { + this.loading = false; + if (document.fullscreenElement) { + this.exitFullScreen(); + } + + this.disableComponentStatus(); + super.webClientConnectionClosed(); + } +} diff --git a/webapp/apps/gateway-ui/src/client/app/shared/bases/terminal-web-client-base.component.ts b/webapp/apps/gateway-ui/src/client/app/shared/bases/terminal-web-client-base.component.ts new file mode 100644 index 000000000..727b9b265 --- /dev/null +++ b/webapp/apps/gateway-ui/src/client/app/shared/bases/terminal-web-client-base.component.ts @@ -0,0 +1,142 @@ +import { Directive, EventEmitter, OnDestroy, Output } from '@angular/core'; +import { DVL_WARNING_ICON } from '@gateway/app.constants'; +import { GatewayAlertMessageService } from '@shared/components/gateway-alert-message/gateway-alert-message.service'; +import { ComponentStatus } from '@shared/models/component-status.model'; +import { ToastMessageOptions } from 'primeng/api'; +import { Subject } from 'rxjs'; +import { AnalyticService } from '../services/analytic.service'; +import { WebSessionService } from '../services/web-session.service'; +import { WebClientBaseComponent } from './base-web-client.component'; + +@Directive() +export abstract class TerminalWebClientBaseComponent extends WebClientBaseComponent implements OnDestroy { + rightToolbarButtons = [ + { label: 'Close Session', icon: 'dvl-icon dvl-icon-close', action: () => this.startTerminationProcess() }, + ]; + + // ── Terminal session state ────────────────────────────────────────────── + clientError: string; + protected removeElement = new Subject(); + + /** Unsubscribe function for the lifecycle onStatusChange handler. */ + protected unsubscribeTerminalEvent: (() => void) | null = null; + /** Unsubscribe function for the connecting-message onStatusChange handler. */ + protected unsubscribeConnectionListener: (() => void) | null = null; + + @Output() readonly componentStatus = new EventEmitter(); + @Output() readonly sizeChange = new EventEmitter(); + + protected constructor( + protected override gatewayAlertMessageService: GatewayAlertMessageService, + protected webSessionService: WebSessionService, + protected override analyticService: AnalyticService, + ) { + super(gatewayAlertMessageService, analyticService); + } + + ngOnDestroy(): void { + this.removeRemoteTerminalListener(); + this.removeWebClientGuiElement(); + if (this.currentStatus.isInitialized && !this.currentStatus.isDisabled) { + this.startTerminationProcess(); + } + // Break the reference cycle: component → remoteTerminal → onStatusChange closures → component + this.teardownTerminalClient(); + super.ngOnDestroy(); + } + + /** Icon shown on the session tab when the terminal connects successfully. */ + protected abstract getSuccessIcon(): string; + protected abstract startTerminationProcess(): void; + abstract sendTerminateSessionCmd(): void; + protected abstract removeWebClientGuiElement(): void; + /** + * Null out the terminal client reference to break the reference cycle that + * would prevent GC of the component tree after the session is closed. + * Called by the base ngOnDestroy after the terminal has been closed. + */ + protected abstract teardownTerminalClient(): void; + + /** Cancels all onStatusChange subscriptions registered by the subclass. */ + protected removeRemoteTerminalListener(): void { + this.unsubscribeTerminalEvent?.(); + this.unsubscribeTerminalEvent = null; + this.unsubscribeConnectionListener?.(); + this.unsubscribeConnectionListener = null; + } + + protected initializeStatus(): void { + this.currentStatus = { + id: this.webSessionId, + isInitialized: true, + isDisabled: false, + isDisabledByUser: false, + }; + } + + protected disableComponentStatus(): void { + if (this.currentStatus.isDisabled) { + return; + } + + // Pre-connect close/error paths can run before initializeStatus(). + // Backfill id so dynamic tab removal receives a valid session id. + this.currentStatus.id ??= this.webSessionId; + this.currentStatus.isDisabled = true; + if (!this.currentStatus.id) { + return; + } + this.componentStatus.emit(this.currentStatus); + } + + /** Called when the terminal GUI signals a successful connection. */ + protected handleClientConnectStarted(): void { + this.loading = false; + void this.webSessionService.updateWebSessionIcon(this.webSessionId, this.getSuccessIcon()); + this.webClientConnectionSuccess(); + } + + /** + * Called when the session ends for any reason (graceful close, error, timeout). + * Exits fullscreen, updates the tab icon, stores the error message, and + * disables the component status. + * + * @param errorMessage Human-readable message stored in `clientError`. + * @param isError When true, the tab icon is updated to the warning icon; + * when false, the success icon is used instead. + */ + protected handleSessionEnded(errorMessage: string, isError = true): void { + if (document.fullscreenElement) { + document.exitFullscreen().catch((err) => { + console.error(`Error attempting to exit fullscreen: ${err}`); + }); + } + + this.clientError = errorMessage; + void this.webSessionService.updateWebSessionIcon( + this.webSessionId, + isError ? DVL_WARNING_ICON : this.getSuccessIcon(), + ); + this.currentStatus.terminationMessage = { + summary: errorMessage, + severity: isError ? 'error' : 'success', + } as ToastMessageOptions; + this.disableComponentStatus(); + this.webClientConnectionClosed(); + } + + /** + * Called when a connection-phase error occurs (e.g. fetch-token failure). + * Sets `clientError`, logs to console, and disables the component. + */ + protected handleConnectionError(message: string): void { + this.clientError = message; + console.error(message); + void this.webSessionService.updateWebSessionIcon(this.webSessionId, DVL_WARNING_ICON); + this.currentStatus.terminationMessage = { + summary: message, + severity: 'error', + } as ToastMessageOptions; + this.disableComponentStatus(); + } +} diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.scss b/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.scss index 8b1378917..0c950035d 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.scss +++ b/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.scss @@ -1 +1,7 @@ - +:host { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + background: #F9F9F9 url('../../../../../assets/images/session_background.png'); +} diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.ts b/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.ts index 20e928283..8cf034dfc 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.ts +++ b/webapp/apps/gateway-ui/src/client/app/shared/components/dynamic-tab/dynamic-tab.component.ts @@ -2,15 +2,19 @@ import { AfterViewInit, ChangeDetectorRef, Component, + ComponentRef, ElementRef, EventEmitter, Input, + OnChanges, OnDestroy, Output, + SimpleChanges, ViewChild, ViewContainerRef, } from '@angular/core'; +import { WebClientFormComponent } from '@gateway/modules/web-client/form/web-client-form.component'; import { WebClientSshComponent } from '@gateway/modules/web-client/ssh/web-client-ssh.component'; import { WebClientTelnetComponent } from '@gateway/modules/web-client/telnet/web-client-telnet.component'; import { BaseComponent } from '@shared/bases/base.component'; @@ -20,6 +24,7 @@ import { SessionType, WebSession } from '@shared/models/web-session.model'; import { ComponentListenerService } from '@shared/services/component-listener.service'; import { DynamicComponentService } from '@shared/services/dynamic-component.service'; import { WebSessionService } from '@shared/services/web-session.service'; +import { Subject } from 'rxjs'; import { distinctUntilChanged, take, takeUntil } from 'rxjs/operators'; @Component({ @@ -28,13 +33,24 @@ import { distinctUntilChanged, take, takeUntil } from 'rxjs/operators'; templateUrl: './dynamic-tab.component.html', styleUrls: ['./dynamic-tab.component.scss'], }) -export class DynamicTabComponent extends BaseComponent implements AfterViewInit, OnDestroy { +export class DynamicTabComponent + extends BaseComponent + implements OnChanges, AfterViewInit, OnDestroy +{ @Input() webSessionTab: WebSession; @Input() sessionsContainerElement: ElementRef; @ViewChild('dynamicComponentContainer', { read: ViewContainerRef }) container: ViewContainerRef; @Output() componentRefSizeChange: EventEmitter = new EventEmitter(); + /** Non-null while the reconnect form occupies the container slot. */ + private formRef: ComponentRef | null = null; + + /** Cancelled and re-created on every initializeDynamicComponent() call to + * ensure subscriptions to the previous component instance are torn down + * before subscribing to the replacement. */ + private componentInstanceDestroyed$ = new Subject(); + constructor( private cdr: ChangeDetectorRef, private webSessionService: WebSessionService, @@ -49,9 +65,27 @@ export class DynamicTabComponent extends BaseComponent im } ngOnDestroy(): void { + this.componentInstanceDestroyed$.next(); + this.componentInstanceDestroyed$.complete(); super.ngOnDestroy(); } + ngOnChanges(changes: SimpleChanges): void { + if (!changes.webSessionTab || !this.container) { + return; + } + + // If the reconnect form was showing, the user submitted it and a new + // session arrived via updateSession(). Clear the tracking ref so + // initializeDynamicComponent() proceeds to create the protocol component + // (which replaces the form inside createComponent's create-before-remove). + if (this.formRef) { + this.formRef = null; + } + + this.initializeDynamicComponent(); + } + initializeDynamicComponent(): void { if (!this.webSessionTab?.component) { return; @@ -61,6 +95,10 @@ export class DynamicTabComponent extends BaseComponent im return; } + // Cancel any subscriptions bound to the previous component instance before + // creating a replacement (e.g. on reconnect after a disconnect). + this.componentInstanceDestroyed$.next(); + const componentRef = this.dynamicComponentService.createComponent( this.container, this.sessionsContainerElement, @@ -71,14 +109,14 @@ export class DynamicTabComponent extends BaseComponent im if (componentRef.instance instanceof WebClientTelnetComponent) { this.componentListenerService .onTelnetInitialized() - .pipe(takeUntil(this.destroyed$), take(1)) + .pipe(takeUntil(this.componentInstanceDestroyed$), take(1)) .subscribe((event) => { (componentRef.instance as WebComponentReady).webComponentReady(event as CustomEvent, this.webSessionTab.id); }); } else if (componentRef.instance instanceof WebClientSshComponent) { this.componentListenerService .onSshInitialized() - .pipe(takeUntil(this.destroyed$), take(1)) + .pipe(takeUntil(this.componentInstanceDestroyed$), take(1)) .subscribe((event) => { (componentRef.instance as WebComponentReady).webComponentReady(event as CustomEvent, this.webSessionTab.id); }); @@ -88,7 +126,7 @@ export class DynamicTabComponent extends BaseComponent im this.cdr.detectChanges(); componentRef.instance.componentStatus - .pipe(takeUntil(this.destroyed$), distinctUntilChanged()) + .pipe(takeUntil(this.componentInstanceDestroyed$), distinctUntilChanged()) .subscribe((status: ComponentStatus) => { this.webSessionTab.status = status; if (status.isDisabled) { @@ -97,15 +135,46 @@ export class DynamicTabComponent extends BaseComponent im }); componentRef.instance?.sizeChange - ?.pipe(takeUntil(this.destroyed$)) + ?.pipe(takeUntil(this.componentInstanceDestroyed$)) .subscribe(() => this.componentRefSizeChange.emit()); this.webSessionTab.componentRef = componentRef; } + /** + * Swap the protocol component out and put the reconnect form in its place. + * Creates the form FIRST so the container slot is never empty, then removes + * the old protocol component (mirrors the create-before-remove pattern used + * in DynamicComponentService). + */ + private swapToForm(status: ComponentStatus): void { + const formRef = this.container.createComponent(WebClientFormComponent); + formRef.instance.isFormExists = true; + formRef.instance.webSessionId = this.webSessionTab.id; + formRef.instance.inputFormData = this.webSessionTab.data; + formRef.instance.sessionTerminationMessage = status.terminationMessage; + + // Remove the protocol component after the form is live. + const previousCount = this.container.length - 1; + for (let i = 0; i < previousCount; i++) { + this.container.remove(0); + } + + this.formRef = formRef; + // Clear componentRef so initializeDynamicComponent() will recreate the + // protocol component once the form is submitted. + this.webSessionTab.componentRef = null; + this.cdr.detectChanges(); + } + private onComponentDisabled(status: ComponentStatus): void { if (status.isDisabledByUser) { + // User clicked Disconnect — close the tab entirely. void this.webSessionService.removeSession(status.id); + return; } + + // Connection failed or dropped — swap to the reconnect form. + this.swapToForm(status); } } diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/gateway-alert-message/gateway-alert-message.component.html b/webapp/apps/gateway-ui/src/client/app/shared/components/gateway-alert-message/gateway-alert-message.component.html index ddb7892fe..e6e559ead 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/components/gateway-alert-message/gateway-alert-message.component.html +++ b/webapp/apps/gateway-ui/src/client/app/shared/components/gateway-alert-message/gateway-alert-message.component.html @@ -3,8 +3,12 @@
- -

{{message.summary}}

+ @if (message.icon) { + + } + @if (message.summary) { +

{{message.summary}}

+ }
diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/main-panel/main-panel.component.scss b/webapp/apps/gateway-ui/src/client/app/shared/components/main-panel/main-panel.component.scss index 997912e5b..202286596 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/components/main-panel/main-panel.component.scss +++ b/webapp/apps/gateway-ui/src/client/app/shared/components/main-panel/main-panel.component.scss @@ -1,8 +1,18 @@ +:host { + display: flex; + flex: 1; + min-height: 0; + width: 100%; + flex-direction: column; +} .container { display: flex; + flex-direction: row; + flex: 1; + min-height: 0; + overflow: hidden; + background-color: #ECECEC; justify-content: center; align-items: center; - height: 100vh; - background-color: #ECECEC; -} \ No newline at end of file +} diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/session-toolbar/session-toolbar.component.html b/webapp/apps/gateway-ui/src/client/app/shared/components/session-toolbar/session-toolbar.component.html index 788bdba2b..e54a5ad61 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/components/session-toolbar/session-toolbar.component.html +++ b/webapp/apps/gateway-ui/src/client/app/shared/components/session-toolbar/session-toolbar.component.html @@ -6,9 +6,7 @@ class="session-toolbar" >
- + @for (button of leftButtons; track $index; let last = $last) {
-
+ }
- + @for (button of middleButtons; track $index; let last = $last) {
-
+ }
- + @for (button of middleToggleButtons; track $index; let last = $last) {
-
+ }
- + @for (checkbox of checkboxes; track checkbox.inputId; let last = $last) { {{ checkbox.label }}
-
+ }
- + @for (slider of sliders; track $index; let last = $last) { {{ slider.value }}
-
+ }
- + @for (button of clipboardActionButtons; track $index; let last = $last) {
-
+ }
- + @for (button of rightButtons; track $index; let last = $last) {
-
+ }
diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.html b/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.html index 33e6abff8..1a8e9b9d0 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.html +++ b/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.html @@ -1,11 +1,21 @@
- - - - - + + + + + @for (tab of webSessionTabs; track trackBySessionId($index, tab); let i = $index) { + + } + + + + @for (tab of webSessionTabs; track trackBySessionId($index, tab); let i = $index) { + + + + }
diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.scss b/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.scss index 93c6d8807..bfa51b3c5 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.scss +++ b/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.scss @@ -16,7 +16,30 @@ background: #F9F9F9 url('../../../../../assets/images/session_background.png'); } +:host ::ng-deep .p-tabs { + flex: 1; // fill tabview-container (flex column parent) + min-height: 0; // prevent flex overflow blowout + display: flex; + flex-direction: column; +} + :host ::ng-deep .p-tabs .p-tabpanels { + flex: 1; + min-height: 0; padding: 0; + overflow: auto; + background: unset; + display: flex; + flex-direction: column; } +:host ::ng-deep .p-tabs .p-tabpanel { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.hidden-tablist { + display: none; +} diff --git a/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.ts b/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.ts index 355c8f83d..db6557637 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.ts +++ b/webapp/apps/gateway-ui/src/client/app/shared/components/tab-view/tab-view.component.ts @@ -28,7 +28,21 @@ export class TabViewComponent extends BaseComponent implements OnDestroy, AfterV @ViewChild('sessionsContainer') sessionsContainerRef: ElementRef; webSessionTabs: WebSession[] = []; - currentTabIndex = 0; + + private _currentTabIndex = 0; + + get currentTabIndex(): number { + return this._currentTabIndex; + } + + set currentTabIndex(value: number | string) { + if (typeof value === 'string') { + const parsed = Number.parseInt(value, 10); + this._currentTabIndex = Number.isNaN(parsed) ? 0 : parsed; + } else { + this._currentTabIndex = value; + } + } constructor( private webSessionService: WebSessionService, @@ -71,13 +85,24 @@ export class TabViewComponent extends BaseComponent implements OnDestroy, AfterV this.webSessionService.setWebSessionScreenSize({ width, height }); } - onTabChange(event: unknown): void { - // PrimeNG 20 Tabs onChange event provides the new value in event.value - const value = (event as { value: unknown })?.value; - const newIndex = typeof value === 'number' ? value : Number.parseInt(value as string, 10); + onTabChange(newTabValue: string | number): void { + // PrimeNG 20 Tabs emits the new tab value directly via (valueChange). + const newIndex = + typeof newTabValue === 'number' + ? newTabValue + : Number.isNaN(Number.parseInt(newTabValue, 10)) + ? 0 + : Number.parseInt(newTabValue, 10); this.webSessionService.setWebSessionCurrentIndex(newIndex); } + /** Stable identity for *ngFor — keeps the same DynamicTabComponent alive + * when a session object is replaced (e.g. on reconnect), so Angular's + * style reference count never drops to 0 and `:host` rules stay injected. */ + trackBySessionId(_index: number, tab: WebSession): string { + return tab.id; + } + private changeTabIndex(): void { if (!this.tabView) return; // PrimeNG 20 Tabs uses 'value' signal instead of 'activeIndex' diff --git a/webapp/apps/gateway-ui/src/client/app/shared/interfaces/forms.interfaces.ts b/webapp/apps/gateway-ui/src/client/app/shared/interfaces/forms.interfaces.ts index bce396ced..4b31e97fd 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/interfaces/forms.interfaces.ts +++ b/webapp/apps/gateway-ui/src/client/app/shared/interfaces/forms.interfaces.ts @@ -12,6 +12,8 @@ export interface AutoCompleteInput { hostname: string; } +export type DesktopFormDataInput = RdpFormDataInput | VncFormDataInput | ArdFormDataInput; + export type FormDataUnion = | RdpFormDataInput | VncFormDataInput diff --git a/webapp/apps/gateway-ui/src/client/app/shared/models/component-status.model.ts b/webapp/apps/gateway-ui/src/client/app/shared/models/component-status.model.ts index acf4bb900..27bfc1c95 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/models/component-status.model.ts +++ b/webapp/apps/gateway-ui/src/client/app/shared/models/component-status.model.ts @@ -1,6 +1,10 @@ +import type { ToastMessageOptions } from 'primeng/api'; + export interface ComponentStatus { id: string; isInitialized: boolean; isDisabled?: boolean; isDisabledByUser?: boolean; + /** Error/success message forwarded to the reconnect form when a session ends. */ + terminationMessage?: ToastMessageOptions; } diff --git a/webapp/apps/gateway-ui/src/client/app/shared/models/web-session.model.ts b/webapp/apps/gateway-ui/src/client/app/shared/models/web-session.model.ts index aa65ff88f..b96fa403d 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/models/web-session.model.ts +++ b/webapp/apps/gateway-ui/src/client/app/shared/models/web-session.model.ts @@ -1,11 +1,11 @@ import { ComponentRef, ElementRef, Type } from '@angular/core'; -import { WebClientArdComponent } from '@gateway/modules/web-client/ard/web-client-ard.component'; -import { WebClientFormComponent } from '@gateway/modules/web-client/form/web-client-form.component'; -import { WebClientRdpComponent } from '@gateway/modules/web-client/rdp/web-client-rdp.component'; -import { WebClientSshComponent } from '@gateway/modules/web-client/ssh/web-client-ssh.component'; -import { WebClientTelnetComponent } from '@gateway/modules/web-client/telnet/web-client-telnet.component'; -import { WebClientVncComponent } from '@gateway/modules/web-client/vnc/web-client-vnc.component'; -import { MainPanelComponent } from '@shared/components/main-panel/main-panel.component'; +import type { WebClientArdComponent } from '@gateway/modules/web-client/ard/web-client-ard.component'; +import type { WebClientFormComponent } from '@gateway/modules/web-client/form/web-client-form.component'; +import type { WebClientRdpComponent } from '@gateway/modules/web-client/rdp/web-client-rdp.component'; +import type { WebClientSshComponent } from '@gateway/modules/web-client/ssh/web-client-ssh.component'; +import type { WebClientTelnetComponent } from '@gateway/modules/web-client/telnet/web-client-telnet.component'; +import type { WebClientVncComponent } from '@gateway/modules/web-client/vnc/web-client-vnc.component'; +import type { MainPanelComponent } from '@shared/components/main-panel/main-panel.component'; import { ComponentStatus } from '@shared/models/component-status.model'; import { DesktopSize } from '@shared/models/desktop-size'; import { v4 as uuidv4 } from 'uuid'; diff --git a/webapp/apps/gateway-ui/src/client/app/shared/services/dynamic-component.service.ts b/webapp/apps/gateway-ui/src/client/app/shared/services/dynamic-component.service.ts index b353e7c19..4bfac14d3 100644 --- a/webapp/apps/gateway-ui/src/client/app/shared/services/dynamic-component.service.ts +++ b/webapp/apps/gateway-ui/src/client/app/shared/services/dynamic-component.service.ts @@ -12,7 +12,11 @@ export class DynamicComponentService { sessionsContainerRef: ElementRef, webSession?: WebSession, ) { - container.clear(); + // Create the new component FIRST so that Angular's style reference count + // goes 1→2 rather than 1→0→1. Removing the old view(s) after creation + // keeps the count at 1 the whole time — the