From 178876e165a4e5d1110615bc6521516c409df3a0 Mon Sep 17 00:00:00 2001 From: Krista House Date: Mon, 13 Apr 2026 13:08:45 -0400 Subject: [PATCH 1/3] refactor(webapp) update libraries, modernize Angular flow, create bases --- webapp/apps/gateway-ui/package.json | 36 +- .../src/client/app/app.component.html | 8 +- .../modules/base/menu/app-menu.component.html | 62 +- .../menu-group-list-item.component.html | 41 +- .../menu-list-active-sessions.component.html | 13 +- .../menu-list-item.component.html | 4 +- .../app/modules/login/login.component.html | 43 +- .../ard/web-client-ard.component.html | 20 +- .../ard/web-client-ard.component.scss | 85 +- .../ard/web-client-ard.component.ts | 92 +- .../ard/ard-form.component.html | 18 +- .../rdp/rdp-form.component.html | 18 +- .../vnc/vnc-form.component.html | 43 +- .../file-control/file-control.component.html | 11 +- .../kdc-url-control.component.html | 9 +- .../password-control.component.html | 9 +- .../screen-size-control.component.html | 74 +- .../username-control.component.html | 9 +- .../form/web-client-form.component.html | 80 +- .../form/web-client-form.component.scss | 47 +- .../form/web-client-form.component.ts | 23 +- .../net-scan/net-scan.component.html | 76 +- .../net-scan/net-scan.component.scss | 1 + .../rdp/web-client-rdp.component.html | 20 +- .../rdp/web-client-rdp.component.scss | 90 +- .../rdp/web-client-rdp.component.ts | 101 +- .../ssh/web-client-ssh.component.html | 20 +- .../ssh/web-client-ssh.component.scss | 29 +- .../ssh/web-client-ssh.component.ts | 99 +- .../telnet/web-client-telnet.component.html | 21 +- .../telnet/web-client-telnet.component.scss | 29 +- .../telnet/web-client-telnet.component.ts | 115 +- .../vnc/web-client-vnc.component.html | 20 +- .../vnc/web-client-vnc.component.scss | 85 +- .../vnc/web-client-vnc.component.ts | 93 +- .../shared/bases/base-web-client.component.ts | 14 +- .../desktop-web-client-base.component.ts | 102 + .../terminal-web-client-base.component.ts | 104 + .../dynamic-tab/dynamic-tab.component.scss | 8 +- .../dynamic-tab/dynamic-tab.component.ts | 59 +- .../gateway-alert-message.component.html | 8 +- .../main-panel/main-panel.component.scss | 16 +- .../session-toolbar.component.html | 42 +- .../tab-view/tab-view.component.html | 24 +- .../tab-view/tab-view.component.scss | 23 + .../components/tab-view/tab-view.component.ts | 23 +- .../shared/models/component-status.model.ts | 4 + .../services/dynamic-component.service.ts | 14 +- .../shared/services/web-session.service.ts | 9 + webapp/pnpm-lock.yaml | 1707 ++++++++--------- .../tailwind.config.js | 2 +- .../recording-player-tester/vite.config.ts | 2 +- 52 files changed, 1819 insertions(+), 1886 deletions(-) create mode 100644 webapp/apps/gateway-ui/src/client/app/shared/bases/desktop-web-client-base.component.ts create mode 100644 webapp/apps/gateway-ui/src/client/app/shared/bases/terminal-web-client-base.component.ts 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..b3ad8f47a 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.tabIndex) { - +} 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..1a4d75d34 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..9d4f7d7fb 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..f307ab32d 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,7 +33,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 { @@ -25,62 +45,15 @@ place-items: center; height: 100vh; width: 100vw; + min-width: 0; + min-height: 0; } -.session-toolbar { - display: flex; - 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; -} - -.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; -} - -.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); +.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-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..17b1bb993 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 @@ -11,8 +11,8 @@ import { Renderer2, ViewChild, } from '@angular/core'; -import { IronError, SessionTerminationInfo, UserInteraction } from '@devolutions/iron-remote-desktop'; -import { WebClientBaseComponent } from '@shared/bases/base-web-client.component'; +import { IronError, SessionTerminationInfo } from '@devolutions/iron-remote-desktop'; +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'; @@ -21,7 +21,6 @@ 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'; @@ -47,7 +46,7 @@ enum UserIronRdpErrorKind { styleUrls: ['web-client-ard.component.scss'], providers: [MessageService], }) -export class WebClientArdComponent extends WebClientBaseComponent implements OnInit, AfterViewInit, OnDestroy { +export class WebClientArdComponent extends DesktopWebClientBaseComponent implements OnInit, AfterViewInit, OnDestroy { @Input() webSessionId: string; @Output() componentStatus: EventEmitter = new EventEmitter(); @Output() sizeChange: EventEmitter = new EventEmitter(); @@ -58,12 +57,9 @@ export class WebClientArdComponent extends WebClientBaseComponent implements OnI backendRef = Backend; formData: ArdFormDataInput; - sessionTerminationMessage: ToastMessageOptions; isFullScreenMode = false; cursorOverrideActive = false; - saveRemoteClipboardButtonEnabled = false; - middleToolbarButtons = [ { label: 'Full Screen', @@ -110,48 +106,8 @@ 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, @@ -171,7 +127,6 @@ export class WebClientArdComponent extends WebClientBaseComponent implements OnI ngOnInit(): void { this.removeWebClientGuiElement(); - this.setupClipboardHandling(); } ngAfterViewInit(): void { @@ -207,27 +162,6 @@ export class WebClientArdComponent extends WebClientBaseComponent implements OnI 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); @@ -266,6 +200,7 @@ export class WebClientArdComponent extends WebClientBaseComponent implements OnI private disableComponentStatus(): void { this.currentStatus.isDisabled = true; + this.currentStatus.terminationMessage = this.sessionTerminationMessage; this.componentStatus.emit(this.currentStatus); } @@ -332,7 +267,7 @@ export class WebClientArdComponent extends WebClientBaseComponent implements OnI const customEvent = event as CustomEvent; this.remoteClient = customEvent.detail.irgUserInteraction; - if (this.formData.autoClipboard !== true) { + if (this.formData?.autoClipboard !== true) { this.remoteClient.setEnableAutoClipboard(false); } @@ -479,23 +414,6 @@ export class WebClientArdComponent extends WebClientBaseComponent implements OnI 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', 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..81f49f194 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 msg.summary) { + + {{ 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..16fa2faa4 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..8636acb20 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,23 +1,39 @@ :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 { @@ -25,62 +41,16 @@ place-items: center; height: 100vh; width: 100vw; + min-width: 0; + min-height: 0; } -.session-toolbar { - display: flex; - 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; -} -.session-toolbar ::ng-deep .p-button { - border: none; - border-radius: 4px; - background-color: transparent; -} -.session-toolbar ::ng-deep .p-button:hover { - background: #0068C31A; +.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-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/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..661ce53da 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 @@ -11,9 +11,9 @@ import { Renderer2, ViewChild, } from '@angular/core'; -import { IronError, SessionTerminationInfo, UserInteraction } from '@devolutions/iron-remote-desktop'; +import { IronError, SessionTerminationInfo } from '@devolutions/iron-remote-desktop'; 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'; @@ -25,7 +25,6 @@ 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'; @@ -36,7 +35,6 @@ import { AnalyticService, ProtocolString } from '@gateway/shared/services/analyt 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, @@ -53,7 +51,7 @@ enum UserIronRdpErrorKind { styleUrls: ['web-client-rdp.component.scss'], providers: [MessageService], }) -export class WebClientRdpComponent extends WebClientBaseComponent implements OnInit, AfterViewInit, OnDestroy { +export class WebClientRdpComponent extends DesktopWebClientBaseComponent implements OnInit, AfterViewInit, OnDestroy { @Input() webSessionId: string; @Input() sessionsContainerElement: ElementRef; @Output() componentStatus: EventEmitter = new EventEmitter(); @@ -65,7 +63,6 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI backendRef = Backend; formData: RdpFormDataInput; - sessionTerminationMessage: ToastMessageOptions; isFullScreenMode = false; useUnicodeKeyboard = false; cursorOverrideActive = false; @@ -73,8 +70,6 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI dynamicResizeSupported = false; dynamicResizeEnabled = false; - saveRemoteClipboardButtonEnabled = false; - rdpConfig: string | null; leftToolbarButtons = [ @@ -145,55 +140,8 @@ 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; @@ -219,7 +167,7 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI ngOnInit(): void { this.removeWebClientGuiElement(); - this.setupClipboardHandling(); + this.setupClipboardHandling(this.formData?.autoClipboard); this.setRdpConfig(); // Navigate to /session route to clear query params. this.navigation.navigateToNewSession().then(noop); @@ -275,27 +223,6 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI 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); @@ -354,6 +281,7 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI private disableComponentStatus(): void { this.currentStatus.isDisabled = true; + this.currentStatus.terminationMessage = this.sessionTerminationMessage; this.componentStatus.emit(this.currentStatus); } @@ -421,7 +349,7 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI // If the user connects to the session via URL. if (this.formData === undefined) { - const autoClipboardMode = new UAParser().getEngine().name === 'Blink'; + const autoClipboardMode = this.isAutoClipboardMode(); this.remoteClient.setEnableAutoClipboard(autoClipboardMode); } else if (this.formData.autoClipboard !== true) { this.remoteClient.setEnableAutoClipboard(false); @@ -645,23 +573,6 @@ export class WebClientRdpComponent extends WebClientBaseComponent implements OnI 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', 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..4995e114c 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,19 @@ -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 { 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 +24,28 @@ 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, 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; 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 { @@ -81,7 +76,7 @@ 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(); } @@ -106,30 +101,16 @@ export class WebClientSshComponent extends WebClientBaseComponent implements Web }); } + protected getSuccessIcon(): string { + return DVL_SSH_ICON; + } + 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); - } - private startConnectionProcess(): void { if (!this.remoteTerminal) { return; @@ -137,7 +118,6 @@ export class WebClientSshComponent extends WebClientBaseComponent implements Web this.remoteTerminal.onStatusChange((v) => { if (v === TerminalConnectionStatus.connected) { - // connected only indicates connection to Gateway is successful this.remoteTerminal.writeToTerminal('connecting... \r\n'); } }); @@ -149,7 +129,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; }), ) @@ -188,6 +168,7 @@ export class WebClientSshComponent extends WebClientBaseComponent implements Web const gatewayAddress: string = gatewayHttpAddress.toString().replace('http', 'ws'); const privateKey: string | null = formData.extraData?.sshPrivateKey || null; const privateKeyPassphrase: string = formData.passphrase || null; + const connectionParameters: SshConnectionParameters = { host: extractedData.hostname, username: username, @@ -210,11 +191,12 @@ export class WebClientSshComponent extends WebClientBaseComponent implements Web 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 +204,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..a3f821e35 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,12 @@ -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 { 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 +16,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 +27,29 @@ 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 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; 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 { @@ -86,7 +81,7 @@ 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 { @@ -110,26 +105,12 @@ 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, - }; + protected getSuccessIcon(): string { + return DVL_TELNET_ICON; } - private disableComponentStatus(): void { - if (this.currentStatus.isDisabled) { - return; - } - - this.currentStatus.isDisabled = true; - this.componentStatus.emit(this.currentStatus); + private removeRemoteTerminalListener(): void { + this.unsubscribeTerminalEvent?.(); } private startConnectionProcess(): void { @@ -139,7 +120,6 @@ export class WebClientTelnetComponent extends WebClientBaseComponent implements this.remoteTerminal.onStatusChange((v) => { if (v === TerminalConnectionStatus.connected) { - // connected only indicates connection to Gateway is successful this.remoteTerminal.writeToTerminal('connecting... \r\n'); } }); @@ -151,7 +131,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 +139,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() { @@ -201,16 +187,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 +204,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..e50a9ddd2 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..895996d25 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; } +.sessionVncContainer { + 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,7 +33,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 { @@ -25,62 +45,15 @@ place-items: center; height: 100vh; width: 100vw; + min-width: 0; + min-height: 0; } -.session-toolbar { - display: flex; - 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; -} - -.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; -} - -.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); +.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-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..33b15ed39 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 @@ -11,8 +11,8 @@ import { Renderer2, ViewChild, } from '@angular/core'; -import { IronError, SessionTerminationInfo, UserInteraction } from '@devolutions/iron-remote-desktop'; -import { WebClientBaseComponent } from '@shared/bases/base-web-client.component'; +import { IronError, SessionTerminationInfo } from '@devolutions/iron-remote-desktop'; +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'; @@ -23,7 +23,6 @@ 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'; @@ -62,7 +61,7 @@ enum UserIronRdpErrorKind { styleUrls: ['web-client-vnc.component.scss'], providers: [MessageService], }) -export class WebClientVncComponent extends WebClientBaseComponent implements OnInit, AfterViewInit, OnDestroy { +export class WebClientVncComponent extends DesktopWebClientBaseComponent implements OnInit, AfterViewInit, OnDestroy { @Input() webSessionId: string; @Input() sessionsContainerElement: ElementRef; @Output() componentStatus: EventEmitter = new EventEmitter(); @@ -74,15 +73,12 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI backendRef = Backend; formData: VncFormDataInput; - sessionTerminationMessage: ToastMessageOptions; isFullScreenMode = false; cursorOverrideActive = false; dynamicResizeSupported = false; dynamicResizeEnabled = false; - saveRemoteClipboardButtonEnabled = false; - leftToolbarButtons = [ { label: 'Start', @@ -152,47 +148,7 @@ 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; @@ -217,7 +173,7 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI ngOnInit(): void { this.removeWebClientGuiElement(); - this.setupClipboardHandling(); + this.setupClipboardHandling(this.formData?.autoClipboard); } ngAfterViewInit(): void { @@ -265,27 +221,6 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI 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); @@ -348,6 +283,7 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI private disableComponentStatus(): void { this.currentStatus.isDisabled = true; + this.currentStatus.terminationMessage = this.sessionTerminationMessage; this.componentStatus.emit(this.currentStatus); } @@ -414,7 +350,7 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI const customEvent = event as CustomEvent; this.remoteClient = customEvent.detail.irgUserInteraction; - if (this.formData.autoClipboard !== true) { + if (this.formData?.autoClipboard !== true) { this.remoteClient.setEnableAutoClipboard(false); } @@ -614,23 +550,6 @@ export class WebClientVncComponent extends WebClientBaseComponent implements OnI 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', 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..a84175081 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, @@ -32,6 +33,8 @@ export abstract class WebClientBaseComponent extends BaseSessionComponent { abstract removeWebClientGuiElement(): void; + // ── Session lifecycle helpers ────────────────────────────────────────────── + //For translation 'ConnectionSuccessful protected webClientConnectionSuccess(message = 'Connection successful'): void { this.hideSpinnerOnly = true; @@ -59,5 +62,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..ebba72a40 --- /dev/null +++ b/webapp/apps/gateway-ui/src/client/app/shared/bases/desktop-web-client-base.component.ts @@ -0,0 +1,102 @@ +import { Directive } from '@angular/core'; +import { IronError, UserInteraction } from '@devolutions/iron-remote-desktop'; +import { GatewayAlertMessageService } from '@shared/components/gateway-alert-message/gateway-alert-message.service'; +import { UAParser } from 'ua-parser-js'; +import { AnalyticService } from '../services/analytic.service'; +import { WebClientBaseComponent } from './base-web-client.component'; + +@Directive() +export abstract class DesktopWebClientBaseComponent extends WebClientBaseComponent { + // ── Clipboard state — shared by desktop protocol components only ────────── + protected remoteClient: UserInteraction; + saveRemoteClipboardButtonEnabled = false; + + clipboardActionButtons: { + label: string; + tooltip: string; + icon: string; + action: () => Promise; + enabled: () => boolean; + }[] = []; + + protected constructor( + protected override gatewayAlertMessageService: GatewayAlertMessageService, + protected override analyticService: AnalyticService, + ) { + super(gatewayAlertMessageService, analyticService); + } + + /** 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 (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, + }); + } + } + + 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' + ); + } +} 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..85ae0e773 --- /dev/null +++ b/webapp/apps/gateway-ui/src/client/app/shared/bases/terminal-web-client-base.component.ts @@ -0,0 +1,104 @@ +import { Directive, EventEmitter, 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 { + rightToolbarButtons = [ + { label: 'Close Session', icon: 'dvl-icon dvl-icon-close', action: () => this.startTerminationProcess() }, + ]; + + // ── Terminal session state ────────────────────────────────────────────── + clientError: string; + protected removeElement = new Subject(); + + @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); + } + + /** Icon shown on the session tab when the terminal connects successfully. */ + protected abstract getSuccessIcon(): string; + protected abstract startTerminationProcess(): void; + + protected initializeStatus(): void { + this.currentStatus = { + id: this.webSessionId, + isInitialized: true, + isDisabled: false, + isDisabledByUser: false, + }; + } + + protected disableComponentStatus(): void { + if (this.currentStatus.isDisabled) { + return; + } + + this.currentStatus.isDisabled = true; + 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..20f4066e1 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'; @@ -28,13 +32,19 @@ 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; + constructor( private cdr: ChangeDetectorRef, private webSessionService: WebSessionService, @@ -52,6 +62,22 @@ export class DynamicTabComponent extends BaseComponent im 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; @@ -103,9 +129,40 @@ export class DynamicTabComponent extends BaseComponent im 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..fb794d53d 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,16 @@ 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) { + this._currentTabIndex = typeof value === 'string' ? Number.parseInt(value, 10) : value; + } constructor( private webSessionService: WebSessionService, @@ -71,13 +80,19 @@ export class TabViewComponent extends BaseComponent implements OnDestroy, AfterV this.webSessionService.setWebSessionScreenSize({ width, height }); } - onTabChange(event: unknown): void { + onTabChange(event: string | number): 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); + const newIndex = typeof event === 'number' ? event : Number.parseInt(event as string, 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/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/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