mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 20:14:49 +00:00
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:
7414
web/package-lock.json
generated
7414
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <email@example.com>
|
||||||
|
<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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}>
|
||||||
@@ -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 {}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user