mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +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/emver": "^0.1.5",
|
||||
"@start9labs/start-sdk": "0.4.0-rev0.lib0.rc8.beta2",
|
||||
"@taiga-ui/addon-charts": "3.53.0",
|
||||
"@taiga-ui/addon-mobile": "3.53.0",
|
||||
"@taiga-ui/cdk": "3.53.0",
|
||||
"@taiga-ui/core": "3.53.0",
|
||||
"@taiga-ui/experimental": "3.53.0",
|
||||
"@taiga-ui/icons": "3.53.0",
|
||||
"@taiga-ui/kit": "3.53.0",
|
||||
"@taiga-ui/styles": "3.53.0",
|
||||
"@taiga-ui/addon-charts": "3.56.0",
|
||||
"@taiga-ui/addon-mobile": "3.56.0",
|
||||
"@taiga-ui/cdk": "3.56.0",
|
||||
"@taiga-ui/core": "3.56.0",
|
||||
"@taiga-ui/experimental": "3.56.0",
|
||||
"@taiga-ui/icons": "3.56.0",
|
||||
"@taiga-ui/kit": "3.56.0",
|
||||
"@taiga-ui/styles": "3.56.0",
|
||||
"@tinkoff/ng-dompurify": "4.0.0",
|
||||
"@tinkoff/ng-event-plugins": "3.1.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
/* stylelint-disable order/order */
|
||||
[tuiWrapper][data-appearance='secondary-warning'] {
|
||||
[tuiAppearance][data-appearance='secondary-warning'] {
|
||||
background: var(--tui-warning-bg);
|
||||
color: var(--tui-warning-fill);
|
||||
|
||||
&[data-mode='onDark'] {
|
||||
background: var(--tui-warning-bg-night);
|
||||
color: var(--tui-warning-fill-night);
|
||||
|
||||
@include wrapper-hover {
|
||||
background: var(--tui-warning-bg-night-hover);
|
||||
}
|
||||
|
||||
@include wrapper-active {
|
||||
background: var(--tui-warning-bg-night-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@include wrapper-hover {
|
||||
background: var(--tui-warning-bg-hover);
|
||||
}
|
||||
@@ -27,18 +14,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
[tuiWrapper][data-appearance='success'] {
|
||||
[tuiAppearance][data-appearance='icon-success'] {
|
||||
color: var(--tui-success-fill);
|
||||
}
|
||||
|
||||
[tuiWrapper][data-appearance='warning'] {
|
||||
[tuiAppearance][data-appearance='icon-warning'] {
|
||||
color: var(--tui-warning-fill);
|
||||
}
|
||||
|
||||
[tuiWrapper][data-appearance='error'] {
|
||||
[tuiAppearance][data-appearance='icon-error'] {
|
||||
color: var(--tui-error-fill);
|
||||
}
|
||||
|
||||
[tuiAppearance][data-appearance='flat'],
|
||||
[tuiAppearance][data-appearance='outline'] {
|
||||
color: var(--tui-text-01);
|
||||
}
|
||||
|
||||
[tuiWrapper][data-appearance='input-file'] {
|
||||
&:hover,
|
||||
&:active {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<tui-root
|
||||
*ngIf="widgetDrawer$ | async as drawer"
|
||||
tuiTheme="night"
|
||||
[tuiMode]="(theme$ | async) === 'Dark' ? 'onDark' : null"
|
||||
[style.--widgets-width.px]="drawer.open ? drawer.width : 0"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { APP_INITIALIZER, Provider } from '@angular/core'
|
||||
import { UntypedFormBuilder } from '@angular/forms'
|
||||
import { Router, RouteReuseStrategy } from '@angular/router'
|
||||
import { IonicRouteStrategy, IonNav } from '@ionic/angular'
|
||||
import { IonNav } from '@ionic/angular'
|
||||
import { TUI_DATE_FORMAT, TUI_DATE_SEPARATOR } from '@taiga-ui/cdk'
|
||||
import {
|
||||
tuiNumberFormatProvider,
|
||||
@@ -54,10 +54,6 @@ export const APP_PROVIDERS: Provider[] = [
|
||||
provide: TUI_DATE_TIME_VALUE_TRANSFORMER,
|
||||
useClass: DatetimeTransformerService,
|
||||
},
|
||||
{
|
||||
provide: RouteReuseStrategy,
|
||||
useClass: IonicRouteStrategy,
|
||||
},
|
||||
{
|
||||
provide: ApiService,
|
||||
useClass: useMocks ? MockApiService : LiveApiService,
|
||||
|
||||
@@ -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 {
|
||||
TuiDataListModule,
|
||||
TuiDialogService,
|
||||
TuiHostedDropdownModule,
|
||||
TuiSvgModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import { ABOUT } from './about.component'
|
||||
|
||||
@Component({
|
||||
selector: 'header-menu',
|
||||
@@ -23,7 +25,7 @@ import { AuthService } from 'src/app/services/auth.service'
|
||||
<ng-template #content>
|
||||
<tui-data-list>
|
||||
<h3 class="title">StartOS</h3>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<button tuiOption class="item" (click)="about()">
|
||||
<tui-svg src="tuiIconInfo"></tui-svg>
|
||||
About this server
|
||||
</button>
|
||||
@@ -101,6 +103,11 @@ import { AuthService } from 'src/app/services/auth.service'
|
||||
export class HeaderMenuComponent {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly auth = inject(AuthService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
about() {
|
||||
this.dialogs.open(ABOUT, { label: 'About this server' }).subscribe()
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.api.logout({}).catch(e => console.error('Failed to log out', e))
|
||||
|
||||
@@ -33,7 +33,7 @@ import { NotificationService } from '../../services/notification.service'
|
||||
<button
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconCloudLarge"
|
||||
appearance="success"
|
||||
appearance="icon-success"
|
||||
[style.margin-left]="'auto'"
|
||||
>
|
||||
Connection
|
||||
@@ -48,7 +48,7 @@ import { NotificationService } from '../../services/notification.service'
|
||||
<button
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconBellLarge"
|
||||
appearance="warning"
|
||||
appearance="icon-warning"
|
||||
(click)="handleNotificationsClick(unread || 0)"
|
||||
>
|
||||
Notifications
|
||||
|
||||
@@ -12,6 +12,10 @@ export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
|
||||
icon: 'tuiIconUploadLarge',
|
||||
title: 'Sideload',
|
||||
},
|
||||
'/portal/system/settings': {
|
||||
icon: 'tuiIconToolLarge',
|
||||
title: 'Settings',
|
||||
},
|
||||
'/portal/system/snek': {
|
||||
icon: 'assets/img/icon_transparent.png',
|
||||
title: 'Snek',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<header appHeader>My server</header>
|
||||
<nav appNavigation></nav>
|
||||
<main>
|
||||
<router-outlet></router-outlet>
|
||||
<router-outlet />
|
||||
</main>
|
||||
<app-drawer></app-drawer>
|
||||
<app-drawer />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
import { TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs'
|
||||
import {
|
||||
@@ -20,10 +20,10 @@ import { NavigationService } from '../../../services/navigation.service'
|
||||
[routerLink]="getLink(service.manifest.id)"
|
||||
(isActiveChange)="onActive(service, $event)"
|
||||
>
|
||||
<tui-svg src="tuiIconChevronLeftLarge" />
|
||||
<tui-icon icon="tuiIconChevronLeft" />
|
||||
{{ service.manifest.title }}
|
||||
</a>
|
||||
<router-outlet></router-outlet>
|
||||
<router-outlet />
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
@@ -44,7 +44,7 @@ import { NavigationService } from '../../../services/navigation.service'
|
||||
host: { class: 'g-page' },
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule, TuiSvgModule],
|
||||
imports: [CommonModule, RouterModule, TuiIconModule],
|
||||
})
|
||||
export class ServiceOutletComponent {
|
||||
private readonly patch = inject(PatchDB<DataModel>)
|
||||
|
||||
@@ -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),
|
||||
data: toNavigationItem('/portal/system/backups'),
|
||||
},
|
||||
{
|
||||
title: systemTabResolver,
|
||||
path: 'settings',
|
||||
loadChildren: () =>
|
||||
import('./settings/settings.routes').then(m => m.SETTINGS_ROUTES),
|
||||
data: toNavigationItem('/portal/system/settings'),
|
||||
},
|
||||
{
|
||||
title: systemTabResolver,
|
||||
path: 'notifications',
|
||||
|
||||
@@ -22,7 +22,7 @@ export class RoutingStrategyService extends BaseRouteReuseStrategy {
|
||||
|
||||
if (!store) this.handlers.delete(path)
|
||||
|
||||
return store
|
||||
return store && path.startsWith('/portal/service')
|
||||
}
|
||||
|
||||
override store(
|
||||
@@ -41,30 +41,14 @@ export class RoutingStrategyService extends BaseRouteReuseStrategy {
|
||||
}
|
||||
|
||||
override shouldReuseRoute(
|
||||
future: ActivatedRouteSnapshot,
|
||||
curr: ActivatedRouteSnapshot,
|
||||
{ routeConfig, params }: ActivatedRouteSnapshot,
|
||||
current: ActivatedRouteSnapshot,
|
||||
): boolean {
|
||||
// return future.routeConfig === curr.routeConfig
|
||||
// TODO: Copied from ionic for backwards compatibility, remove later
|
||||
if (future.routeConfig !== curr.routeConfig) {
|
||||
return false
|
||||
}
|
||||
|
||||
// checking router params
|
||||
const futureParams = future.params
|
||||
const currentParams = curr.params
|
||||
const keysA = Object.keys(futureParams)
|
||||
const keysB = Object.keys(currentParams)
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false
|
||||
}
|
||||
// Test for A's keys different from B.
|
||||
for (const key of keysA) {
|
||||
if (currentParams[key] !== futureParams[key]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
return (
|
||||
routeConfig === current.routeConfig &&
|
||||
Object.keys(params).length === Object.keys(current.params).length &&
|
||||
Object.keys(params).every(key => current.params[key] === params[key])
|
||||
)
|
||||
}
|
||||
|
||||
private getPath(route: ActivatedRouteSnapshot): string {
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
.template {
|
||||
@include transition(opacity);
|
||||
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
font: var(--tui-font-text-l);
|
||||
font: var(--tui-font-text-m);
|
||||
font-weight: bold;
|
||||
|
||||
&_hidden {
|
||||
@@ -41,5 +42,5 @@ small {
|
||||
|
||||
tui-tag {
|
||||
z-index: 1;
|
||||
margin: 0 -0.25rem 0 auto;
|
||||
margin: -0.25rem -0.25rem -0.25rem auto;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<ng-container *ngIf="network$ | async as network">
|
||||
<!-- clearnet -->
|
||||
<ion-item-divider style="--padding-top: 0">Clearnet</ion-item-divider>
|
||||
<h3 class="g-title">Clearnet</h3>
|
||||
<ion-item-group>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
@@ -47,7 +47,7 @@
|
||||
</ion-item-group>
|
||||
|
||||
<!-- tor -->
|
||||
<ion-item-divider>Tor</ion-item-divider>
|
||||
<h3 class="g-title">Tor</h3>
|
||||
<ion-item-group>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
@@ -72,7 +72,7 @@
|
||||
</ion-item-group>
|
||||
|
||||
<!-- local -->
|
||||
<ion-item-divider>Local</ion-item-divider>
|
||||
<h3 class="g-title">Local</h3>
|
||||
<ion-item-group>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
>
|
||||
Restart your server for these updates to take effect. It can take several
|
||||
minutes to come back online.
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
size="s"
|
||||
style="margin-top: 8px"
|
||||
(click)="restart()"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<div>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
size="s"
|
||||
style="margin-top: 8px"
|
||||
(click)="restart()"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -117,7 +117,7 @@ export class ProxyService {
|
||||
buttons: [
|
||||
{
|
||||
text: 'Manage proxies',
|
||||
link: '/system/proxies',
|
||||
link: '/portal/system/settings/proxies',
|
||||
},
|
||||
{
|
||||
text: 'Save',
|
||||
|
||||
@@ -387,6 +387,7 @@ ul {
|
||||
|
||||
td,
|
||||
th {
|
||||
position: relative;
|
||||
font: var(--tui-font-text-s);
|
||||
text-align: left;
|
||||
height: 2rem;
|
||||
@@ -476,10 +477,6 @@ button.g-action {
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
svg:not(:root) {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user