feat(portal): refactor settings (#2536)

* feat(portal): refactor settings

* chore: refactor

* small updates

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
Alex Inkin
2023-12-08 23:19:33 +04:00
committed by GitHub
parent 7324a4973f
commit ad13b5eb4e
54 changed files with 7004 additions and 3904 deletions

7414
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -46,14 +46,14 @@
"@start9labs/argon2": "^0.1.0", "@start9labs/argon2": "^0.1.0",
"@start9labs/emver": "^0.1.5", "@start9labs/emver": "^0.1.5",
"@start9labs/start-sdk": "0.4.0-rev0.lib0.rc8.beta2", "@start9labs/start-sdk": "0.4.0-rev0.lib0.rc8.beta2",
"@taiga-ui/addon-charts": "3.53.0", "@taiga-ui/addon-charts": "3.56.0",
"@taiga-ui/addon-mobile": "3.53.0", "@taiga-ui/addon-mobile": "3.56.0",
"@taiga-ui/cdk": "3.53.0", "@taiga-ui/cdk": "3.56.0",
"@taiga-ui/core": "3.53.0", "@taiga-ui/core": "3.56.0",
"@taiga-ui/experimental": "3.53.0", "@taiga-ui/experimental": "3.56.0",
"@taiga-ui/icons": "3.53.0", "@taiga-ui/icons": "3.56.0",
"@taiga-ui/kit": "3.53.0", "@taiga-ui/kit": "3.56.0",
"@taiga-ui/styles": "3.53.0", "@taiga-ui/styles": "3.56.0",
"@tinkoff/ng-dompurify": "4.0.0", "@tinkoff/ng-dompurify": "4.0.0",
"@tinkoff/ng-event-plugins": "3.1.0", "@tinkoff/ng-event-plugins": "3.1.0",
"ansi-to-html": "^0.7.2", "ansi-to-html": "^0.7.2",

View File

@@ -1,23 +1,10 @@
@import '@taiga-ui/core/styles/taiga-ui-local'; @import '@taiga-ui/core/styles/taiga-ui-local';
/* stylelint-disable order/order */ /* stylelint-disable order/order */
[tuiWrapper][data-appearance='secondary-warning'] { [tuiAppearance][data-appearance='secondary-warning'] {
background: var(--tui-warning-bg); background: var(--tui-warning-bg);
color: var(--tui-warning-fill); color: var(--tui-warning-fill);
&[data-mode='onDark'] {
background: var(--tui-warning-bg-night);
color: var(--tui-warning-fill-night);
@include wrapper-hover {
background: var(--tui-warning-bg-night-hover);
}
@include wrapper-active {
background: var(--tui-warning-bg-night-hover);
}
}
@include wrapper-hover { @include wrapper-hover {
background: var(--tui-warning-bg-hover); background: var(--tui-warning-bg-hover);
} }
@@ -27,18 +14,23 @@
} }
} }
[tuiWrapper][data-appearance='success'] { [tuiAppearance][data-appearance='icon-success'] {
color: var(--tui-success-fill); color: var(--tui-success-fill);
} }
[tuiWrapper][data-appearance='warning'] { [tuiAppearance][data-appearance='icon-warning'] {
color: var(--tui-warning-fill); color: var(--tui-warning-fill);
} }
[tuiWrapper][data-appearance='error'] { [tuiAppearance][data-appearance='icon-error'] {
color: var(--tui-error-fill); color: var(--tui-error-fill);
} }
[tuiAppearance][data-appearance='flat'],
[tuiAppearance][data-appearance='outline'] {
color: var(--tui-text-01);
}
[tuiWrapper][data-appearance='input-file'] { [tuiWrapper][data-appearance='input-file'] {
&:hover, &:hover,
&:active { &:active {

View File

@@ -1,5 +1,6 @@
<tui-root <tui-root
*ngIf="widgetDrawer$ | async as drawer" *ngIf="widgetDrawer$ | async as drawer"
tuiTheme="night"
[tuiMode]="(theme$ | async) === 'Dark' ? 'onDark' : null" [tuiMode]="(theme$ | async) === 'Dark' ? 'onDark' : null"
[style.--widgets-width.px]="drawer.open ? drawer.width : 0" [style.--widgets-width.px]="drawer.open ? drawer.width : 0"
> >

View File

@@ -1,7 +1,7 @@
import { APP_INITIALIZER, Provider } from '@angular/core' import { APP_INITIALIZER, Provider } from '@angular/core'
import { UntypedFormBuilder } from '@angular/forms' import { UntypedFormBuilder } from '@angular/forms'
import { Router, RouteReuseStrategy } from '@angular/router' import { Router, RouteReuseStrategy } from '@angular/router'
import { IonicRouteStrategy, IonNav } from '@ionic/angular' import { IonNav } from '@ionic/angular'
import { TUI_DATE_FORMAT, TUI_DATE_SEPARATOR } from '@taiga-ui/cdk' import { TUI_DATE_FORMAT, TUI_DATE_SEPARATOR } from '@taiga-ui/cdk'
import { import {
tuiNumberFormatProvider, tuiNumberFormatProvider,
@@ -54,10 +54,6 @@ export const APP_PROVIDERS: Provider[] = [
provide: TUI_DATE_TIME_VALUE_TRANSFORMER, provide: TUI_DATE_TIME_VALUE_TRANSFORMER,
useClass: DatetimeTransformerService, useClass: DatetimeTransformerService,
}, },
{
provide: RouteReuseStrategy,
useClass: IonicRouteStrategy,
},
{ {
provide: ApiService, provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService, useClass: useMocks ? MockApiService : LiveApiService,

View File

@@ -0,0 +1,70 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { CopyService, EmverPipesModule } from '@start9labs/shared'
import {
TuiButtonModule,
TuiCellModule,
TuiTitleModule,
} from '@taiga-ui/experimental'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ConfigService } from 'src/app/services/config.service'
@Component({
template: `
<ng-container *ngIf="server$ | async as server">
<div tuiCell>
<div tuiTitle>
<strong>Version</strong>
<div tuiSubtitle>{{ server.version | displayEmver }}</div>
</div>
</div>
<div tuiCell>
<div tuiTitle>
<strong>Git Hash</strong>
<div tuiSubtitle>{{ gitHash }}</div>
</div>
<button
tuiIconButton
appearance="icon"
iconLeft="tuiIconCopy"
(click)="copyService.copy(gitHash)"
>
Copy
</button>
</div>
<div tuiCell>
<div tuiTitle>
<strong>CA fingerprint</strong>
<div tuiSubtitle>{{ server['ca-fingerprint'] }}</div>
</div>
<button
tuiIconButton
appearance="icon"
iconLeft="tuiIconCopy"
(click)="copyService.copy(server['ca-fingerprint'])"
>
Copy
</button>
</div>
</ng-container>
`,
styles: ['[tuiCell] { padding-inline: 0 }'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
EmverPipesModule,
TuiTitleModule,
TuiButtonModule,
TuiCellModule,
],
})
export class AboutComponent {
readonly server$ = inject(PatchDB<DataModel>).watch$('server-info')
readonly copyService = inject(CopyService)
readonly gitHash = inject(ConfigService).gitHash
}
export const ABOUT = new PolymorpheusComponent(AboutComponent)

View File

@@ -1,12 +1,14 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { import {
TuiDataListModule, TuiDataListModule,
TuiDialogService,
TuiHostedDropdownModule, TuiHostedDropdownModule,
TuiSvgModule, TuiSvgModule,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental' import { TuiButtonModule } from '@taiga-ui/experimental'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.service' import { AuthService } from 'src/app/services/auth.service'
import { ABOUT } from './about.component'
@Component({ @Component({
selector: 'header-menu', selector: 'header-menu',
@@ -23,7 +25,7 @@ import { AuthService } from 'src/app/services/auth.service'
<ng-template #content> <ng-template #content>
<tui-data-list> <tui-data-list>
<h3 class="title">StartOS</h3> <h3 class="title">StartOS</h3>
<button tuiOption class="item" (click)="({})"> <button tuiOption class="item" (click)="about()">
<tui-svg src="tuiIconInfo"></tui-svg> <tui-svg src="tuiIconInfo"></tui-svg>
About this server About this server
</button> </button>
@@ -101,6 +103,11 @@ import { AuthService } from 'src/app/services/auth.service'
export class HeaderMenuComponent { export class HeaderMenuComponent {
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly auth = inject(AuthService) private readonly auth = inject(AuthService)
private readonly dialogs = inject(TuiDialogService)
about() {
this.dialogs.open(ABOUT, { label: 'About this server' }).subscribe()
}
logout() { logout() {
this.api.logout({}).catch(e => console.error('Failed to log out', e)) this.api.logout({}).catch(e => console.error('Failed to log out', e))

View File

@@ -33,7 +33,7 @@ import { NotificationService } from '../../services/notification.service'
<button <button
tuiIconButton tuiIconButton
iconLeft="tuiIconCloudLarge" iconLeft="tuiIconCloudLarge"
appearance="success" appearance="icon-success"
[style.margin-left]="'auto'" [style.margin-left]="'auto'"
> >
Connection Connection
@@ -48,7 +48,7 @@ import { NotificationService } from '../../services/notification.service'
<button <button
tuiIconButton tuiIconButton
iconLeft="tuiIconBellLarge" iconLeft="tuiIconBellLarge"
appearance="warning" appearance="icon-warning"
(click)="handleNotificationsClick(unread || 0)" (click)="handleNotificationsClick(unread || 0)"
> >
Notifications Notifications

View File

@@ -12,6 +12,10 @@ export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
icon: 'tuiIconUploadLarge', icon: 'tuiIconUploadLarge',
title: 'Sideload', title: 'Sideload',
}, },
'/portal/system/settings': {
icon: 'tuiIconToolLarge',
title: 'Settings',
},
'/portal/system/snek': { '/portal/system/snek': {
icon: 'assets/img/icon_transparent.png', icon: 'assets/img/icon_transparent.png',
title: 'Snek', title: 'Snek',

View File

@@ -1,6 +1,6 @@
<header appHeader>My server</header> <header appHeader>My server</header>
<nav appNavigation></nav> <nav appNavigation></nav>
<main> <main>
<router-outlet></router-outlet> <router-outlet />
</main> </main>
<app-drawer></app-drawer> <app-drawer />

View File

@@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ActivatedRoute, Router, RouterModule } from '@angular/router' import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import { TuiSvgModule } from '@taiga-ui/core' import { TuiIconModule } from '@taiga-ui/experimental'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs' import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs'
import { import {
@@ -20,10 +20,10 @@ import { NavigationService } from '../../../services/navigation.service'
[routerLink]="getLink(service.manifest.id)" [routerLink]="getLink(service.manifest.id)"
(isActiveChange)="onActive(service, $event)" (isActiveChange)="onActive(service, $event)"
> >
<tui-svg src="tuiIconChevronLeftLarge" /> <tui-icon icon="tuiIconChevronLeft" />
{{ service.manifest.title }} {{ service.manifest.title }}
</a> </a>
<router-outlet></router-outlet> <router-outlet />
`, `,
styles: [ styles: [
` `
@@ -44,7 +44,7 @@ import { NavigationService } from '../../../services/navigation.service'
host: { class: 'g-page' }, host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [CommonModule, RouterModule, TuiSvgModule], imports: [CommonModule, RouterModule, TuiIconModule],
}) })
export class ServiceOutletComponent { export class ServiceOutletComponent {
private readonly patch = inject(PatchDB<DataModel>) private readonly patch = inject(PatchDB<DataModel>)

View File

@@ -0,0 +1,50 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { TuiIconModule, TuiTitleModule } from '@taiga-ui/experimental'
import { SettingBtn } from '../settings.types'
@Component({
selector: 'settings-button',
template: `
<button *ngIf="button.action" class="g-action" (click)="button.action()">
<ng-container *ngTemplateOutlet="template" />
</button>
<a
*ngIf="button.href"
class="g-action"
target="_blank"
rel="noreferrer"
[href]="button.href"
>
<ng-container *ngTemplateOutlet="template" />
</a>
<a
*ngIf="button.routerLink"
class="g-action"
[routerLink]="button.routerLink"
>
<ng-container *ngTemplateOutlet="template" />
</a>
<ng-template #template>
<tui-icon [icon]="button.icon" />
<div tuiTitle [style.flex]="1">
<strong>{{ button.title }}</strong>
<div tuiSubtitle>{{ button.description }}</div>
<ng-content />
</div>
<tui-icon *ngIf="button.routerLink" icon="tuiIconChevronRight" />
<tui-icon *ngIf="button.href" icon="tuiIconExternalLink" />
</ng-template>
`,
styles: [
':host:not(:last-child) { display: block; box-shadow: 0 1px var(--tui-clear); }',
],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiIconModule, TuiTitleModule, RouterLink],
})
export class SettingsButtonComponent {
@Input({ required: true })
button!: SettingBtn
}

View File

@@ -0,0 +1,43 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiNotificationModule } from '@taiga-ui/core'
import {
TuiButtonModule,
TuiCellModule,
TuiTitleModule,
} from '@taiga-ui/experimental'
@Component({
selector: 'settings-http',
template: `
<tui-notification status="warning">
<div tuiCell [style.padding]="0">
<div tuiTitle>
Http detected
<div tuiSubtitle>
<div>
Tor is faster over https.
<a
href="https://docs.start9.com/0.3.5.x/user-manual/trust-ca"
target="_blank"
rel="noreferrer"
>
Download and trust your server's Root CA
</a>
, then switch to https.
</div>
</div>
</div>
<ng-content />
</div>
</tui-notification>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
TuiButtonModule,
TuiCellModule,
TuiNotificationModule,
TuiTitleModule,
],
})
export class SettingsHttpsComponent {}

View File

@@ -0,0 +1,140 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiAlertService, TuiLoaderModule } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { PatchDB } from 'patch-db-client'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ClientStorageService } from 'src/app/services/client-storage.service'
import { SettingsService } from '../settings.service'
import { SettingsSyncComponent } from './sync.component'
import { SettingsHttpsComponent } from './http.component'
import { SettingsButtonComponent } from './button.component'
import { SettingsUpdateComponent } from './update.component'
@Component({
selector: 'settings-menu',
template: `
<ng-container *ngIf="server$ | async as server; else loading">
<settings-sync *ngIf="!server['ntp-synced']" />
<settings-http *ngIf="isTorHttp">
<a
tuiButton
appearance="glass"
iconRight="tuiIconExternalLinkLarge"
target="_self"
size="s"
[href]="'https://' + server.ui.torHostname"
>
Open Https
</a>
</settings-http>
<section *ngFor="let cat of service.settings | keyvalue : asIsOrder">
<h3 class="g-title" (click)="addClick(cat.key)">{{ cat.key }}</h3>
<ng-container *ngFor="let btn of cat.value">
<settings-button [button]="btn">
<div
*ngIf="btn.title === 'Outbound Proxy'"
tuiSubtitle
[style.color]="
!server.network.outboundProxy
? 'var(--tui-warning-fill)'
: 'var(--tui-success-fill)'
"
>
{{
!server.network.outboundProxy
? 'None'
: server.network.outboundProxy === 'primary'
? 'System Primary'
: server.network.outboundProxy.proxyId
}}
</div>
</settings-button>
<settings-update
*ngIf="btn.title === 'About'"
[updated]="server['status-info'].updated"
/>
</ng-container>
</section>
</ng-container>
<ng-template #loading>
<tui-loader
textContent="Connecting to server"
[style.margin-top.rem]="10"
/>
</ng-template>
`,
styles: [
`
:host {
display: flex;
flex-direction: column;
gap: 1rem;
padding-top: 1rem;
}
`,
],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
TuiLoaderModule,
TuiButtonModule,
SettingsSyncComponent,
SettingsHttpsComponent,
SettingsButtonComponent,
SettingsUpdateComponent,
],
})
export class SettingsMenuComponent {
private readonly clientStorageService = inject(ClientStorageService)
private readonly alerts = inject(TuiAlertService)
readonly isTorHttp = inject(ConfigService).isTorHttp()
readonly server$ = inject(PatchDB<DataModel>).watch$('server-info')
readonly service = inject(SettingsService)
manageClicks = 0
powerClicks = 0
addClick(title: string) {
switch (title) {
case 'Security':
this.addSecurityClick()
break
case 'Power':
this.addPowerClick()
break
default:
return
}
}
asIsOrder() {
return 0
}
private addSecurityClick() {
this.manageClicks++
if (this.manageClicks === 5) {
this.manageClicks = 0
this.alerts
.open(
this.clientStorageService.toggleShowDevTools()
? 'Dev tools unlocked'
: 'Dev tools hidden',
)
.subscribe()
}
}
private addPowerClick() {
this.powerClicks++
if (this.powerClicks === 5) {
this.powerClicks = 0
this.clientStorageService.toggleShowDiskRepair()
}
}
}

View File

@@ -0,0 +1,43 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiNotificationModule } from '@taiga-ui/core'
import {
TuiButtonModule,
TuiCellModule,
TuiTitleModule,
} from '@taiga-ui/experimental'
@Component({
selector: 'settings-sync',
template: `
<tui-notification status="warning">
<div tuiCell [style.padding]="0">
<div tuiTitle>
Clock sync failure
<div tuiSubtitle>
This will cause connectivity issues. Refer to the StartOS docs to
resolve the issue.
</div>
</div>
<a
tuiButton
appearance="glass"
iconRight="tuiIconExternalLinkLarge"
href="https://docs.start9.com/0.3.5.x/support/common-issues#clock-sync-failure"
target="_blank"
rel="noreferrer"
>
Open Docs
</a>
</div>
</tui-notification>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
TuiButtonModule,
TuiCellModule,
TuiNotificationModule,
TuiTitleModule,
],
})
export class SettingsSyncComponent {}

View File

@@ -0,0 +1,102 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { CommonModule } from '@angular/common'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiIconModule, TuiTitleModule } from '@taiga-ui/experimental'
import { TuiDialogService } from '@taiga-ui/core'
import { EOSService } from 'src/app/services/eos.service'
import { UPDATE } from '../modals/update.component'
@Component({
selector: 'settings-update',
template: `
<button
class="g-action"
[disabled]="service.updatingOrBackingUp$ | async"
(click)="onClick()"
>
<tui-icon icon="tuiIconDownloadCloudLarge"></tui-icon>
<div tuiTitle>
<strong>Software Update</strong>
<div tuiSubtitle>Get the latest version of StartOS</div>
<div
*ngIf="updated; else notUpdated"
tuiSubtitle
[style.color]="'var(--tui-warning-fill)'"
>
Update Complete. Restart to apply changes
</div>
<ng-template #notUpdated>
<ng-container *ngIf="service.showUpdate$ | async; else check">
<div tuiSubtitle [style.color]="'var(--tui-success-fill)'">
<tui-icon class="small" icon="tuiIconZapLarge" />
Update Available
</div>
</ng-container>
<ng-template #check>
<div tuiSubtitle [style.color]="'var(--tui-info-fill)'">
<tui-icon class="small" icon="tuiIconRotateCwLarge" />
Check for updates
</div>
</ng-template>
</ng-template>
</div>
</button>
`,
styles: [
':host { display: block; box-shadow: 0 1px var(--tui-clear); }',
'.small { width: 1rem; height: 1rem; }',
],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiIconModule, TuiTitleModule],
})
export class SettingsUpdateComponent {
private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
readonly service = inject(EOSService)
@Input()
updated = false
onClick() {
this.service.updateAvailable$.value ? this.update() : this.check()
}
private update() {
this.dialogs.open(UPDATE).subscribe()
}
private async check(): Promise<void> {
const loader = this.loader.open('Checking for updates').subscribe()
try {
await this.service.loadEos()
if (this.service.updateAvailable$.value) {
this.update()
} else {
this.showLatest()
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private showLatest() {
this.dialogs
.open('You are on the latest version of StartOS.', {
label: 'Up to date!',
size: 's',
})
.subscribe()
}
}

View File

@@ -0,0 +1,81 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import {
ErrorService,
LoadingService,
MarkdownPipeModule,
SafeLinksDirective,
} from '@start9labs/shared'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@tinkoff/ng-polymorpheus'
import { TuiAutoFocusModule } from '@taiga-ui/cdk'
import { TuiDialogContext, TuiScrollbarModule } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { EOSService } from 'src/app/services/eos.service'
@Component({
template: `
<h2 style="margin-top: 0">StartOS {{ versions[0].version }}</h2>
<h3 style="color: var(--tui-text-02); font-weight: normal">
Release Notes
</h3>
<tui-scrollbar style="margin-bottom: 24px; max-height: 50vh;">
<ng-container *ngFor="let v of versions">
<h4 class="g-title">
{{ v.version }}
</h4>
<div safeLinks [innerHTML]="v.notes | markdown | dompurify"></div>
</ng-container>
</tui-scrollbar>
<button tuiButton tuiAutoFocus style="float: right;" (click)="update()">
Begin Update
</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
MarkdownPipeModule,
NgDompurifyModule,
SafeLinksDirective,
TuiAutoFocusModule,
TuiButtonModule,
TuiScrollbarModule,
],
})
export class SettingsUpdateModal {
readonly versions = Object.entries(this.eosService.eos?.['release-notes']!)
.sort(([a], [b]) => a.localeCompare(b))
.reverse()
.map(([version, notes]) => ({
version,
notes,
}))
constructor(
@Inject(POLYMORPHEUS_CONTEXT) private readonly context: TuiDialogContext,
private readonly loader: LoadingService,
private readonly errorService: ErrorService,
private readonly embassyApi: ApiService,
private readonly eosService: EOSService,
) {}
async update() {
const loader = this.loader.open('Beginning update...').subscribe()
try {
await this.embassyApi.updateServer()
this.context.$implicit.complete()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}
export const UPDATE = new PolymorpheusComponent(SettingsUpdateModal)

View File

@@ -0,0 +1,152 @@
import { Config } from '@start9labs/start-sdk/lib/config/builder/config'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
import { Variants } from '@start9labs/start-sdk/lib/config/builder/variants'
import { Proxy } from 'src/app/services/patch-db/data-model'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
const auth = Config.of({
username: Value.text({
name: 'Username',
required: { default: null },
}),
password: Value.text({
name: 'Password',
required: { default: null },
masked: true,
}),
})
function getStrategyUnion(proxies: Proxy[]) {
const inboundProxies = proxies
.filter(p => p.type === 'inbound-outbound')
.reduce((prev, curr) => {
return {
[curr.id]: curr.name,
...prev,
}
}, {})
return Value.union(
{
name: 'Networking Strategy',
required: { default: null },
description: `<h5>Local</h5>Select this option if you do not mind exposing your home/business IP address to the Internet. This option requires configuring router settings, which StartOS can do automatically if you have an OpenWRT router
<h5>Proxy</h5>Select this option is you prefer to hide your home/business IP address from the Internet. This option requires running your own Virtual Private Server (VPS) <i>or</i> paying service provider such as Static Wire
`,
},
Variants.of({
local: {
name: 'Local',
spec: Config.of({
ipStrategy: Value.select({
name: 'IP Strategy',
description: `<h5>IPv6 Only (recommended)</h5><b>Requirements</b>:<ol><li>ISP IPv6 support</li><li>OpenWRT (recommended) or Linksys router</li></ol><b>Pros</b>: Ready for IPv6 Internet. Enhanced privacy. Run multiple clearnet servers from the same network
<b>Cons</b>: Interfaces using this domain will only be accessible to people whose ISP supports IPv6
<h5>IPv6 and IPv4</h5><b>Pros</b>: Ready for IPv6 Internet. Accessible by anyone
<b>Cons</b>: Less private, as IPv4 addresses are closely correlated with geographic areas. Cannot run multiple clearnet servers from the same network
<h5>IPv4 Only</h5><b>Pros</b>: Accessible by anyone
<b>Cons</b>: Less private, as IPv4 addresses are closely correlated with geographic areas. Cannot run multiple clearnet servers from the same network
`,
required: { default: 'ipv6' },
values: {
ipv6: 'IPv6 Only',
ipv4: 'IPv4 Only',
dualstack: 'IPv6 and IPv4',
},
}),
}),
},
proxy: {
name: 'Proxy',
spec: Config.of({
proxyStrategy: Value.union(
{
name: 'Proxy Strategy',
required: { default: 'primary' },
description: `<h5>Primary</h5>Use the <i>Primary Inbound</i> proxy from your proxy settings. If you do not have any inbound proxies, no proxy will be used
<h5>Other</h5>Use a specific proxy from your proxy settings
`,
},
Variants.of({
primary: {
name: 'Primary',
spec: Config.of({}),
},
other: {
name: 'Specific',
spec: Config.of({
proxyId: Value.select({
name: 'Select Proxy',
required: { default: null },
values: inboundProxies,
}),
}),
},
}),
),
}),
},
}),
)
}
export async function getStart9ToSpec(proxies: Proxy[]) {
return configBuilderToSpec(
Config.of({
strategy: getStrategyUnion(proxies),
}),
)
}
export async function getCustomSpec(proxies: Proxy[]) {
return configBuilderToSpec(
Config.of({
hostname: Value.text({
name: 'Hostname',
required: { default: null },
placeholder: 'yourdomain.com',
}),
provider: Value.union(
{
name: 'Dynamic DNS Provider',
required: { default: 'start9' },
},
Variants.of({
start9: {
name: 'Start9',
spec: Config.of({}),
},
njalla: {
name: 'Njalla',
spec: auth,
},
duckdns: {
name: 'Duck DNS',
spec: auth,
},
dyn: {
name: 'DynDNS',
spec: auth,
},
easydns: {
name: 'easyDNS',
spec: auth,
},
zoneedit: {
name: 'Zoneedit',
spec: auth,
},
googledomains: {
name: 'Google Domains (IPv4 or IPv6)',
spec: auth,
},
namecheap: {
name: 'Namecheap (IPv4 only)',
spec: auth,
},
}),
),
strategy: getStrategyUnion(proxies),
}),
)
}

View File

@@ -0,0 +1,213 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter, firstValueFrom, map } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { FormContext, FormPage } from 'src/app/apps/ui/modals/form/form.page'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { getCustomSpec } from 'src/app/apps/ui/pages/system/domains/domain.const'
import { getStart9ToSpec } from './constants'
import { DomainsTableComponent } from './table.component'
import { DomainsInfoComponent } from './info.component'
@Component({
template: `
<domains-info />
<ng-container *ngIf="domains$ | async as domains">
<h3 class="g-title">
Start9.to
<button
*ngIf="!domains.start9To.length"
tuiButton
size="xs"
iconLeft="tuiIconPlus"
(click)="claim()"
>
Claim
</button>
</h3>
<table
class="g-table"
[domains]="domains.start9To"
(delete)="delete()"
></table>
<h3 class="g-title">
Custom Domains
<button tuiButton size="xs" iconLeft="tuiIconPlus" (click)="add()">
Add Domain
</button>
</h3>
<table
class="g-table"
[domains]="domains.custom"
(delete)="delete($event.value)"
></table>
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
TuiButtonModule,
DomainsTableComponent,
DomainsInfoComponent,
],
})
export class SettingsDomainsComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly formDialog = inject(FormDialogService)
private readonly patch = inject(PatchDB<DataModel>)
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
readonly domains$ = this.patch.watch$('server-info', 'network').pipe(
map(network => {
const start9ToSubdomain = network.start9ToSubdomain
const start9To = !start9ToSubdomain
? []
: [
{
...start9ToSubdomain,
value: `${start9ToSubdomain.value}.start9.to`,
provider: 'Start9',
},
]
return { start9To, custom: network.domains }
}),
)
delete(hostname?: string) {
this.dialogs
.open(TUI_PROMPT, {
label: 'Confirm',
size: 's',
data: {
content: `Delete ${hostname || 'start9.to'} domain?`,
yes: 'Delete',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.deleteDomain(hostname))
}
async add() {
const proxies = await firstValueFrom(
this.patch.watch$('server-info', 'network', 'proxies'),
)
const options: Partial<TuiDialogOptions<FormContext<any>>> = {
label: 'Custom Domain',
data: {
spec: await getCustomSpec(proxies),
buttons: [
{
text: 'Manage proxies',
link: '/portal/system/settings/proxies',
},
{
text: 'Save',
handler: async value => this.save(value),
},
],
},
}
this.formDialog.open(FormPage, options)
}
async claim() {
const proxies = await firstValueFrom(
this.patch.watch$('server-info', 'network', 'proxies'),
)
const options: Partial<TuiDialogOptions<FormContext<any>>> = {
label: 'start9.to',
data: {
spec: await getStart9ToSpec(proxies),
buttons: [
{
text: 'Manage proxies',
link: '/portal/system/settings/proxies',
},
{
text: 'Save',
handler: async value => this.claimDomain(value),
},
],
},
}
this.formDialog.open(FormPage, options)
}
private getNetworkStrategy(strategy: any) {
const { ipStrategy, proxyStrategy = {} } = strategy.unionValueKey
const { unionSelectKey, unionValueKey = {} } = proxyStrategy
const proxyId = unionSelectKey === 'primary' ? null : unionValueKey.proxyId
return strategy.unionSelectKey === 'local' ? { ipStrategy } : { proxyId }
}
private async deleteDomain(hostname?: string) {
const loader = this.loader.open('Deleting...').subscribe()
try {
if (hostname) {
await this.api.deleteDomain({ hostname })
} else {
await this.api.deleteStart9ToDomain({})
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async claimDomain({ strategy }: any): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
const networkStrategy = this.getNetworkStrategy(strategy)
try {
await this.api.claimStart9ToDomain({ networkStrategy })
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private async save({ provider, strategy, hostname }: any): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
const name = provider.unionSelectKey
try {
await this.api.addDomain({
hostname,
networkStrategy: this.getNetworkStrategy(strategy),
provider: {
name,
username: name === 'start9' ? null : provider.unionValueKey.username,
password: name === 'start9' ? null : provider.unionValueKey.password,
},
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiNotificationModule } from '@taiga-ui/core'
@Component({
selector: 'domains-info',
template: `
<tui-notification>
Adding domains permits accessing your server and services over clearnet.
<a
href="https://docs.start9.com/latest/user-manual/domains"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</tui-notification>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiNotificationModule],
})
export class DomainsInfoComponent {}

View File

@@ -0,0 +1,91 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
inject,
Input,
Output,
} from '@angular/core'
import { TuiDialogService, TuiLinkModule } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { Domain } from 'src/app/services/patch-db/data-model'
@Component({
selector: 'table[domains]',
template: `
<thead>
<tr>
<th>Domain</th>
<th>Added</th>
<th>DDNS Provider</th>
<th>Network Strategy</th>
<th>Used By</th>
<th></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let domain of domains">
<td>{{ domain.value }}</td>
<td>{{ domain.createdAt | date : 'short' }}</td>
<td>{{ domain.provider }}</td>
<td>{{ getStrategy(domain) }}</td>
<td>
<button
*ngIf="domain.usedBy.length as qty; else unused"
tuiLink
(click)="onUsedBy(domain)"
>
Interfaces: {{ qty }}
</button>
<ng-template #unused>N/A</ng-template>
</td>
<td>
<button
tuiIconButton
size="xs"
appearance="icon"
iconLeft="tuiIconTrash2"
[style.display]="'flex'"
(click)="delete.emit(domain)"
>
Delete
</button>
</td>
</tr>
</tbody>
`,
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TuiButtonModule, TuiLinkModule],
})
export class DomainsTableComponent {
private readonly dialogs = inject(TuiDialogService)
@Input()
domains: readonly Domain[] = []
@Output()
readonly delete = new EventEmitter<Domain>()
getStrategy(domain: any) {
return (
domain.networkStrategy.ipStrategy ||
domain.networkStrategy.proxyId ||
'Primary Proxy'
)
}
onUsedBy({ value, usedBy }: Domain) {
const interfaces = usedBy.map(u =>
u.interfaces.map(i => `<li>${u.service.title} - ${i.title}</li>`),
)
this.dialogs
.open(`${value} is currently being used by:<ul>${interfaces}</ul>`, {
label: 'Used by',
size: 's',
})
.subscribe()
}
}

View File

@@ -0,0 +1,134 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import {
FormsModule,
ReactiveFormsModule,
UntypedFormGroup,
} from '@angular/forms'
import { TuiDialogService } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { TuiInputModule } from '@taiga-ui/kit'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import { customSmtp } from '@start9labs/start-sdk/lib/config/configConstants'
import { PatchDB } from 'patch-db-client'
import { switchMap } from 'rxjs'
import { FormModule } from 'src/app/common/form/form.module'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormService } from 'src/app/services/form.service'
import { EmailInfoComponent } from './info.component'
@Component({
template: `
<email-info />
<ng-container *ngIf="form$ | async as form">
<form [formGroup]="form">
<h3 class="g-title">SMTP Credentials</h3>
<form-group
*ngIf="spec | async as resolved"
[spec]="resolved"
></form-group>
<div class="ion-text-right ion-padding-top">
<button
tuiButton
size="m"
[disabled]="form.invalid"
(click)="save(form.value)"
>
Save
</button>
</div>
</form>
<form>
<h3 class="g-title">Test Email</h3>
<tui-input
[(ngModel)]="testAddress"
[ngModelOptions]="{ standalone: true }"
>
Firstname Lastname &lt;email@example.com&gt;
<input tuiTextfield inputmode="email" />
</tui-input>
<div class="ion-text-right ion-padding-top">
<button
tuiButton
appearance="secondary"
size="m"
[disabled]="!testAddress || form.invalid"
(click)="sendTestEmail(form)"
>
Send Test Email
</button>
</div>
</form>
</ng-container>
`,
styles: ['form { margin: auto; max-width: 30rem; }'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
FormModule,
TuiButtonModule,
TuiInputModule,
EmailInfoComponent,
],
})
export class SettingsEmailComponent {
private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly formService = inject(FormService)
private readonly patch = inject(PatchDB<DataModel>)
private readonly api = inject(ApiService)
testAddress = ''
readonly spec: Promise<InputSpec> = configBuilderToSpec(customSmtp)
readonly form$ = this.patch
.watch$('server-info', 'smtp')
.pipe(
switchMap(async value =>
this.formService.createForm(await this.spec, value),
),
)
async save(value: unknown): Promise<void> {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.configureEmail(customSmtp.validator.unsafeCast(value))
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async sendTestEmail(form: UntypedFormGroup) {
const loader = this.loader.open('Sending...').subscribe()
try {
await this.api.testEmail({
to: this.testAddress,
...form.value,
})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
this.dialogs
.open(
`A test email has been sent to ${this.testAddress}.<br /><br /><b>Check your spam folder and mark as not spam</b>`,
{
label: 'Success',
size: 's',
},
)
.subscribe()
}
}

View File

@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiNotificationModule } from '@taiga-ui/core'
@Component({
selector: 'email-info',
template: `
<tui-notification>
Adding SMTP credentials to StartOS enables StartOS and some services to
send you emails.
<a
href="https://docs.start9.com/latest/user-manual/smtp"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</tui-notification>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiNotificationModule],
})
export class EmailInfoComponent {}

View File

@@ -0,0 +1,142 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
TemplateRef,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiAlertService, TuiDialogService } from '@taiga-ui/core'
import { TuiIconModule, TuiTitleModule } from '@taiga-ui/experimental'
import { TUI_PROMPT, TuiCheckboxLabeledModule } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ConfigService } from 'src/app/services/config.service'
import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
template: `
<ng-container *ngIf="server$ | async as server">
<button class="g-action" (click)="reset(tor)">
<tui-icon icon="tuiIconRotateCw" />
<div tuiTitle>
<strong>Reset Tor</strong>
<div tuiSubtitle>
Resetting the Tor daemon on your server may resolve Tor connectivity
issues.
</div>
</div>
</button>
<button class="g-action" (click)="zram(server.zram)">
<tui-icon [icon]="server.zram ? 'tuiIconZapOff' : 'tuiIconZap'" />
<div tuiTitle>
<strong>{{ server.zram ? 'Disable' : 'Enable' }} zram</strong>
<div tuiSubtitle>
Zram creates compressed swap in memory, resulting in faster I/O for
low RAM devices
</div>
</div>
</button>
</ng-container>
<ng-template #tor>
<p *ngIf="isTor">
You are currently connected over Tor. If you reset the Tor daemon, you
will lose connectivity until it comes back online.
</p>
<p *ngIf="!isTor">Reset Tor?</p>
<p>
Optionally wipe state to forcibly acquire new guard nodes. It is
recommended to try without wiping state first.
</p>
<tui-checkbox-labeled size="l" [(ngModel)]="wipe">
Wipe state
</tui-checkbox-labeled>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
FormsModule,
TuiTitleModule,
TuiIconModule,
TuiCheckboxLabeledModule,
],
})
export class SettingsExperimentalComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
private readonly alerts = inject(TuiAlertService)
readonly server$ = inject(PatchDB<DataModel>).watch$('server-info')
readonly isTor = inject(ConfigService).isTor()
wipe = false
reset(content: TemplateRef<any>) {
this.wipe = false
this.dialogs
.open(TUI_PROMPT, {
label: this.isTor ? 'Warning' : 'Confirm',
data: {
content,
yes: 'Reset',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.resetTor(this.wipe))
}
zram(enabled: boolean) {
this.dialogs
.open(TUI_PROMPT, {
label: 'Confirm',
data: {
content: enabled
? 'Are you sure you want to disable zram? It provides significant performance benefits on low RAM devices.'
: 'Enable zram? It will only make a difference on lower RAM devices.',
yes: enabled ? 'Disable' : 'Enable',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.toggleZram(enabled))
}
private async toggleZram(enabled: boolean) {
const loader = this.loader
.open(enabled ? 'Disabling zram...' : 'Enabling zram...')
.subscribe()
try {
await this.api.toggleZram({ enable: !enabled })
this.alerts.open(`Zram ${enabled ? 'disabled' : 'enabled'}`).subscribe()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async resetTor(wipeState: boolean) {
const loader = this.loader.open('Resetting Tor...').subscribe()
try {
await this.api.resetTor({
'wipe-state': wipeState,
reason: 'User triggered',
})
this.alerts.open('Tor reset in progress').subscribe()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,22 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { InterfaceAddressesComponentModule } from 'src/app/common/interface-addresses/interface-addresses.module'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
template: `
<interface-addresses
*ngIf="ui$ | async as ui"
[style.max-width.rem]="50"
[addressInfo]="ui"
[isUi]="true"
></interface-addresses>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, InterfaceAddressesComponentModule],
})
export class SettingsInterfacesComponent {
readonly ui$ = inject(PatchDB<DataModel>).watch$('server-info', 'ui')
}

View File

@@ -0,0 +1,34 @@
import { Config } from '@start9labs/start-sdk/lib/config/builder/config'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
import { TuiDialogOptions } from '@taiga-ui/core'
import { TuiPromptData } from '@taiga-ui/kit'
export const DELETE_OPTIONS: Partial<TuiDialogOptions<TuiPromptData>> = {
label: 'Confirm',
size: 's',
data: {
content: 'Delete proxy? This action cannot be undone.',
yes: 'Delete',
no: 'Cancel',
},
}
export const wireguardSpec = Config.of({
name: Value.text({
name: 'Name',
description: 'A friendly name to help you remember and identify this proxy',
required: { default: null },
}),
config: Value.file({
name: 'Wiregaurd Config',
required: { default: null },
extensions: ['.conf'],
}),
})
export type WireguardSpec = typeof wireguardSpec.validator._TYPE
export type ProxyUpdate = Partial<{
name: string
primaryInbound: true
primaryOutbound: true
}>

View File

@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiNotificationModule } from '@taiga-ui/core'
@Component({
selector: 'proxies-info',
template: `
<tui-notification>
Currently, StartOS only supports Wireguard proxies, which can be used for:
<ol>
<li>
Proxying
<i>outbound</i>
traffic to mask your home/business IP from other servers accessed by
your server/services
</li>
<li>
Proxying
<i>inbound</i>
traffic to mask your home/business IP from anyone accessing your
server/services over clearnet
</li>
<li>
Creating a Virtual Local Area Network (VLAN) to enable private, remote
VPN access to your server/services
</li>
</ol>
<a
href="https://docs.start9.com/latest/user-manual/vpns/"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</tui-notification>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiNotificationModule],
})
export class ProxiesInfoComponent {}

View File

@@ -0,0 +1,137 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
import { TuiButtonModule } from '@taiga-ui/experimental'
import {
TuiDataListModule,
TuiDialogOptions,
TuiDialogService,
TuiDropdownModule,
TuiHostedDropdownModule,
} from '@taiga-ui/core'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { filter } from 'rxjs'
import { Proxy } from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormContext, FormPage } from 'src/app/apps/ui/modals/form/form.page'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { DELETE_OPTIONS, ProxyUpdate } from './constants'
@Component({
selector: 'proxies-menu',
template: `
<tui-hosted-dropdown
style="float: right"
tuiDropdownAlign="left"
[sided]="true"
[content]="dropdown"
>
<button
tuiIconButton
type="button"
appearance="icon"
size="s"
iconLeft="tuiIconMoreHorizontal"
></button>
</tui-hosted-dropdown>
<ng-template #dropdown>
<tui-data-list>
<button
*ngIf="!proxy.primaryInbound && proxy.type === 'inbound-outbound'"
tuiOption
(click)="update({ primaryInbound: true })"
>
Make Primary Inbound
</button>
<button
*ngIf="
!proxy.primaryOutbound &&
(proxy.type === 'inbound-outbound' || proxy.type === 'outbound')
"
tuiOption
(click)="update({ primaryOutbound: true })"
>
Make Primary Outbound
</button>
<button tuiOption (click)="rename()">Rename</button>
<tui-opt-group>
<button tuiOption (click)="delete()">Delete</button>
</tui-opt-group>
</tui-data-list>
</ng-template>
`,
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
TuiButtonModule,
TuiDataListModule,
TuiDropdownModule,
TuiHostedDropdownModule,
],
})
export class ProxiesMenuComponent {
private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
@Input({ required: true }) proxy!: Proxy
delete() {
this.dialogs
.open(TUI_PROMPT, DELETE_OPTIONS)
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting...').subscribe()
try {
await this.api.deleteProxy({ id: this.proxy.id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
async update(value: ProxyUpdate): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.updateProxy(value)
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
async rename() {
const spec = { name: 'Name', required: { default: this.proxy.name } }
const name = await Value.text(spec).build({} as any)
const options: Partial<TuiDialogOptions<FormContext<{ name: string }>>> = {
label: `Rename ${this.proxy.name}`,
data: {
spec: { name },
buttons: [
{
text: 'Save',
handler: value => this.update(value),
},
],
},
}
this.formDialog.open(FormPage, options)
}
}

View File

@@ -0,0 +1,77 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { FormContext, FormPage } from 'src/app/apps/ui/modals/form/form.page'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ProxiesTableComponent } from './table.component'
import { ProxiesInfoComponent } from './info.component'
import { wireguardSpec, WireguardSpec } from './constants'
@Component({
template: `
<proxies-info />
<h3 class="g-title">
Proxies
<button tuiButton size="xs" iconLeft="tuiIconPlus" (click)="add()">
Add Proxy
</button>
</h3>
<table class="g-table" [proxies]="(proxies$ | async) || []"></table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
TuiButtonModule,
ProxiesInfoComponent,
ProxiesTableComponent,
],
})
export class SettingsProxiesComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
readonly proxies$ = inject(PatchDB<DataModel>).watch$(
'server-info',
'network',
'proxies',
)
async add() {
const options: Partial<TuiDialogOptions<FormContext<WireguardSpec>>> = {
label: 'Add Proxy',
data: {
spec: await wireguardSpec.build({} as any),
buttons: [
{
text: 'Save',
handler: value => this.save(value).then(() => true),
},
],
},
}
this.formDialog.open(FormPage, options)
}
private async save({ name, config }: WireguardSpec): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.addProxy({ name, config: config?.filePath || '' })
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,98 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
inject,
Input,
Output,
} from '@angular/core'
import { TuiDialogService, TuiLinkModule } from '@taiga-ui/core'
import { TuiBadgeModule, TuiButtonModule } from '@taiga-ui/experimental'
import { Proxy } from 'src/app/services/patch-db/data-model'
import { ProxiesMenuComponent } from './menu.component'
@Component({
selector: 'table[proxies]',
template: `
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Type</th>
<th>Primary</th>
<th>Used By</th>
<th></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let proxy of proxies">
<td>{{ proxy.name }}</td>
<td>{{ proxy.createdAt | date : 'short' }}</td>
<td>{{ proxy.type }}</td>
<td>
<tui-badge
*ngIf="proxy.primaryInbound"
appearance="success"
size="m"
[style.margin-right.rem]="0.25"
>
Inbound
</tui-badge>
<tui-badge *ngIf="proxy.primaryOutbound" appearance="info" size="m">
Outbound
</tui-badge>
</td>
<td>
<button
*ngIf="getLength(proxy); else unused"
tuiLink
(click)="onUsedBy(proxy)"
>
Connections: {{ getLength(proxy) }}
</button>
<ng-template #unused>N/A</ng-template>
</td>
<td><proxies-menu [proxy]="proxy" /></td>
</tr>
</tbody>
`,
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
TuiButtonModule,
TuiBadgeModule,
TuiLinkModule,
ProxiesMenuComponent,
],
})
export class ProxiesTableComponent {
private readonly dialogs = inject(TuiDialogService)
@Input()
proxies: readonly Proxy[] = []
@Output()
readonly delete = new EventEmitter<Proxy>()
getLength({ usedBy }: Proxy) {
return usedBy.domains.length + usedBy.services.length
}
onUsedBy({ name, usedBy }: Proxy) {
let message = `Proxy "${name}" is currently used by:`
const domains = usedBy.domains.map(d => `<li>${d}</li>`)
const services = usedBy.services.map(s => `<li>${s.title}</li>`)
if (usedBy.domains.length) {
message = `${message}<h2>Domains (inbound)</h2><ul>${domains}</ul>`
}
if (usedBy.services.length) {
message = `${message}<h2>Services (outbound)</h2>${services}`
}
this.dialogs.open(message, { label: 'Used by', size: 's' }).subscribe()
}
}

View File

@@ -0,0 +1,53 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiNotificationModule } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
@Component({
selector: 'router-info',
template: `
<tui-notification [status]="enabled ? 'success' : 'warning'">
<ng-container *ngIf="enabled; else disabled">
<strong>UPnP Enabled!</strong>
<p>
The ports below have been
<i>automatically</i>
forwarded in your router.
</p>
If you are running multiple servers, you may want to override specific
ports to suite your needs.
<a
href="https://docs.start9.com/latest/user-manual/port-forwards/upnp#override"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</ng-container>
<ng-template #disabled>
<strong>UPnP Disabled</strong>
<p>
Below are a list of ports that must be
<i>manually</i>
forwarded in your router in order to enable clearnet access.
</p>
Alternatively, you can enable UPnP in your router for automatic
configuration.
<a
href="https://docs.start9.com/latest/user-manual/port-forwards/manual"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</ng-template>
</tui-notification>
`,
styles: ['strong { font-size: 1rem }'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiNotificationModule],
})
export class RouterInfoComponent {
@Input()
enabled = false
}

View File

@@ -0,0 +1,69 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiTextfieldControllerModule } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { PrimaryIpPipeModule } from 'src/app/common/primary-ip/primary-ip.module'
import { RouterInfoComponent } from './info.component'
import { RouterPortComponent } from './table.component'
@Component({
template: `
<ng-container *ngIf="server$ | async as server">
<router-info [enabled]="!server.network.wanConfig.upnp" />
<table
*ngIf="server.ui.ipInfo | primaryIp as ip"
tuiTextfieldAppearance="unstyled"
tuiTextfieldSize="m"
[tuiTextfieldLabelOutside]="true"
>
<thead>
<tr>
<th [style.width.rem]="2.5"></th>
<th [style.padding-left.rem]="0.75">
<div class="g-title">Port</div>
</th>
<th>
<div class="g-title">Target</div>
</th>
<th [style.width.rem]="3"></th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let portForward of server.network.wanConfig.forwards"
[portForward]="portForward"
[ip]="ip"
></tr>
</tbody>
</table>
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
table {
width: 100%;
min-width: 30rem;
max-width: 40rem;
table-layout: fixed;
background: var(--tui-base-02);
border-radius: 0.75rem;
font-size: 1rem;
margin: 2rem 0;
box-shadow: 0 1rem var(--tui-base-02);
}
`,
],
standalone: true,
imports: [
CommonModule,
PrimaryIpPipeModule,
RouterInfoComponent,
RouterPortComponent,
TuiTextfieldControllerModule,
],
})
export class SettingsRouterComponent {
readonly server$ = inject(PatchDB<DataModel>).watch$('server-info')
}

View File

@@ -0,0 +1,139 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
OnChanges,
SimpleChanges,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { CopyService, ErrorService, LoadingService } from '@start9labs/shared'
import { TuiTextfieldControllerModule } from '@taiga-ui/core'
import { TuiButtonModule, TuiIconModule } from '@taiga-ui/experimental'
import { TuiInputModule, TuiInputNumberModule } from '@taiga-ui/kit'
import { PortForward } from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
selector: 'tr[portForward]',
template: `
<td [style.text-align]="'right'">
<tui-icon
*ngIf="portForward.error; else noError"
icon="tuiIconClose"
[style.color]="'var(--tui-negative)'"
/>
<ng-template #noError>
<tui-icon icon="tuiIconCheck" [style.color]="'var(--tui-positive)'" />
</ng-template>
</td>
<td>
<tui-input-number
decimal="never"
[(ngModel)]="value"
[readOnly]="!editing"
[min]="0"
[tuiTextfieldCustomContent]="buttons"
>
<input tuiTextfield type="text" [style.font-size.rem]="1" />
</tui-input-number>
<ng-template #buttons>
<button
*ngIf="!editing; else actions"
tuiIconButton
appearance="icon"
iconLeft="tuiIconEdit2"
size="s"
(click)="toggle(true)"
>
Edit
</button>
<ng-template #actions>
<button
tuiIconButton
appearance="icon"
iconLeft="tuiIconClose"
size="s"
(click)="toggle(false)"
>
Cancel
</button>
<button
tuiIconButton
appearance="icon"
iconLeft="tuiIconCheck"
size="s"
[disabled]="!value"
(click)="save()"
>
Save
</button>
</ng-template>
</ng-template>
</td>
<td>{{ ip }}:{{ portForward.target }}</td>
<td>
<button
tuiIconButton
appearance="icon"
iconLeft="tuiIconCopy"
size="s"
(click)="copyService.copy(ip + ':' + portForward.target)"
>
Copy
</button>
</td>
`,
styles: ['button { pointer-events: auto }'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
FormsModule,
TuiIconModule,
TuiInputModule,
TuiButtonModule,
TuiInputNumberModule,
TuiTextfieldControllerModule,
],
})
export class RouterPortComponent implements OnChanges {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly copyService = inject(CopyService)
@Input({ required: true })
portForward!: PortForward
@Input()
ip = ''
value = NaN
editing = false
ngOnChanges() {
this.value = this.portForward.override || this.portForward.assigned
}
toggle(editing: boolean) {
this.editing = editing
this.value = this.portForward.override || this.portForward.assigned
}
async save() {
const loader = this.loader.open('Saving...').subscribe()
const { target } = this.portForward
try {
await this.api.overridePortForward({ target, port: this.value })
this.portForward.override = this.value
this.editing = false
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,38 @@
import { Pipe, PipeTransform } from '@angular/core'
import { PlatformType } from 'src/app/services/api/api.types'
@Pipe({
name: 'platformInfo',
standalone: true,
})
export class PlatformInfoPipe implements PipeTransform {
transform(platforms: readonly PlatformType[]): {
name: string
icon: string
} {
const info = {
name: '',
icon: 'tuiIconSmartphone',
}
if (platforms.includes('cli')) {
info.name = 'CLI'
info.icon = 'tuiIconTerminal'
} else if (platforms.includes('desktop')) {
info.name = 'Desktop/Laptop'
info.icon = 'tuiIconMonitor'
} else if (platforms.includes('android')) {
info.name = 'Android Device'
} else if (platforms.includes('iphone')) {
info.name = 'iPhone'
} else if (platforms.includes('ipad')) {
info.name = 'iPad'
} else if (platforms.includes('ios')) {
info.name = 'iOS Device'
} else {
info.name = 'Unknown Device'
}
return info
}
}

View File

@@ -0,0 +1,90 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { from, map, merge, Observable, Subject } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { Session } from 'src/app/services/api/api.types'
import { SSHTableComponent } from './table.component'
import { TuiLetModule } from '@taiga-ui/cdk'
@Component({
template: `
<h3 class="g-title">Current session</h3>
<table
class="g-table"
[single]="true"
[sessions]="current$ | async"
></table>
<ng-container *tuiLet="other$ | async as others">
<h3 class="g-title">
Other sessions
<button
*ngIf="table.selected$ | async as selected"
tuiButton
size="xs"
appearance="error"
[disabled]="!selected.length"
(click)="terminate(selected, others || [])"
>
Terminate selected
</button>
</h3>
<table #table class="g-table" [sessions]="others"></table>
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiButtonModule, SSHTableComponent, TuiLetModule],
})
export class SettingsSessionsComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly sessions$ = from(this.api.getSessions({}))
private readonly local$ = new Subject<readonly SessionWithId[]>()
readonly current$ = this.sessions$.pipe(map(s => [s.sessions[s.current]]))
readonly other$: Observable<readonly SessionWithId[]> = merge(
this.local$,
this.sessions$.pipe(
map(s =>
Object.entries(s.sessions)
.filter(([id, _]) => id !== s.current)
.map(([id, session]) => ({
id,
...session,
}))
.sort(
(a, b) =>
new Date(b['last-active']).valueOf() -
new Date(a['last-active']).valueOf(),
),
),
),
)
async terminate(
sessions: readonly SessionWithId[],
all: readonly SessionWithId[],
) {
const ids = sessions.map(s => s.id)
const loader = this.loader
.open(`Terminating session${ids.length > 1 ? 's' : ''}...`)
.subscribe()
try {
await this.api.killSessions({ ids })
this.local$.next(all.filter(s => !ids.includes(s.id)))
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}
interface SessionWithId extends Session {
id: string
}

View File

@@ -0,0 +1,130 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
} from '@angular/core'
import { TuiLinkModule } from '@taiga-ui/core'
import {
TuiButtonModule,
TuiCheckboxModule,
TuiIconModule,
} from '@taiga-ui/experimental'
import { BehaviorSubject } from 'rxjs'
import { TuiForModule } from '@taiga-ui/cdk'
import { Session } from 'src/app/services/api/api.types'
import { PlatformInfoPipe } from './platform-info.pipe'
import { FormsModule } from '@angular/forms'
@Component({
selector: 'table[sessions]',
template: `
<thead>
<tr>
<th [style.width.%]="50" [style.padding-left.rem]="single ? null : 2">
<input
*ngIf="!single"
tuiCheckbox
size="s"
type="checkbox"
[disabled]="!sessions?.length"
[ngModel]="all"
(ngModelChange)="onAll($event)"
/>
User Agent
</th>
<th [style.width.%]="25">Platform</th>
<th [style.width.%]="25">Last Active</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let session of sessions; else: loading">
<td [style.padding-left.rem]="single ? null : 2">
<input
*ngIf="!single"
tuiCheckbox
size="s"
type="checkbox"
[ngModel]="selected$.value.includes(session)"
(ngModelChange)="onToggle(session)"
/>
{{ session['user-agent'] }}
</td>
<td *ngIf="session.metadata.platforms | platformInfo as info">
<tui-icon [icon]="info.icon"></tui-icon>
{{ info.name }}
</td>
<td>{{ session['last-active'] }}</td>
</tr>
<ng-template #loading>
<tr *ngFor="let _ of single ? [''] : ['', '']">
<td colspan="5">
<div class="tui-skeleton">Loading</div>
</td>
</tr>
</ng-template>
</tbody>
`,
styles: [
`
input {
position: absolute;
top: 50%;
left: 0.5rem;
transform: translateY(-50%);
}
`,
],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
TuiForModule,
TuiButtonModule,
TuiLinkModule,
PlatformInfoPipe,
TuiIconModule,
TuiCheckboxModule,
FormsModule,
],
})
export class SSHTableComponent<T extends Session> implements OnChanges {
readonly selected$ = new BehaviorSubject<readonly T[]>([])
@Input()
sessions: readonly T[] | null = null
@Input()
single = false
get all(): boolean | null {
if (!this.sessions?.length || !this.selected$.value.length) {
return false
}
if (this.sessions?.length === this.selected$.value.length) {
return true
}
return null
}
ngOnChanges() {
this.selected$.next([])
}
onAll(selected: boolean) {
this.selected$.next((selected && this.sessions) || [])
}
onToggle(session: T) {
const selected = this.selected$.value
if (selected.includes(session)) {
this.selected$.next(selected.filter(s => s !== session))
} else {
this.selected$.next([...selected, session])
}
}
}

View File

@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiNotificationModule } from '@taiga-ui/core'
@Component({
selector: 'ssh-info',
template: `
<tui-notification>
Adding domains to StartOS enables you to access your server and service
interfaces over clearnet.
<a
href="https://docs.start9.com/0.3.5.x/user-manual/ssh"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</tui-notification>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiNotificationModule],
})
export class SSHInfoComponent {}

View File

@@ -0,0 +1,41 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ErrorService } from '@start9labs/shared'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { catchError, defer, of } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { SSHInfoComponent } from './info.component'
import { SSHTableComponent } from './table.component'
@Component({
template: `
<ssh-info />
<h3 class="g-title">
Saved Keys
<button
tuiButton
size="xs"
iconLeft="tuiIconPlus"
(click)="table.add.call(table)"
>
Add Key
</button>
</h3>
<table #table class="g-table" [keys]="keys$ | async"></table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiButtonModule, SSHTableComponent, SSHInfoComponent],
})
export class SettingsSSHComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly keys$ = defer(() => this.api.getSshKeys({})).pipe(
catchError(e => {
this.errorService.handleError(e)
return of([])
}),
)
}

View File

@@ -0,0 +1,127 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
} from '@angular/core'
import {
TuiDialogOptions,
TuiDialogService,
TuiLinkModule,
} from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { SSHKey } from 'src/app/services/api/api.types'
import { PROMPT } from '../../../../../../ui/modals/prompt/prompt.component'
import { filter, take } from 'rxjs'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { ApiService } from '../../../../../../../services/api/embassy-api.service'
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { TuiForModule } from '@taiga-ui/cdk'
@Component({
selector: 'table[keys]',
template: `
<thead>
<tr>
<th>Hostname</th>
<th>Created At</th>
<th>Algorithm</th>
<th>Fingerprint</th>
<th></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let key of keys; else: loading">
<td>{{ key.hostname }}</td>
<td>{{ key['created-at'] | date : 'medium' }}</td>
<td>{{ key.alg }}</td>
<td>{{ key.fingerprint }}</td>
<td>
<button
tuiIconButton
size="xs"
appearance="icon"
iconLeft="tuiIconTrash2"
[style.display]="'flex'"
(click)="delete(key)"
>
Delete
</button>
</td>
</tr>
<ng-template #loading>
<tr *ngFor="let _ of ['', '']">
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
</tr>
</ng-template>
</tbody>
`,
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TuiForModule, TuiButtonModule, TuiLinkModule],
})
export class SSHTableComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
private readonly cdr = inject(ChangeDetectorRef)
@Input()
keys: SSHKey[] | null = null
add() {
this.dialogs
.open<string>(PROMPT, ADD_OPTIONS)
.pipe(take(1))
.subscribe(async key => {
const loader = this.loader.open('Saving...').subscribe()
try {
this.keys?.push(await this.api.addSshKey({ key }))
} finally {
loader.unsubscribe()
this.cdr.markForCheck()
}
})
}
delete(key: SSHKey) {
this.dialogs
.open(TUI_PROMPT, DELETE_OPTIONS)
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting...').subscribe()
try {
await this.api.deleteSshKey({ fingerprint: key.fingerprint })
this.keys?.splice(this.keys?.indexOf(key), 1)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
this.cdr.markForCheck()
}
})
}
}
const ADD_OPTIONS: Partial<TuiDialogOptions<{ message: string }>> = {
label: 'SSH Key',
data: {
message:
'Enter the SSH public key you would like to authorize for root access to your Embassy.',
},
}
const DELETE_OPTIONS: Partial<TuiDialogOptions<TuiPromptData>> = {
label: 'Confirm',
size: 's',
data: {
content: 'Delete key? This action cannot be undone.',
yes: 'Delete',
no: 'Cancel',
},
}

View File

@@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiNotificationModule } from '@taiga-ui/core'
@Component({
selector: 'wifi-info',
template: `
<tui-notification>
Adding WiFi credentials to StartOS allows you to remove the Ethernet cable
and move the device anywhere you want. StartOS will automatically connect
to available networks.
<a
href="https://docs.start9.com/latest/user-manual/wifi"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</tui-notification>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiNotificationModule],
})
export class WifiInfoComponent {}

View File

@@ -0,0 +1,148 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
} from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogOptions } from '@taiga-ui/core'
import {
TuiBadgeModule,
TuiButtonModule,
TuiCellModule,
TuiIconModule,
TuiTitleModule,
} from '@taiga-ui/experimental'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormContext, FormPage } from 'src/app/apps/ui/modals/form/form.page'
import { Wifi, WiFiForm, wifiSpec } from './utils'
import { SettingsWifiComponent } from './wifi.component'
@Component({
selector: '[wifi]',
template: `
<ng-container *ngFor="let network of wifi">
<div *ngIf="network.ssid" tuiCell [style.padding]="0">
<div tuiTitle>
<strong>
{{ network.ssid }}
<tui-badge
*ngIf="network.connected"
appearance="success"
[dot]="true"
>
Connected
</tui-badge>
</strong>
</div>
<button
*ngIf="!network.connected"
tuiButton
size="xs"
appearance="opposite"
(click)="prompt(network)"
>
Connect
</button>
<button
*ngIf="network.connected !== undefined; else strength"
tuiIconButton
size="s"
appearance="icon"
iconLeft="tuiIconTrash2"
(click)="forget(network)"
>
Forget
</button>
<ng-template #strength>
<tui-icon
[style.width.rem]="2"
[icon]="network.security.length ? 'tuiIconLock' : 'tuiIconUnlock'"
/>
</ng-template>
<img
[src]="getSignal(network.strength)"
[style.width.rem]="2"
alt="Signal Strength: {{ network.strength }}"
/>
</div>
</ng-container>
`,
host: { style: 'align-items: stretch' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
TuiCellModule,
TuiTitleModule,
TuiBadgeModule,
TuiButtonModule,
TuiIconModule,
],
})
export class WifiTableComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
private readonly component = inject(SettingsWifiComponent)
private readonly cdr = inject(ChangeDetectorRef)
@Input()
wifi: readonly Wifi[] = []
getSignal(signal: number): string {
if (signal < 5) {
return 'assets/img/icons/wifi-0.png'
}
if (signal >= 5 && signal < 50) {
return 'assets/img/icons/wifi-1.png'
}
if (signal >= 50 && signal < 90) {
return 'assets/img/icons/wifi-2.png'
}
return 'assets/img/icons/wifi-3.png'
}
async forget({ ssid }: Wifi): Promise<void> {
const loader = this.loader.open('Deleting...').subscribe()
try {
await this.api.deleteWifi({ ssid })
this.wifi = this.wifi.filter(network => network.ssid !== ssid)
this.cdr.markForCheck()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async prompt(network: Wifi): Promise<void> {
if (!network.security.length) {
await this.component.saveAndConnect(network.ssid)
} else {
const options: Partial<TuiDialogOptions<FormContext<WiFiForm>>> = {
label: 'Password Needed',
data: {
spec: wifiSpec.spec,
buttons: [
{
text: 'Connect',
handler: async ({ ssid, password }) =>
this.component.saveAndConnect(ssid, password),
},
],
},
}
this.formDialog.open(FormPage, options)
}
}
}

View File

@@ -0,0 +1,78 @@
import { ValueSpecObject } from '@start9labs/start-sdk/lib/config/configTypes'
import { AvailableWifi } from 'src/app/services/api/api.types'
import { RR } from 'src/app/services/api/api.types'
export interface WiFiForm {
ssid: string
password: string
}
export interface Wifi extends AvailableWifi {
readonly connected?: boolean
}
export interface WifiData {
known: readonly Wifi[]
available: readonly Wifi[]
}
export function parseWifi(res: RR.GetWifiRes): WifiData {
return {
available: res['available-wifi'],
known: Object.entries(res.ssids).map(([ssid, strength]) => ({
ssid,
strength,
security: [],
connected: ssid === res.connected,
})),
}
}
export const wifiSpec: ValueSpecObject = {
type: 'object',
name: 'WiFi Credentials',
description:
'Enter the network SSID and password. You can connect now or save the network for later.',
warning: null,
spec: {
ssid: {
type: 'text',
minLength: null,
maxLength: null,
patterns: [],
name: 'Network SSID',
description: null,
inputmode: 'text',
placeholder: null,
required: true,
masked: false,
default: null,
warning: null,
disabled: false,
immutable: false,
generate: null,
},
password: {
type: 'text',
minLength: null,
maxLength: null,
patterns: [
{
regex: '^.{8,}$',
description: 'Must be longer than 8 characters',
},
],
name: 'Password',
description: null,
inputmode: 'text',
placeholder: null,
required: true,
masked: true,
default: null,
warning: null,
disabled: false,
immutable: false,
generate: null,
},
},
}

View File

@@ -0,0 +1,254 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import {
ErrorService,
LoadingService,
pauseFor,
SharedPipesModule,
} from '@start9labs/shared'
import { TuiLetModule } from '@taiga-ui/cdk'
import {
TuiAlertService,
TuiDialogOptions,
TuiLoaderModule,
} from '@taiga-ui/core'
import {
TuiButtonModule,
TuiCardModule,
TuiToggleModule,
} from '@taiga-ui/experimental'
import { PatchDB } from 'patch-db-client'
import { catchError, defer, merge, Observable, of, Subject, map } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { WifiInfoComponent } from './info.component'
import { WifiTableComponent } from './table.component'
import { parseWifi, WifiData, WiFiForm } from './utils'
import { RR } from '../../../../../../../services/api/api.types'
import {
FormContext,
FormPage,
} from '../../../../../../ui/modals/form/form.page'
import { wifiSpec } from '../../../../../../ui/pages/system/wifi/wifi.const'
import { FormDialogService } from '../../../../../../../services/form-dialog.service'
@Component({
template: `
<wifi-info />
<ng-container *tuiLet="enabled$ | async as enabled">
<h3 class="g-title">
Wi-Fi
<input
type="checkbox"
tuiToggle
[ngModel]="enabled"
(ngModelChange)="onToggle($event)"
/>
</h3>
<ng-container *ngIf="enabled">
<ng-container *ngIf="wifi$ | async as wifi; else loading">
<ng-container *ngIf="wifi.known.length">
<h3 class="g-title">Known Networks</h3>
<div tuiCard="l" [wifi]="wifi.known"></div>
</ng-container>
<ng-container *ngIf="wifi.available.length">
<h3 class="g-title">Other Networks</h3>
<div tuiCard="l" [wifi]="wifi.available"></div>
</ng-container>
<p>
<button
tuiButton
size="s"
appearance="opposite"
(click)="other(wifi)"
>
Other...
</button>
</p>
</ng-container>
<ng-template #loading><tui-loader></tui-loader></ng-template>
</ng-container>
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
FormsModule,
TuiButtonModule,
TuiToggleModule,
TuiLetModule,
TuiCardModule,
TuiLoaderModule,
SharedPipesModule,
WifiInfoComponent,
WifiTableComponent,
],
})
export class SettingsWifiComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly alerts = inject(TuiAlertService)
private readonly update$ = new Subject<WifiData>()
private readonly formDialog = inject(FormDialogService)
private readonly cdr = inject(ChangeDetectorRef)
readonly wifi$ = merge(this.getWifi$(), this.update$)
readonly enabled$ = inject(PatchDB<DataModel>).watch$(
'server-info',
'network',
'wifi',
'enabled',
)
async onToggle(enable: boolean) {
const loader = this.loader
.open(enable ? 'Enabling Wifi' : 'Disabling WiFi')
.subscribe()
try {
await this.api.enableWifi({ enable })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
other(wifi: WifiData) {
const options: Partial<TuiDialogOptions<FormContext<WiFiForm>>> = {
label: wifiSpec.name,
data: {
spec: wifiSpec.spec,
buttons: [
{
text: 'Save for Later',
handler: async ({ ssid, password }) =>
this.save(ssid, password, wifi),
},
{
text: 'Save and Connect',
handler: async ({ ssid, password }) =>
this.saveAndConnect(ssid, password),
},
],
},
}
this.formDialog.open(FormPage, options)
}
async saveAndConnect(ssid: string, password?: string): Promise<boolean> {
const loader = this.loader
.open('Connecting. This could take a while...')
.subscribe()
try {
if (password) {
await this.api.addWifi({
ssid,
password,
priority: 0,
connect: true,
})
} else {
await this.api.connectWifi({ ssid })
}
await this.confirmWifi(ssid)
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private async confirmWifi(ssid: string): Promise<void> {
const maxAttempts = 5
let attempts = 0
while (true) {
if (attempts > maxAttempts) {
this.alerts
.open('Check credentials and try again', {
label: 'Failed to connect',
status: 'warning',
})
.subscribe()
break
}
try {
const start = new Date().valueOf()
const newWifi = await this.api.getWifi({}, 10000)
const end = new Date().valueOf()
if (newWifi.connected === ssid) {
this.update$.next(parseWifi(newWifi))
this.alerts
.open('Connection successful!', { status: 'success' })
.subscribe()
break
} else {
attempts++
const diff = end - start
// depending on the response time, wait a min of 1000 ms, and a max of 4000 ms in between retries. Both 1000 and 4000 are arbitrary
await pauseFor(Math.max(1000, 4000 - diff))
}
} catch (e) {
attempts++
console.warn(e)
}
}
}
private getWifi$(): Observable<WifiData> {
return defer(() => this.api.getWifi({}, 10000)).pipe(
map(res => parseWifi(res)),
catchError((e: any) => {
this.errorService.handleError(e)
return of({ known: [], available: [] })
}),
)
}
private async save(
ssid: string,
password: string,
wifi: WifiData,
): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.addWifi({
ssid,
password,
priority: 0,
connect: false,
})
wifi.known = wifi.known.concat({
ssid,
strength: 0,
security: [],
connected: false,
})
this.cdr.markForCheck()
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,59 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { RouterModule } from '@angular/router'
import { TuiIconModule } from '@taiga-ui/experimental'
import { SettingsMenuComponent } from './components/menu.component'
@Component({
template: `
<a
routerLink="/portal/system/settings"
routerLinkActive="_current"
[routerLinkActiveOptions]="{ exact: true }"
>
<tui-icon icon="tuiIconChevronLeft" />
Settings
</a>
<settings-menu class="page" />
<router-outlet />
`,
styles: [
`
:host {
::ng-deep tui-notification {
position: sticky;
left: 0;
}
}
a {
position: sticky;
left: 0;
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin: 1rem 0;
font-size: 1rem;
color: var(--tui-text-01);
}
._current {
display: none;
}
.page {
display: none;
}
._current + .page {
display: flex;
max-width: 45rem;
margin: 0 auto;
}
`,
],
host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [RouterModule, TuiIconModule, SettingsMenuComponent],
})
export class SettingsComponent {}

View File

@@ -0,0 +1,75 @@
import { Routes } from '@angular/router'
import { SettingsComponent } from './settings.component'
export const SETTINGS_ROUTES: Routes = [
{
path: '',
component: SettingsComponent,
children: [
{
path: 'email',
loadComponent: () =>
import('./routes/email/email.component').then(
m => m.SettingsEmailComponent,
),
},
{
path: 'experimental',
loadComponent: () =>
import('./routes/experimental/experimental.component').then(
m => m.SettingsExperimentalComponent,
),
},
{
path: 'domains',
loadComponent: () =>
import('./routes/domains/domains.component').then(
m => m.SettingsDomainsComponent,
),
},
{
path: 'proxies',
loadComponent: () =>
import('./routes/proxies/proxies.component').then(
m => m.SettingsProxiesComponent,
),
},
{
path: 'router',
loadComponent: () =>
import('./routes/router/router.component').then(
m => m.SettingsRouterComponent,
),
},
{
path: 'wifi',
loadComponent: () =>
import('./routes/wifi/wifi.component').then(
m => m.SettingsWifiComponent,
),
},
{
path: 'interfaces',
loadComponent: () =>
import('./routes/interfaces/interfaces.component').then(
m => m.SettingsInterfacesComponent,
),
},
{
path: 'ssh',
loadComponent: () =>
import('./routes/ssh/ssh.component').then(
m => m.SettingsSSHComponent,
),
},
{
path: 'sessions',
loadComponent: () =>
import('./routes/sessions/sessions.component').then(
m => m.SettingsSessionsComponent,
),
},
],
},
]

View File

@@ -0,0 +1,240 @@
import { inject, Injectable } from '@angular/core'
import { TuiAlertService, TuiDialogService } from '@taiga-ui/core'
import * as argon2 from '@start9labs/argon2'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter, from, take } from 'rxjs'
import { switchMap } from 'rxjs/operators'
import { ProxyService } from 'src/app/services/proxy.service'
import { FormPage } from 'src/app/apps/ui/modals/form/form.page'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
import { getServerInfo } from 'src/app/util/get-server-info'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PROMPT } from 'src/app/apps/ui/modals/prompt/prompt.component'
import { passwordSpec, PasswordSpec, SettingBtn } from './settings.types'
@Injectable({ providedIn: 'root' })
export class SettingsService {
private readonly alerts = inject(TuiAlertService)
private readonly dialogs = inject(TuiDialogService)
private readonly proxyService = inject(ProxyService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly formDialog = inject(FormDialogService)
private readonly patch = inject(PatchDB<DataModel>)
private readonly api = inject(ApiService)
readonly settings: Record<string, readonly SettingBtn[]> = {
General: [
{
title: 'Email',
description:
'Connect to an external SMTP server to send yourself emails',
icon: 'tuiIconMail',
routerLink: 'email',
},
{
title: 'Change Master Password',
description: `Change your StartOS master password`,
icon: 'tuiIconKey',
action: () => this.promptNewPassword(),
},
{
title: 'Experimental Features',
description: 'Try out new and potentially unstable new features',
icon: 'tuiIconThermometer',
routerLink: 'experimental',
},
],
Network: [
{
title: 'Domains',
description: 'Manage domains for clearnet connectivity',
icon: 'tuiIconGlobe',
routerLink: 'domains',
},
{
title: 'Proxies',
description: 'Manage proxies for inbound and outbound connections',
icon: 'tuiIconShuffle',
routerLink: 'proxies',
},
{
title: 'Router Config',
description: 'Connect or configure your router for clearnet',
icon: 'tuiIconRadio',
routerLink: 'router',
},
{
title: 'WiFi',
description: 'Add or remove WiFi networks',
icon: 'tuiIconWifi',
routerLink: 'wifi',
},
],
'User Interface': [
{
title: 'Browser Tab Title',
description: `Customize the display name of your browser tab`,
icon: 'tuiIconTag',
action: () => this.setBrowserTab(),
},
{
title: 'Web Addresses',
description: 'View and manage web addresses for accessing this UI',
icon: 'tuiIconMonitor',
routerLink: 'interfaces',
},
],
'Privacy and Security': [
{
title: 'Outbound Proxy',
description: 'Proxy outbound traffic from the StartOS main process',
icon: 'tuiIconShield',
action: () => this.proxyService.presentModalSetOutboundProxy(),
},
{
title: 'SSH',
description:
'Manage your SSH keys to access your server from the command line',
icon: 'tuiIconTerminal',
routerLink: 'ssh',
},
{
title: 'Active Sessions',
description: 'View and manage device access',
icon: 'tuiIconClock',
routerLink: 'sessions',
},
],
Support: [
{
title: 'User Manual',
description: 'Discover what StartOS can do',
icon: 'tuiIconMap',
href: 'https://docs.start9.com/0.3.5.x/user-manual',
},
{
title: 'Contact Support',
description: 'Get help from the Start9 team and community',
icon: 'tuiIconMessageSquare',
href: 'https://start9.com/contact',
},
{
title: 'Donate to Start9',
description: `Support StartOS development`,
icon: 'tuiIconDollarSign',
href: 'https://donate.start9.com',
},
],
}
private async setBrowserTab(): Promise<void> {
this.patch
.watch$('ui', 'name')
.pipe(
switchMap(initialValue =>
this.dialogs.open<string>(PROMPT, {
label: 'Browser Tab Title',
data: {
message: `This value will be displayed as the title of your browser tab.`,
label: 'Device Name',
placeholder: 'StartOS',
required: false,
buttonText: 'Save',
initialValue,
},
}),
),
take(1),
)
.subscribe(async name => {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.setDbValue<string | null>(['name'], name || null)
} finally {
loader.unsubscribe()
}
})
}
private promptNewPassword() {
this.dialogs
.open(TUI_PROMPT, {
label: 'Warning',
size: 's',
data: {
content:
'You will still need your current password to decrypt existing backups!',
yes: 'Continue',
no: 'Cancel',
},
})
.pipe(
filter(Boolean),
switchMap(() => from(configBuilderToSpec(passwordSpec))),
)
.subscribe(spec => {
this.formDialog.open(FormPage, {
label: 'Change Master Password',
data: {
spec,
buttons: [
{
text: 'Save',
handler: (value: PasswordSpec) => this.resetPassword(value),
},
],
},
})
})
}
private async resetPassword(value: PasswordSpec): Promise<boolean> {
let err = ''
if (value.newPassword1 !== value.newPassword2) {
err = 'New passwords do not match'
} else if (value.newPassword1.length < 12) {
err = 'New password must be 12 characters or greater'
} else if (value.newPassword1.length > 64) {
err = 'New password must be less than 65 characters'
}
// confirm current password is correct
const { 'password-hash': passwordHash } = await getServerInfo(this.patch)
try {
argon2.verify(passwordHash, value.currentPassword)
} catch (e) {
err = 'Current password is invalid'
}
if (err) {
this.errorService.handleError(err)
return false
}
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.resetPassword({
'old-password': value.currentPassword,
'new-password': value.newPassword1,
})
this.alerts.open('Password changed!').subscribe()
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,37 @@
import { Config } from '@start9labs/start-sdk/lib/config/builder/config'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
export interface SettingBtn {
title: string
description: string
icon: string
action?: Function
href?: string
routerLink?: string
}
export const passwordSpec = Config.of({
currentPassword: Value.text({
name: 'Current Password',
required: {
default: null,
},
masked: true,
}),
newPassword1: Value.text({
name: 'New Password',
required: {
default: null,
},
masked: true,
}),
newPassword2: Value.text({
name: 'Retype New Password',
required: {
default: null,
},
masked: true,
}),
})
export type PasswordSpec = typeof passwordSpec.validator._TYPE

View File

@@ -11,6 +11,13 @@ const ROUTES: Routes = [
import('./backups/backups.component').then(m => m.BackupsComponent), import('./backups/backups.component').then(m => m.BackupsComponent),
data: toNavigationItem('/portal/system/backups'), data: toNavigationItem('/portal/system/backups'),
}, },
{
title: systemTabResolver,
path: 'settings',
loadChildren: () =>
import('./settings/settings.routes').then(m => m.SETTINGS_ROUTES),
data: toNavigationItem('/portal/system/settings'),
},
{ {
title: systemTabResolver, title: systemTabResolver,
path: 'notifications', path: 'notifications',

View File

@@ -22,7 +22,7 @@ export class RoutingStrategyService extends BaseRouteReuseStrategy {
if (!store) this.handlers.delete(path) if (!store) this.handlers.delete(path)
return store return store && path.startsWith('/portal/service')
} }
override store( override store(
@@ -41,30 +41,14 @@ export class RoutingStrategyService extends BaseRouteReuseStrategy {
} }
override shouldReuseRoute( override shouldReuseRoute(
future: ActivatedRouteSnapshot, { routeConfig, params }: ActivatedRouteSnapshot,
curr: ActivatedRouteSnapshot, current: ActivatedRouteSnapshot,
): boolean { ): boolean {
// return future.routeConfig === curr.routeConfig return (
// TODO: Copied from ionic for backwards compatibility, remove later routeConfig === current.routeConfig &&
if (future.routeConfig !== curr.routeConfig) { Object.keys(params).length === Object.keys(current.params).length &&
return false Object.keys(params).every(key => current.params[key] === params[key])
} )
// checking router params
const futureParams = future.params
const currentParams = curr.params
const keysA = Object.keys(futureParams)
const keysB = Object.keys(currentParams)
if (keysA.length !== keysB.length) {
return false
}
// Test for A's keys different from B.
for (const key of keysA) {
if (currentParams[key] !== futureParams[key]) {
return false
}
}
return true
} }
private getPath(route: ActivatedRouteSnapshot): string { private getPath(route: ActivatedRouteSnapshot): string {

View File

@@ -3,10 +3,11 @@
.template { .template {
@include transition(opacity); @include transition(opacity);
width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 0.5rem; padding: 0 0.5rem;
font: var(--tui-font-text-l); font: var(--tui-font-text-m);
font-weight: bold; font-weight: bold;
&_hidden { &_hidden {
@@ -41,5 +42,5 @@ small {
tui-tag { tui-tag {
z-index: 1; z-index: 1;
margin: 0 -0.25rem 0 auto; margin: -0.25rem -0.25rem -0.25rem auto;
} }

View File

@@ -1,6 +1,6 @@
<ng-container *ngIf="network$ | async as network"> <ng-container *ngIf="network$ | async as network">
<!-- clearnet --> <!-- clearnet -->
<ion-item-divider style="--padding-top: 0">Clearnet</ion-item-divider> <h3 class="g-title">Clearnet</h3>
<ion-item-group> <ion-item-group>
<ion-item> <ion-item>
<ion-label> <ion-label>
@@ -47,7 +47,7 @@
</ion-item-group> </ion-item-group>
<!-- tor --> <!-- tor -->
<ion-item-divider>Tor</ion-item-divider> <h3 class="g-title">Tor</h3>
<ion-item-group> <ion-item-group>
<ion-item> <ion-item>
<ion-label> <ion-label>
@@ -72,7 +72,7 @@
</ion-item-group> </ion-item-group>
<!-- local --> <!-- local -->
<ion-item-divider>Local</ion-item-divider> <h3 class="g-title">Local</h3>
<ion-item-group> <ion-item-group>
<ion-item> <ion-item>
<ion-label> <ion-label>

View File

@@ -9,13 +9,15 @@
> >
Restart your server for these updates to take effect. It can take several Restart your server for these updates to take effect. It can take several
minutes to come back online. minutes to come back online.
<button <div>
tuiButton <button
appearance="secondary" tuiButton
size="s" appearance="secondary"
style="margin-top: 8px" size="s"
(click)="restart()" style="margin-top: 8px"
> (click)="restart()"
Restart >
</button> Restart
</button>
</div>
</ng-template> </ng-template>

View File

@@ -117,7 +117,7 @@ export class ProxyService {
buttons: [ buttons: [
{ {
text: 'Manage proxies', text: 'Manage proxies',
link: '/system/proxies', link: '/portal/system/settings/proxies',
}, },
{ {
text: 'Save', text: 'Save',

View File

@@ -387,6 +387,7 @@ ul {
td, td,
th { th {
position: relative;
font: var(--tui-font-text-s); font: var(--tui-font-text-s);
text-align: left; text-align: left;
height: 2rem; height: 2rem;
@@ -476,10 +477,6 @@ button.g-action {
overflow: auto !important; overflow: auto !important;
} }
p {
font-size: 1rem;
}
svg:not(:root) { svg:not(:root) {
overflow: auto; overflow: auto;
} }