diff --git a/web/projects/start-tunnel/src/app/app.ts b/web/projects/start-tunnel/src/app/app.ts index 3d982cb57..5f2258b23 100644 --- a/web/projects/start-tunnel/src/app/app.ts +++ b/web/projects/start-tunnel/src/app/app.ts @@ -3,6 +3,7 @@ import { Component, inject } from '@angular/core' import { RouterOutlet } from '@angular/router' import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { PatchService } from './services/patch.service' +import { UpdateService } from './services/update.service' @Component({ selector: 'app-root', @@ -24,4 +25,6 @@ export class App { readonly subscription = inject(PatchService) .pipe(takeUntilDestroyed()) .subscribe() + + readonly updates = inject(UpdateService) } diff --git a/web/projects/start-tunnel/src/app/routes/home/components/nav.ts b/web/projects/start-tunnel/src/app/routes/home/components/nav.ts index 25f21db84..0473d0b63 100644 --- a/web/projects/start-tunnel/src/app/routes/home/components/nav.ts +++ b/web/projects/start-tunnel/src/app/routes/home/components/nav.ts @@ -2,9 +2,11 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { Router, RouterLink, RouterLinkActive } from '@angular/router' import { ErrorService, LoadingService } from '@start9labs/shared' import { TuiButton } from '@taiga-ui/core' +import { TuiBadgeNotification } from '@taiga-ui/kit' import { ApiService } from 'src/app/services/api/api.service' import { AuthService } from 'src/app/services/auth.service' import { SidebarService } from 'src/app/services/sidebar.service' +import { UpdateService } from 'src/app/services/update.service' @Component({ selector: 'nav', @@ -22,6 +24,19 @@ import { SidebarService } from 'src/app/services/sidebar.service' {{ route.name }} } + + Settings + @if (update.hasUpdate()) { + + } + + + + `, + providers: [ + tuiValidationErrorsProvider({ + required: 'This field is required', + minlength: 'Password must be at least 8 characters', + maxlength: 'Password cannot exceed 64 characters', + match: 'Passwords do not match', + }), + ], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + AsyncPipe, + ReactiveFormsModule, + TuiAutoFocus, + TuiButton, + TuiButtonLoading, + TuiError, + TuiFieldErrorPipe, + TuiForm, + TuiTextfield, + TuiValidator, + ], +}) +export class ChangePasswordDialog { + private readonly context = injectContext>() + private readonly api = inject(ApiService) + private readonly alerts = inject(TuiAlertService) + private readonly errorService = inject(ErrorService) + + protected readonly loading = signal(false) + protected readonly form = inject(NonNullableFormBuilder).group({ + password: [ + '', + [Validators.required, Validators.minLength(8), Validators.maxLength(64)], + ], + confirm: [ + '', + [Validators.required, Validators.minLength(8), Validators.maxLength(64)], + ], + }) + + protected readonly matchValidator = toSignal( + this.form.controls.password.valueChanges.pipe( + map( + (password): ValidatorFn => + ({ value }) => + value === password ? null : { match: true }, + ), + ), + { initialValue: Validators.nullValidator }, + ) + + protected readonly formInvalid = toSignal( + this.form.statusChanges.pipe(map(() => this.form.invalid)), + { initialValue: this.form.invalid }, + ) + + protected async onSave() { + this.loading.set(true) + + try { + await this.api.setPassword({ password: this.form.getRawValue().password }) + this.alerts + .open('Password changed', { label: 'Success', appearance: 'positive' }) + .subscribe() + this.context.$implicit.complete() + } catch (e: any) { + this.errorService.handleError(e) + } finally { + this.loading.set(false) + } + } +} + +export const CHANGE_PASSWORD = new PolymorpheusComponent(ChangePasswordDialog) diff --git a/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts b/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts index 4339af6a9..e2360e52f 100644 --- a/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts +++ b/web/projects/start-tunnel/src/app/routes/home/routes/settings/index.ts @@ -1,142 +1,101 @@ -import { AsyncPipe } from '@angular/common' import { ChangeDetectionStrategy, Component, inject, signal, } from '@angular/core' -import { toSignal } from '@angular/core/rxjs-interop' -import { - NonNullableFormBuilder, - ReactiveFormsModule, - ValidatorFn, - Validators, -} from '@angular/forms' import { ErrorService } from '@start9labs/shared' -import { tuiMarkControlAsTouchedAndValidate, TuiValidator } from '@taiga-ui/cdk' -import { - TuiAlertService, - TuiAppearance, - TuiButton, - TuiError, - TuiTextfield, - TuiTitle, -} from '@taiga-ui/core' -import { - TuiButtonLoading, - TuiFieldErrorPipe, - tuiValidationErrorsProvider, -} from '@taiga-ui/kit' -import { TuiCard, TuiForm, TuiHeader } from '@taiga-ui/layout' -import { map } from 'rxjs' -import { ApiService } from 'src/app/services/api/api.service' +import { TuiAppearance, TuiButton, TuiTitle } from '@taiga-ui/core' +import { TuiDialogService } from '@taiga-ui/experimental' +import { TuiBadge, TuiButtonLoading } from '@taiga-ui/kit' +import { TuiCard, TuiCell } from '@taiga-ui/layout' +import { UpdateService } from 'src/app/services/update.service' + +import { CHANGE_PASSWORD } from './change-password' @Component({ template: ` -
-
-

- Settings - Change password -

-
- - - - - - - - - - -
- -
- +
+
+ + + Version + @if (update.hasUpdate()) { + + Update Available + + } + + Current: {{ update.installed() ?? '—' }} + + @if (update.hasUpdate()) { + + } @else { + + } +
+
+ + Change password + + +
+
`, - providers: [ - tuiValidationErrorsProvider({ - required: 'This field is required', - minlength: 'Password must be at least 8 characters', - maxlength: 'Password cannot exceed 64 characters', - match: 'Passwords do not match', - }), - ], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - ReactiveFormsModule, - AsyncPipe, TuiCard, - TuiForm, - TuiHeader, + TuiCell, TuiTitle, - TuiTextfield, - TuiError, - TuiFieldErrorPipe, TuiButton, TuiButtonLoading, - TuiValidator, + TuiBadge, TuiAppearance, ], }) export default class Settings { - private readonly api = inject(ApiService) - private readonly alerts = inject(TuiAlertService) + private readonly dialogs = inject(TuiDialogService) private readonly errorService = inject(ErrorService) - protected readonly loading = signal(false) - protected readonly form = inject(NonNullableFormBuilder).group({ - password: [ - '', - [Validators.required, Validators.minLength(8), Validators.maxLength(64)], - ], - confirm: [ - '', - [Validators.required, Validators.minLength(8), Validators.maxLength(64)], - ], - }) + protected readonly update = inject(UpdateService) + protected readonly checking = signal(false) + protected readonly applying = signal(false) - protected readonly matchValidator = toSignal( - this.form.controls.password.valueChanges.pipe( - map( - (password): ValidatorFn => - ({ value }) => - value === password ? null : { match: true }, - ), - ), - { initialValue: Validators.nullValidator }, - ) + protected onChangePassword(): void { + this.dialogs.open(CHANGE_PASSWORD, { label: 'Change Password' }).subscribe() + } - protected async onSave() { - if (this.form.invalid) { - tuiMarkControlAsTouchedAndValidate(this.form) - - return - } - - this.loading.set(true) + protected async onCheckUpdate() { + this.checking.set(true) try { - await this.api.setPassword({ password: this.form.getRawValue().password }) - this.alerts - .open('Password changed', { label: 'Success', appearance: 'positive' }) - .subscribe() - this.form.reset() + await this.update.checkUpdate() } catch (e: any) { this.errorService.handleError(e) } finally { - this.loading.set(false) + this.checking.set(false) + } + } + + protected async onApply() { + this.applying.set(true) + + try { + await this.update.applyUpdate() + } catch (e: any) { + this.errorService.handleError(e) + } finally { + this.applying.set(false) } } } diff --git a/web/projects/start-tunnel/src/app/services/update.service.ts b/web/projects/start-tunnel/src/app/services/update.service.ts new file mode 100644 index 000000000..861b5c057 --- /dev/null +++ b/web/projects/start-tunnel/src/app/services/update.service.ts @@ -0,0 +1,120 @@ +import { Component, computed, inject, Injectable, signal } from '@angular/core' +import { toObservable } from '@angular/core/rxjs-interop' +import { ErrorService } from '@start9labs/shared' +import { TuiLoader } from '@taiga-ui/core' +import { TuiDialogService } from '@taiga-ui/experimental' +import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { + catchError, + EMPTY, + filter, + from, + interval, + Subscription, + switchMap, + takeWhile, +} from 'rxjs' +import { ApiService, TunnelUpdateResult } from './api/api.service' +import { AuthService } from './auth.service' + +@Component({ + template: '', + imports: [TuiLoader], +}) +class UpdatingDialog { + protected readonly text = 'StartTunnel is updating...' +} + +@Injectable({ + providedIn: 'root', +}) +export class UpdateService { + private readonly api = inject(ApiService) + private readonly auth = inject(AuthService) + private readonly dialogs = inject(TuiDialogService) + private readonly errorService = inject(ErrorService) + + readonly result = signal(null) + readonly hasUpdate = computed( + () => this.result()?.status === 'update-available', + ) + readonly installed = computed(() => this.result()?.installed ?? null) + readonly candidate = computed(() => this.result()?.candidate ?? null) + + private polling = false + private updatingDialog: Subscription | null = null + + constructor() { + toObservable(this.auth.authenticated) + .pipe(filter(Boolean)) + .subscribe(() => this.initCheck()) + } + + async checkUpdate(): Promise { + const result = await this.api.checkUpdate() + this.setResult(result) + } + + async applyUpdate(): Promise { + const result = await this.api.applyUpdate() + this.setResult(result) + } + + private setResult(result: TunnelUpdateResult): void { + this.result.set(result) + + if (result.status === 'updating') { + this.showUpdatingDialog() + this.startPolling() + } else { + this.hideUpdatingDialog() + } + } + + private async initCheck(): Promise { + try { + await this.checkUpdate() + } catch (e: any) { + this.errorService.handleError(e) + } + } + + private startPolling(): void { + if (this.polling) return + this.polling = true + + interval(5000) + .pipe( + switchMap(() => + from(this.api.checkUpdate()).pipe(catchError(() => EMPTY)), + ), + takeWhile(result => result.status === 'updating', true), + ) + .subscribe({ + next: result => this.result.set(result), + complete: () => { + this.polling = false + this.hideUpdatingDialog() + }, + error: () => { + this.polling = false + this.hideUpdatingDialog() + }, + }) + } + + private showUpdatingDialog(): void { + if (this.updatingDialog) return + this.updatingDialog = this.dialogs + .open(new PolymorpheusComponent(UpdatingDialog), { + closable: false, + dismissible: false, + }) + .subscribe({ complete: () => (this.updatingDialog = null) }) + } + + private hideUpdatingDialog(): void { + this.updatingDialog?.unsubscribe() + this.updatingDialog = null + } +}