diff --git a/static/skywire-manager-src/src/app/app.module.ts b/static/skywire-manager-src/src/app/app.module.ts index 99ac3922bc..43958695a5 100644 --- a/static/skywire-manager-src/src/app/app.module.ts +++ b/static/skywire-manager-src/src/app/app.module.ts @@ -100,6 +100,10 @@ import { SelectColumnComponent } from './components/layout/select-column/select- import { SelectOptionComponent } from './components/layout/select-option/select-option.component'; import { SelectPageComponent } from './components/layout/paginator/select-page/select-page.component'; import { TerminalComponent } from './components/pages/node/actions/terminal/terminal.component'; +import { SkysocksSettingsComponent } from './components/pages/node/apps/node-apps/skysocks-settings/skysocks-settings.component'; +import { + SkysocksClientSettingsComponent +} from './components/pages/node/apps/node-apps/skysocks-client-settings/skysocks-client-settings.component'; const globalRippleConfig: RippleGlobalOptions = { disabled: true, @@ -179,6 +183,8 @@ const globalRippleConfig: RippleGlobalOptions = { SelectOptionComponent, SelectPageComponent, TerminalComponent, + SkysocksSettingsComponent, + SkysocksClientSettingsComponent, ], entryComponents: [ ConfigurationComponent, @@ -207,6 +213,8 @@ const globalRippleConfig: RippleGlobalOptions = { SelectOptionComponent, SelectPageComponent, TerminalComponent, + SkysocksSettingsComponent, + SkysocksClientSettingsComponent, ], imports: [ BrowserModule, diff --git a/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/node-apps-list/node-apps-list.component.html b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/node-apps-list/node-apps-list.component.html index 34d42063b5..3950e0b235 100644 --- a/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/node-apps-list/node-apps-list.component.html +++ b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/node-apps-list/node-apps-list.component.html @@ -100,6 +100,15 @@ + + + + + diff --git a/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-client-settings/skysocks-client-settings.component.scss b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-client-settings/skysocks-client-settings.component.scss new file mode 100644 index 0000000000..7249a87014 --- /dev/null +++ b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-client-settings/skysocks-client-settings.component.scss @@ -0,0 +1,27 @@ +@import "variables"; + +form { + margin-top: 15px; +} + +.no-history-text { + margin-top: 20px; + margin-bottom: 2px; + text-align: center; + color: $black; + + mat-icon { + position: relative; + top: 2px; + } +} + +.top-history-margin { + width: 100%; + height: 15px; +} + +.history-button-content { + text-align: left; + padding: 5px 0px; +} diff --git a/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-client-settings/skysocks-client-settings.component.spec.ts b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-client-settings/skysocks-client-settings.component.spec.ts new file mode 100644 index 0000000000..97045170d8 --- /dev/null +++ b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-client-settings/skysocks-client-settings.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SkysocksClientSettingsComponent } from './skysocks-client-settings.component'; + +describe('SkysocksClientSettingsComponent', () => { + let component: SkysocksClientSettingsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SkysocksClientSettingsComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SkysocksClientSettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-client-settings/skysocks-client-settings.component.ts b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-client-settings/skysocks-client-settings.component.ts new file mode 100644 index 0000000000..09384021d6 --- /dev/null +++ b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-client-settings/skysocks-client-settings.component.ts @@ -0,0 +1,150 @@ +import { Component, OnInit, ViewChild, OnDestroy, ElementRef, Inject } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatDialogRef, MatDialog, MatDialogConfig, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { Subscription } from 'rxjs'; + +import { ButtonComponent } from '../../../../../layout/button/button.component'; +import { NodeComponent } from '../../../node.component'; +import { SnackbarService } from '../../../../../../services/snackbar.service'; +import { AppConfig } from 'src/app/app.config'; +import { processServiceError } from 'src/app/utils/errors'; +import { OperationError } from 'src/app/utils/operation-error'; +import { AppsService } from 'src/app/services/apps.service'; +import GeneralUtils from 'src/app/utils/generalUtils'; + +/** + * Modal window used for configuring the Skysocks-client app. + */ +@Component({ + selector: 'app-skysocks-client-settings', + templateUrl: './skysocks-client-settings.component.html', + styleUrls: ['./skysocks-client-settings.component.scss'] +}) +export class SkysocksClientSettingsComponent implements OnInit, OnDestroy { + // Key for saving the history in persistent storage. + private readonly historyStorageKey = 'SkysocksClientHistory'; + // Max elements the history can contain. + readonly maxHistoryElements = 10; + + @ViewChild('button', { static: false }) button: ButtonComponent; + @ViewChild('firstInput', { static: false }) firstInput: ElementRef; + form: FormGroup; + // Entries to show on the history. + history: string[]; + + // If the operation in being currently made. + private working = false; + // Last public key set to be sent to the backend. + private lastPublicKey: string; + private operationSubscription: Subscription; + + /** + * Opens the modal window. Please use this function instead of opening the window "by hand". + */ + public static openDialog(dialog: MatDialog, appName: string): MatDialogRef { + const config = new MatDialogConfig(); + config.data = appName; + config.autoFocus = false; + config.width = AppConfig.mediumModalWidth; + + return dialog.open(SkysocksClientSettingsComponent, config); + } + + constructor( + @Inject(MAT_DIALOG_DATA) private data: string, + private dialogRef: MatDialogRef, + private appsService: AppsService, + private formBuilder: FormBuilder, + private snackbarService: SnackbarService, + private dialog: MatDialog, + ) { } + + ngOnInit() { + // Get the history. + const retrievedHistory = localStorage.getItem(this.historyStorageKey); + if (retrievedHistory) { + this.history = JSON.parse(retrievedHistory); + } else { + this.history = []; + } + + this.form = this.formBuilder.group({ + 'pk': ['', Validators.compose([ + Validators.required, + Validators.minLength(66), + Validators.maxLength(66), + Validators.pattern('^[0-9a-fA-F]+$')]) + ], + }); + + setTimeout(() => (this.firstInput.nativeElement as HTMLElement).focus()); + } + + ngOnDestroy() { + if (this.operationSubscription) { + this.operationSubscription.unsubscribe(); + } + } + + /** + * Saves the settings. + */ + saveChanges(publicKey: string = null) { + if ((!this.form.valid && !publicKey) || this.working) { + return; + } + + this.lastPublicKey = publicKey ? publicKey : this.form.get('pk').value; + + // Ask for confirmation. + const confirmationMsg = 'apps.skysocks-client-settings.change-key-confirmation'; + const confirmationDialog = GeneralUtils.createConfirmationDialog(this.dialog, confirmationMsg); + confirmationDialog.componentInstance.operationAccepted.subscribe(() => { + confirmationDialog.close(); + this.continueSavingChanges(); + }); + } + + private continueSavingChanges() { + this.button.showLoading(); + this.working = true; + + this.operationSubscription = this.appsService.changeAppSettings( + // The node pk is obtained from the currently openned node page. + NodeComponent.getCurrentNodeKey(), + this.data, + { pk: this.lastPublicKey }, + ).subscribe({ + next: this.onSuccess.bind(this), + error: this.onError.bind(this) + }); + } + + private onSuccess() { + // Remove any repeated entry from the history. + this.history = this.history.filter(value => value !== this.lastPublicKey); + + // Save the new public key on the history. + this.history = [this.lastPublicKey].concat(this.history); + if (this.history.length > this.maxHistoryElements) { + const itemsToRemove = this.history.length - this.maxHistoryElements; + this.history.splice(this.history.length - itemsToRemove, itemsToRemove); + } + + const dataToSave = JSON.stringify(this.history); + localStorage.setItem(this.historyStorageKey, dataToSave); + + // Close the window. + NodeComponent.refreshCurrentDisplayedData(); + this.snackbarService.showDone('apps.skysocks-client-settings.changes-made'); + this.dialogRef.close(); + } + + private onError(err: OperationError) { + this.working = false; + this.button.showError(); + err = processServiceError(err); + + this.snackbarService.showError(err); + } +} diff --git a/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-settings/skysocks-settings.component.html b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-settings/skysocks-settings.component.html new file mode 100644 index 0000000000..fc17c6f831 --- /dev/null +++ b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-settings/skysocks-settings.component.html @@ -0,0 +1,40 @@ + +
+ + + + + + + {{ 'apps.skysocks-settings.passwords-not-match' | translate }} + + + + + {{ 'apps.skysocks-settings.save' | translate }} + +
+
diff --git a/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-settings/skysocks-settings.component.scss b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-settings/skysocks-settings.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-settings/skysocks-settings.component.spec.ts b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-settings/skysocks-settings.component.spec.ts new file mode 100644 index 0000000000..bb9b45d334 --- /dev/null +++ b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-settings/skysocks-settings.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SkysocksSettingsComponent } from './skysocks-settings.component'; + +describe('SkysocksSettingsComponent', () => { + let component: SkysocksSettingsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SkysocksSettingsComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SkysocksSettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-settings/skysocks-settings.component.ts b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-settings/skysocks-settings.component.ts new file mode 100644 index 0000000000..a1cab20e05 --- /dev/null +++ b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skysocks-settings/skysocks-settings.component.ts @@ -0,0 +1,127 @@ +import { Component, OnInit, ViewChild, OnDestroy, ElementRef, Inject } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { MatDialogRef, MatDialog, MatDialogConfig, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { Subscription } from 'rxjs'; + +import { ButtonComponent } from '../../../../../layout/button/button.component'; +import { NodeComponent } from '../../../node.component'; +import { SnackbarService } from '../../../../../../services/snackbar.service'; +import { AppConfig } from 'src/app/app.config'; +import { processServiceError } from 'src/app/utils/errors'; +import { OperationError } from 'src/app/utils/operation-error'; +import { AppsService } from 'src/app/services/apps.service'; +import GeneralUtils from 'src/app/utils/generalUtils'; + +/** + * Modal window used for configuring the Skysocks app. + */ +@Component({ + selector: 'app-skysocks-settings', + templateUrl: './skysocks-settings.component.html', + styleUrls: ['./skysocks-settings.component.scss'] +}) +export class SkysocksSettingsComponent implements OnInit, OnDestroy { + @ViewChild('button', { static: false }) button: ButtonComponent; + @ViewChild('firstInput', { static: false }) firstInput: ElementRef; + form: FormGroup; + + private operationSubscription: Subscription; + private formSubscription: Subscription; + + /** + * Opens the modal window. Please use this function instead of opening the window "by hand". + */ + public static openDialog(dialog: MatDialog, appName: string): MatDialogRef { + const config = new MatDialogConfig(); + config.data = appName; + config.autoFocus = false; + config.width = AppConfig.mediumModalWidth; + + return dialog.open(SkysocksSettingsComponent, config); + } + + constructor( + @Inject(MAT_DIALOG_DATA) private data: string, + private appsService: AppsService, + private formBuilder: FormBuilder, + private dialogRef: MatDialogRef, + private snackbarService: SnackbarService, + private dialog: MatDialog, + ) { } + + ngOnInit() { + this.form = this.formBuilder.group({ + 'password': [''], + 'passwordConfirmation': ['', this.validatePasswords.bind(this)], + }); + + this.formSubscription = this.form.get('password').valueChanges.subscribe(() => { + this.form.get('passwordConfirmation').updateValueAndValidity(); + }); + + setTimeout(() => (this.firstInput.nativeElement as HTMLElement).focus()); + } + + ngOnDestroy() { + this.formSubscription.unsubscribe(); + if (this.operationSubscription) { + this.operationSubscription.unsubscribe(); + } + } + + /** + * Saves the settings. + */ + saveChanges() { + if (!this.form.valid || this.button.disabled) { + return; + } + + // Ask for confirmation. + + const confirmationMsg = this.form.get('password').value ? + 'apps.skysocks-settings.change-passowrd-confirmation' : 'apps.skysocks-settings.remove-passowrd-confirmation'; + + const confirmationDialog = GeneralUtils.createConfirmationDialog(this.dialog, confirmationMsg); + confirmationDialog.componentInstance.operationAccepted.subscribe(() => { + confirmationDialog.close(); + this.continueSavingChanges(); + }); + } + + private continueSavingChanges() { + this.button.showLoading(); + + this.operationSubscription = this.appsService.changeAppSettings( + // The node pk is obtained from the currently openned node page. + NodeComponent.getCurrentNodeKey(), + this.data, + { passcode: this.form.get('password').value }, + ).subscribe({ + next: this.onSuccess.bind(this), + error: this.onError.bind(this) + }); + } + + private onSuccess() { + NodeComponent.refreshCurrentDisplayedData(); + this.snackbarService.showDone('apps.skysocks-settings.changes-made'); + this.dialogRef.close(); + } + + private onError(err: OperationError) { + this.button.showError(); + err = processServiceError(err); + + this.snackbarService.showError(err); + } + + private validatePasswords() { + if (this.form) { + return this.form.get('password').value !== this.form.get('passwordConfirmation').value + ? { invalid: true } : null; + } else { + return null; + } + } +} diff --git a/static/skywire-manager-src/src/app/services/apps.service.ts b/static/skywire-manager-src/src/app/services/apps.service.ts index 3120b3e75a..d8ce6eda87 100644 --- a/static/skywire-manager-src/src/app/services/apps.service.ts +++ b/static/skywire-manager-src/src/app/services/apps.service.ts @@ -28,9 +28,14 @@ export class AppsService { * Changes the autostart setting of an app. */ changeAppAutostart(nodeKey: string, appName: string, autostart: boolean) { - return this.apiService.put(`visors/${nodeKey}/apps/${encodeURIComponent(appName)}`, - { autostart: autostart } - ); + return this.changeAppSettings(nodeKey, appName, { autostart: autostart }); + } + + /** + * Changes the autostart setting of an app. + */ + changeAppSettings(nodeKey: string, appName: string, settings: object) { + return this.apiService.put(`visors/${nodeKey}/apps/${encodeURIComponent(appName)}`, settings); } /** diff --git a/static/skywire-manager-src/src/assets/i18n/en.json b/static/skywire-manager-src/src/assets/i18n/en.json index c6c1e891b0..a830e0ff7d 100644 --- a/static/skywire-manager-src/src/assets/i18n/en.json +++ b/static/skywire-manager-src/src/assets/i18n/en.json @@ -256,9 +256,33 @@ "autostart-disabled": "Autostart disabled", "autostart-enabled": "Autostart enabled" }, + "skysocks-settings": { + "title": "Skysocks Settings", + "new-password": "New password (Leave empty to remove the password)", + "repeat-password": "Repeat password", + "passwords-not-match": "Passwords do not match.", + "save": "Save", + "remove-passowrd-confirmation": "You left the password field empty. Are you sure you want to remove the password?", + "change-passowrd-confirmation": "Are you sure you want to change the password?", + "changes-made": "The changes have been made." + }, + "skysocks-client-settings": { + "title": "Skysocks-Client Settings", + "remote-visor-tab": "Remote Visor", + "history-tab": "History", + "public-key": "Remote visor public key", + "remote-key-length-error": "The public key must be 66 characters long.", + "remote-key-chars-error": "The public key must only contain hexadecimal characters.", + "save": "Save", + "change-key-confirmation": "Are you sure you want to change the remote visor public key?", + "changes-made": "The changes have been made.", + "no-history": "This tab will show the last {{ number }} public keys used." + }, "stop-app": "Stop", "start-app": "Start", "view-logs": "View logs", + "settings": "Settings", + "error": "An error has occured and it was not possible to perform the operation.", "stop-confirmation": "Are you sure you want to stop the app?", "stop-selected-confirmation": "Are you sure you want to stop the selected apps?", "disable-autostart-confirmation": "Are you sure you want to disable autostart for the app?",