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/emver": "^0.1.5",
"@start9labs/start-sdk": "0.4.0-rev0.lib0.rc8.beta2",
"@taiga-ui/addon-charts": "3.53.0",
"@taiga-ui/addon-mobile": "3.53.0",
"@taiga-ui/cdk": "3.53.0",
"@taiga-ui/core": "3.53.0",
"@taiga-ui/experimental": "3.53.0",
"@taiga-ui/icons": "3.53.0",
"@taiga-ui/kit": "3.53.0",
"@taiga-ui/styles": "3.53.0",
"@taiga-ui/addon-charts": "3.56.0",
"@taiga-ui/addon-mobile": "3.56.0",
"@taiga-ui/cdk": "3.56.0",
"@taiga-ui/core": "3.56.0",
"@taiga-ui/experimental": "3.56.0",
"@taiga-ui/icons": "3.56.0",
"@taiga-ui/kit": "3.56.0",
"@taiga-ui/styles": "3.56.0",
"@tinkoff/ng-dompurify": "4.0.0",
"@tinkoff/ng-event-plugins": "3.1.0",
"ansi-to-html": "^0.7.2",

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { APP_INITIALIZER, Provider } from '@angular/core'
import { UntypedFormBuilder } from '@angular/forms'
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 {
tuiNumberFormatProvider,
@@ -54,10 +54,6 @@ export const APP_PROVIDERS: Provider[] = [
provide: TUI_DATE_TIME_VALUE_TRANSFORMER,
useClass: DatetimeTransformerService,
},
{
provide: RouteReuseStrategy,
useClass: IonicRouteStrategy,
},
{
provide: ApiService,
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 {
TuiDataListModule,
TuiDialogService,
TuiHostedDropdownModule,
TuiSvgModule,
} from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.service'
import { ABOUT } from './about.component'
@Component({
selector: 'header-menu',
@@ -23,7 +25,7 @@ import { AuthService } from 'src/app/services/auth.service'
<ng-template #content>
<tui-data-list>
<h3 class="title">StartOS</h3>
<button tuiOption class="item" (click)="({})">
<button tuiOption class="item" (click)="about()">
<tui-svg src="tuiIconInfo"></tui-svg>
About this server
</button>
@@ -101,6 +103,11 @@ import { AuthService } from 'src/app/services/auth.service'
export class HeaderMenuComponent {
private readonly api = inject(ApiService)
private readonly auth = inject(AuthService)
private readonly dialogs = inject(TuiDialogService)
about() {
this.dialogs.open(ABOUT, { label: 'About this server' }).subscribe()
}
logout() {
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
tuiIconButton
iconLeft="tuiIconCloudLarge"
appearance="success"
appearance="icon-success"
[style.margin-left]="'auto'"
>
Connection
@@ -48,7 +48,7 @@ import { NotificationService } from '../../services/notification.service'
<button
tuiIconButton
iconLeft="tuiIconBellLarge"
appearance="warning"
appearance="icon-warning"
(click)="handleNotificationsClick(unread || 0)"
>
Notifications

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ export class RoutingStrategyService extends BaseRouteReuseStrategy {
if (!store) this.handlers.delete(path)
return store
return store && path.startsWith('/portal/service')
}
override store(
@@ -41,30 +41,14 @@ export class RoutingStrategyService extends BaseRouteReuseStrategy {
}
override shouldReuseRoute(
future: ActivatedRouteSnapshot,
curr: ActivatedRouteSnapshot,
{ routeConfig, params }: ActivatedRouteSnapshot,
current: ActivatedRouteSnapshot,
): boolean {
// return future.routeConfig === curr.routeConfig
// TODO: Copied from ionic for backwards compatibility, remove later
if (future.routeConfig !== curr.routeConfig) {
return false
}
// 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
return (
routeConfig === current.routeConfig &&
Object.keys(params).length === Object.keys(current.params).length &&
Object.keys(params).every(key => current.params[key] === params[key])
)
}
private getPath(route: ActivatedRouteSnapshot): string {

View File

@@ -3,10 +3,11 @@
.template {
@include transition(opacity);
width: 100%;
display: flex;
align-items: center;
padding: 0 0.5rem;
font: var(--tui-font-text-l);
font: var(--tui-font-text-m);
font-weight: bold;
&_hidden {
@@ -41,5 +42,5 @@ small {
tui-tag {
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">
<!-- clearnet -->
<ion-item-divider style="--padding-top: 0">Clearnet</ion-item-divider>
<h3 class="g-title">Clearnet</h3>
<ion-item-group>
<ion-item>
<ion-label>
@@ -47,7 +47,7 @@
</ion-item-group>
<!-- tor -->
<ion-item-divider>Tor</ion-item-divider>
<h3 class="g-title">Tor</h3>
<ion-item-group>
<ion-item>
<ion-label>
@@ -72,7 +72,7 @@
</ion-item-group>
<!-- local -->
<ion-item-divider>Local</ion-item-divider>
<h3 class="g-title">Local</h3>
<ion-item-group>
<ion-item>
<ion-label>

View File

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

View File

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

View File

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