chore: refactor system settings routes (#2853)

* chore: refactor system settings routes

* switch mock to null to see backup page

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
Alex Inkin
2025-03-23 21:03:41 +04:00
committed by GitHub
parent 6f9069a4fb
commit 99739575d4
41 changed files with 1996 additions and 754 deletions

19
web/package-lock.json generated
View File

@@ -46,6 +46,7 @@
"core-js": "^3.21.1",
"cron": "^2.2.0",
"cronstrue": "^2.21.0",
"deep-equality-data-structures": "1.5.1",
"dompurify": "^2.3.6",
"fast-json-patch": "^3.1.1",
"fuse.js": "^6.4.6",
@@ -6931,6 +6932,15 @@
"node": ">=0.10.0"
}
},
"node_modules/deep-equality-data-structures": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-1.5.1.tgz",
"integrity": "sha512-P7zsL2/AbZIGHDxbo/LLEhCp11AttRp8GvzXOXudqMT/qiGCLo/pyI4lAZvjUZyQnlIbPna3fv8DMsuRvLt4ww==",
"license": "MIT",
"dependencies": {
"object-hash": "^3.0.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -11314,6 +11324,15 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",

View File

@@ -69,6 +69,7 @@
"cron": "^2.2.0",
"cronstrue": "^2.21.0",
"dompurify": "^2.3.6",
"deep-equality-data-structures": "1.5.1",
"fast-json-patch": "^3.1.1",
"fuse.js": "^6.4.6",
"jose": "^4.9.0",

View File

@@ -162,3 +162,20 @@ tui-badge-notification {
align-self: center !important;
}
}
// TODO Remove after Taiga UI update
[tuiTitle] {
h1,
h2,
h3,
h4,
h5,
h6 {
font: inherit;
margin: 0;
}
[tuiSubtitle] {
margin: 0;
}
}

View File

@@ -99,7 +99,7 @@ export interface FormContext<T> {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormComponent<T extends Record<string, any>> implements OnInit {
private readonly confirmService = inject(TuiConfirmService)
private readonly confirm = inject(TuiConfirmService, { optional: true })
private readonly formService = inject(FormService)
private readonly invalidService = inject(InvalidService)
private readonly context = inject<TuiDialogContext<void, FormContext<T>>>(
@@ -115,7 +115,7 @@ export class FormComponent<T extends Record<string, any>> implements OnInit {
form = new FormGroup({})
ngOnInit() {
this.confirmService.markAsPristine()
this.confirm?.markAsPristine()
this.form = this.formService.createForm(this.spec, this.value)
this.process(this.operations)
}
@@ -136,7 +136,7 @@ export class FormComponent<T extends Record<string, any>> implements OnInit {
}
markAsDirty() {
this.confirmService.markAsDirty()
this.confirm?.markAsDirty()
}
close() {

View File

@@ -19,10 +19,11 @@ export class UptimeComponent implements OnChanges, OnDestroy {
appUptime = ''
ngOnChanges() {
clearInterval(this.interval)
if (!this.appUptime) {
this.el.textContent = '-'
} else {
clearInterval(this.interval)
this.el.textContent = uptime(new Date(this.appUptime))
this.interval = setInterval(() => {
this.el.textContent = uptime(new Date(this.appUptime))

View File

@@ -8,11 +8,11 @@ import {
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { tuiPure } from '@taiga-ui/cdk'
import { UptimeComponent } from 'src/app/routes/portal/components/uptime.component'
import { ConnectionService } from 'src/app/services/connection.service'
import { PkgDependencyErrors } from 'src/app/services/dep-error.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
import { UptimeComponent } from './uptime.component'
import { ControlsComponent } from './controls.component'
import { StatusComponent } from './status.component'

View File

@@ -33,7 +33,7 @@ const ICONS = {
</tui-avatar>
<span tuiFade>{{ manifest()?.title }}</span>
</div>
<aside>
<aside class="g-aside">
<header tuiCell>
<tui-avatar><img alt="" [src]="service()?.icon" /></tui-avatar>
<span tuiTitle>
@@ -76,23 +76,6 @@ const ICONS = {
}
}
aside {
position: sticky;
top: 1px;
left: 1px;
margin: 1px;
width: 16rem;
padding: 0.5rem;
text-transform: capitalize;
box-shadow: 1px 0 var(--tui-border-normal);
backdrop-filter: blur(1rem);
background-color: color-mix(
in hsl,
var(--tui-background-base) 90%,
transparent
);
}
header {
margin: 0 -0.5rem;
}

View File

@@ -11,6 +11,7 @@ import { isEmptyObject } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { UptimeComponent } from 'src/app/routes/portal/components/uptime.component'
import { ConnectionService } from 'src/app/services/connection.service'
import {
DataModel,
@@ -33,6 +34,9 @@ import { ServiceStatusComponent } from '../components/status.component'
[installingInfo]="pkg().stateInfo.installingInfo"
[status]="status()"
>
@if ($any(pkg().status).started; as started) {
<p class="g-secondary" [appUptime]="started"></p>
}
@if (installed() && connected()) {
<service-actions [pkg]="pkg()" [status]="status()" />
}
@@ -92,6 +96,7 @@ import { ServiceStatusComponent } from '../components/status.component'
ServiceDependenciesComponent,
ServiceErrorComponent,
ServiceActionRequestsComponent,
UptimeComponent,
],
})
export class ServiceRoute {

View File

@@ -1,56 +0,0 @@
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { TuiCell } from '@taiga-ui/layout'
import { SettingBtn } from '../system.types'
@Component({
selector: 'system-button',
template: `
@if (button.action) {
<button tuiCell (click)="button.action()">
<ng-container *ngTemplateOutlet="template" />
</button>
}
@if (button.routerLink) {
<a tuiCell [routerLink]="button.routerLink">
<ng-container *ngTemplateOutlet="template" />
</a>
}
<ng-template #template>
<tui-icon [icon]="button.icon" />
<div tuiTitle>
<strong>{{ button.title }}</strong>
<div tuiSubtitle>{{ button.description }}</div>
<ng-content />
</div>
@if (button.routerLink) {
<tui-icon icon="@tui.chevron-right" />
}
</ng-template>
`,
styles: `
:host {
display: flex;
flex-direction: column;
&:not(:last-child) {
box-shadow: 0 1px var(--tui-background-neutral-1);
}
}
button {
cursor: pointer;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiIcon, TuiTitle, RouterLink, TuiCell],
})
export class SystemButtonComponent {
@Input({ required: true })
button!: SettingBtn
}

View File

@@ -1,114 +0,0 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { TuiAlertService, TuiLoader, TuiButton } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ClientStorageService } from 'src/app/services/client-storage.service'
import { SystemService } from '../system.service'
import { SystemSyncComponent } from './sync.component'
import { SystemButtonComponent } from './button.component'
import { SystemUpdateComponent } from './update.component'
@Component({
selector: 'system-menu',
template: `
@if (data(); as server) {
@if (!server.ntpSynced) {
<system-sync />
}
@for (cat of service.settings | keyvalue: asIsOrder; track $index) {
<section class="g-card">
<header (click)="addClick(cat.key)">{{ cat.key }}</header>
@if (cat.key === 'General') {
<system-update [updated]="server.statusInfo.updated" />
}
@for (btn of cat.value; track $index) {
<system-button [button]="btn">
<!-- // @TODO 041
<div
*ngIf="btn.title === 'Outbound Proxy'"
tuiSubtitle
[style.color]="
!server.network.outboundProxy
? 'var(--tui-status-warning)'
: 'var(--tui-status-positive)'
"
>
{{ server.network.outboundProxy || 'None' }}
</div> -->
</system-button>
}
</section>
}
} @else {
<tui-loader
textContent="Connecting to server"
[style.margin-top.rem]="10"
/>
}
`,
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
TuiLoader,
SystemSyncComponent,
SystemButtonComponent,
SystemUpdateComponent,
],
})
export class SystemMenuComponent {
private readonly clientStorageService = inject(ClientStorageService)
private readonly alerts = inject(TuiAlertService)
readonly service = inject(SystemService)
readonly data = toSignal(
inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo'),
)
manageClicks = 0
powerClicks = 0
addClick(title: string) {
switch (title) {
case 'Security':
this.addSecurityClick()
break
case 'Power':
this.addPowerClick()
break
default:
return
}
}
asIsOrder() {
return 0
}
private addSecurityClick() {
this.manageClicks++
if (this.manageClicks === 5) {
this.manageClicks = 0
this.alerts
.open(
this.clientStorageService.toggleShowDevTools()
? 'Dev tools unlocked'
: 'Dev tools hidden',
)
.subscribe()
}
}
private addPowerClick() {
this.powerClicks++
if (this.powerClicks === 5) {
this.powerClicks = 0
this.clientStorageService.toggleShowDiskRepair()
}
}
}

View File

@@ -1,34 +0,0 @@
import { TuiCell } from '@taiga-ui/layout'
import { TuiTitle, TuiButton, TuiNotification } from '@taiga-ui/core'
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: 'system-sync',
template: `
<tui-notification appearance="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="secondary-grayscale"
iconEnd="@tui.external-link"
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: [TuiButton, TuiCell, TuiNotification, TuiTitle],
})
export class SystemSyncComponent {}

View File

@@ -1,109 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { CommonModule } from '@angular/common'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogService, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiCell } from '@taiga-ui/layout'
import { EOSService } from 'src/app/services/eos.service'
import { UPDATE } from '../modals/update.component'
@Component({
selector: 'system-update',
template: `
<button
tuiCell
[disabled]="service.updatingOrBackingUp$ | async"
(click)="onClick()"
>
<tui-icon icon="@tui.cloud-download" />
<div tuiTitle>
<strong>Software Update</strong>
<div tuiSubtitle>Get the latest version of StartOS</div>
@if (updated) {
<div tuiSubtitle class="g-warning">
Update Complete. Restart to apply changes
</div>
} @else {
@if (service.showUpdate$ | async) {
<div tuiSubtitle class="g-positive">
<tui-icon class="small" icon="@tui.zap" />
Update Available
</div>
} @else {
<div tuiSubtitle class="g-info">
<tui-icon class="small" icon="@tui.rotate-cw" />
Check for updates
</div>
}
}
</div>
</button>
`,
styles: `
:host {
display: flex;
flex-direction: column;
box-shadow: 0 1px var(--tui-background-neutral-1);
}
button {
cursor: pointer;
}
.small {
font-size: 1rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiIcon, TuiTitle, TuiCell],
})
export class SystemUpdateComponent {
private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
readonly service = inject(EOSService)
@Input()
updated = false
onClick() {
this.service.updateAvailable$.value ? this.update() : this.check()
}
private update() {
this.dialogs.open(UPDATE).subscribe()
}
private async check(): Promise<void> {
const loader = this.loader.open('Checking for updates').subscribe()
try {
await this.service.loadEos()
if (this.service.updateAvailable$.value) {
this.update()
} else {
this.showLatest()
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private showLatest() {
this.dialogs
.open('You are on the latest version of StartOS.', {
label: 'Up to date!',
size: 's',
})
.subscribe()
}
}

View File

@@ -12,9 +12,9 @@ import { TuiLink, TuiNotification } from '@taiga-ui/core'
href="https://docs.start9.com/latest/user-manual/acme"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
iconEnd="@tui.external-link"
[textContent]="'View instructions'"
></a>
</tui-notification>
`,
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -0,0 +1,191 @@
import { Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import * as argon2 from '@start9labs/argon2'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { TuiButton, TuiGroup, TuiLoader, TuiTitle } from '@taiga-ui/core'
import { TuiBlock, TuiCheckbox } from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { firstValueFrom, map } from 'rxjs'
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
import { getServerInfo } from 'src/app/utils/get-server-info'
import { verifyPassword } from 'src/app/utils/verify-password'
import { PASSWORD_OPTIONS } from './backup.const'
import { BackupService } from './backup.service'
import { BackupContext } from './backup.types'
interface Package {
id: string
title: string
icon: string
disabled: boolean
checked: boolean
}
@Component({
template: `
<div tuiGroup orientation="vertical" [collapsed]="true">
@if (pkgs) {
@for (pkg of pkgs; track $index) {
<label tuiBlock="m">
<img alt="" [src]="pkg.icon" />
<span tuiTitle>{{ pkg.title }}</span>
<input
type="checkbox"
tuiCheckbox
[disabled]="pkg.disabled"
[(ngModel)]="pkg.checked"
(ngModelChange)="handleChange()"
/>
</label>
} @empty {
No services installed!
}
} @else {
<tui-loader />
}
</div>
<footer class="g-buttons">
<button tuiButton appearance="flat-grayscale" (click)="toggleSelectAll()">
Toggle all
</button>
<button tuiButton [disabled]="!hasSelection" (click)="done()">
Done
</button>
</footer>
`,
styles: [
`
[tuiGroup] {
width: 100%;
margin: 1.5rem 0 0;
}
[tuiBlock] {
align-items: center;
}
img {
width: 2.5rem;
border-radius: 100%;
}
`,
],
standalone: true,
imports: [
FormsModule,
TuiButton,
TuiGroup,
TuiLoader,
TuiBlock,
TuiCheckbox,
TuiTitle,
],
})
export class BackupsBackupComponent {
private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly service = inject(BackupService)
readonly context = injectContext<BackupContext>()
hasSelection = false
pkgs: readonly Package[] | null = null
async ngOnInit() {
this.pkgs = await firstValueFrom(
this.patch.watch$('packageData').pipe(
map(pkgs =>
Object.values(pkgs)
.map(pkg => {
const { id, title } = getManifest(pkg)
return {
id,
title,
icon: pkg.icon,
disabled: pkg.stateInfo.state !== 'installed',
checked: false,
}
})
.sort((a, b) =>
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
),
),
),
)
}
async done() {
const { passwordHash, id } = await getServerInfo(this.patch)
const { entry } = this.context.data
this.dialogs
.open<string>(PROMPT, PASSWORD_OPTIONS)
.pipe(verifyPassword(passwordHash, e => this.errorService.handleError(e)))
.subscribe(async password => {
// first time backup
if (!this.service.hasThisBackup(entry, id)) {
this.createBackup(password)
// existing backup
} else {
try {
argon2.verify(entry.startOs[id].passwordHash!, password)
await this.createBackup(password)
} catch {
this.oldPassword(password)
}
}
})
}
handleChange() {
this.hasSelection = !!this.pkgs?.some(p => p.checked)
}
toggleSelectAll() {
this.pkgs?.forEach(p => (p.checked = !this.hasSelection && !p.disabled))
this.hasSelection = !this.hasSelection
}
private async oldPassword(password: string) {
const { id } = await getServerInfo(this.patch)
const { passwordHash } = this.context.data.entry.startOs[id]
this.dialogs
.open<string>(PROMPT, PASSWORD_OPTIONS)
.pipe(verifyPassword(passwordHash, e => this.errorService.handleError(e)))
.subscribe(oldPassword => this.createBackup(password, oldPassword))
}
private async createBackup(
password: string,
oldPassword: string | null = null,
) {
const loader = this.loader.open('Beginning backup...').subscribe()
const packageIds = this.pkgs?.filter(p => p.checked).map(p => p.id) || []
const params = {
targetId: this.context.data.id,
packageIds,
oldPassword,
password,
}
try {
await this.api.createBackup(params)
this.context.$implicit.complete()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}
export const BACKUP = new PolymorpheusComponent(BackupsBackupComponent)

View File

@@ -0,0 +1,35 @@
import { TuiDialogOptions } from '@taiga-ui/core'
import { PromptOptions } from 'src/app/routes/portal/modals/prompt.component'
export const PASSWORD_OPTIONS: Partial<TuiDialogOptions<PromptOptions>> = {
label: 'Master Password Needed',
data: {
message: 'Enter your master password to encrypt this backup.',
label: 'Master Password',
placeholder: 'Enter master password',
useMask: true,
buttonText: 'Create Backup',
},
}
export const OLD_OPTIONS: Partial<TuiDialogOptions<PromptOptions>> = {
label: 'Original Password Needed',
data: {
message:
'This backup was created with a different password. Enter the ORIGINAL password that was used to encrypt this backup.',
label: 'Original Password',
placeholder: 'Enter original password',
useMask: true,
buttonText: 'Create Backup',
},
}
export const RESTORE_OPTIONS: Partial<TuiDialogOptions<PromptOptions>> = {
label: 'Password Required',
data: {
message: `Enter the master password that was used to encrypt this backup. On the next screen, you will select the individual services you want to restore.`,
label: 'Master Password',
placeholder: 'Enter master password',
useMask: true,
},
}

View File

@@ -0,0 +1,77 @@
import { inject, Injectable, signal } from '@angular/core'
import { ErrorService, getErrorMessage } from '@start9labs/shared'
import { Version } from '@start9labs/start-sdk'
import {
BackupTarget,
CifsBackupTarget,
DiskBackupTarget,
} from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
export interface MappedBackupTarget<T> {
id: string
hasAnyBackup: boolean
entry: T
}
@Injectable({
providedIn: 'root',
})
export class BackupService {
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
readonly cifs = signal<MappedBackupTarget<CifsBackupTarget>[]>([])
readonly drives = signal<MappedBackupTarget<DiskBackupTarget>[]>([])
readonly loading = signal(true)
async getBackupTargets(): Promise<void> {
this.loading.set(true)
try {
const targets = await this.api.getBackupTargets({})
this.cifs.set(
Object.entries(targets)
.filter(([_, target]) => target.type === 'cifs')
.map(([id, cifs]) => {
return {
id,
hasAnyBackup: this.hasAnyBackup(cifs),
entry: cifs as CifsBackupTarget,
}
}),
)
this.drives.set(
Object.entries(targets)
.filter(([_, target]) => target.type === 'disk')
.map(([id, drive]) => {
return {
id,
hasAnyBackup: this.hasAnyBackup(drive),
entry: drive as DiskBackupTarget,
}
}),
)
} catch (e: any) {
this.errorService.handleError(getErrorMessage(e))
} finally {
this.loading.set(false)
}
}
hasAnyBackup({ startOs }: BackupTarget): boolean {
return Object.values(startOs).some(
s => Version.parse(s.version).compare(Version.parse('0.3.6')) !== 'less',
)
}
hasThisBackup({ startOs }: BackupTarget, id: string): boolean {
return (
startOs[id] &&
Version.parse(startOs[id].version).compare(Version.parse('0.3.6')) !==
'less'
)
}
}

View File

@@ -0,0 +1,27 @@
import { TuiDialogContext } from '@taiga-ui/core'
import {
BackupInfo,
CifsBackupTarget,
DiskBackupTarget,
PackageBackupInfo,
} from 'src/app/services/api/api.types'
import { MappedBackupTarget } from './backup.service'
export type BackupContext = TuiDialogContext<
void,
MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>
>
export interface RecoverOption extends PackageBackupInfo {
id: string
checked: boolean
installed: boolean
newerOs: boolean
}
export interface RecoverData {
targetId: string
serverId: string
backupInfo: BackupInfo
password: string
}

View File

@@ -0,0 +1,111 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
} from '@angular/core'
import { ActivatedRoute, RouterLink } from '@angular/router'
import { UnitConversionPipesModule } from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { TuiButton, TuiLink, TuiLoader } from '@taiga-ui/core'
import { BACKUP } from 'src/app/routes/portal/routes/system/routes/backups/backup.component'
import {
CifsBackupTarget,
DiskBackupTarget,
} from 'src/app/services/api/api.types'
import { EOSService } from 'src/app/services/eos.service'
import { TitleDirective } from 'src/app/services/title.service'
import { BackupService, MappedBackupTarget } from './backup.service'
import { BackupNetworkComponent } from './network.component'
import { BackupPhysicalComponent } from './physical.component'
import { BackupProgressComponent } from './progress.component'
import { BACKUP_RESTORE } from './restore.component'
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
{{ type === 'create' ? 'Create Backup' : 'Restore Backup' }}
</ng-container>
@if (type === 'create' && (eos.backingUp$ | async)) {
<section backupProgress></section>
} @else {
@if (service.loading()) {
<tui-loader
textContent="Fetching backups"
size="l"
[style.height.rem]="20"
/>
} @else {
<section (networkFolders)="onTarget($event)">
{{ text }}
a folder on another computer that is connected to the same network as
your Start9 server. View the
<a
tuiLink
href="https://docs.start9.com/0.3.5.x/user-manual/backups/backup-create#network-folder"
target="_blank"
rel="noreferrer"
iconEnd="@tui.external-link"
[textContent]="'Instructions'"
></a>
</section>
<section (physicalFolders)="onTarget($event)">
{{ text }}
a physical drive that is plugged directly into your Start9 Server.
View the
<a
tuiLink
href="https://docs.start9.com/0.3.5.x/user-manual/backups/backup-create#physical-drive"
target="_blank"
rel="noreferrer"
iconEnd="@tui.external-link"
[textContent]="'Instructions'"
></a>
</section>
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
RouterLink,
TuiButton,
TuiLoader,
TuiLink,
TitleDirective,
UnitConversionPipesModule,
BackupNetworkComponent,
BackupPhysicalComponent,
AsyncPipe,
BackupProgressComponent,
],
})
export default class SystemBackupComponent implements OnInit {
readonly dialogs = inject(TuiResponsiveDialogService)
readonly type = inject(ActivatedRoute).snapshot.data['type']
readonly service = inject(BackupService)
readonly eos = inject(EOSService)
get text() {
return this.type === 'create'
? 'Backup server to'
: 'Restore your services from'
}
ngOnInit() {
this.service.getBackupTargets()
}
onTarget(target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>) {
const component = this.type === 'create' ? BACKUP : BACKUP_RESTORE
const label =
this.type === 'create'
? 'Select Services to Back Up'
: 'Select server backup'
this.dialogs.open(component, { label, data: target }).subscribe()
}
}

View File

@@ -0,0 +1,259 @@
import {
ChangeDetectionStrategy,
Component,
inject,
output,
} from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { ISB } from '@start9labs/start-sdk'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { TuiAlertService, TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TUI_CONFIRM, TuiTooltip } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { filter } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { CifsBackupTarget, RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { BackupService, MappedBackupTarget } from './backup.service'
import { BackupStatusComponent } from './status.component'
const ERROR =
'Ensure (1) target computer is connected to the same LAN as your Start9 Server, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.'
@Component({
standalone: true,
selector: '[networkFolders]',
template: `
<header>
Network Folders
<tui-icon [tuiTooltip]="cifs" />
<ng-template #cifs><ng-content /></ng-template>
<button tuiButton size="s" iconStart="@tui.plus" (click)="add()">
Open New
</button>
</header>
@for (target of service.cifs(); track $index) {
<button tuiCell (click)="select(target)">
<tui-icon icon="@tui.folder-open" />
<span tuiTitle>
<strong>{{ target.entry.path.split('/').pop() }}</strong>
@if (target.entry.mountable) {
<span tuiSubtitle [backupStatus]="target.hasAnyBackup"></span>
} @else {
<span tuiSubtitle>
<tui-icon
icon="@tui.signal-high"
class="g-negative"
[style.font-size.rem]="1"
/>
Unable to connect
</span>
}
<span tuiSubtitle>
<b>Hostname:</b>
{{ target.entry.hostname }}
</span>
<span tuiSubtitle>
<b>Path:</b>
{{ target.entry.path }}
</span>
</span>
<button
tuiIconButton
appearance="action-destructive"
iconStart="@tui.trash"
(click.stop)="forget(target, $index)"
>
Forget
</button>
<button
tuiIconButton
appearance="icon"
size="xs"
iconStart="@tui.pencil"
(click.stop)="edit(target)"
>
Edit
</button>
</button>
} @empty {
<app-placeholder icon="@tui.folder-x">No network folders</app-placeholder>
}
`,
styles: `
[tuiButton] {
margin-inline-start: auto;
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
TuiCell,
TuiIcon,
TuiTitle,
TuiTooltip,
PlaceholderComponent,
BackupStatusComponent,
],
})
export class BackupNetworkComponent {
private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly alerts = inject(TuiAlertService)
private readonly formDialog = inject(FormDialogService)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly type = inject(ActivatedRoute).snapshot.data['type']
readonly service = inject(BackupService)
readonly networkFolders = output<MappedBackupTarget<CifsBackupTarget>>()
select(target: MappedBackupTarget<CifsBackupTarget>) {
if (!target.entry.mountable) {
this.alerts
.open(ERROR, {
appearance: 'negative',
label: 'Unable to connect',
autoClose: 0,
})
.subscribe()
} else if (this.type === 'restore' && !target.hasAnyBackup) {
this.alerts
.open('Network Folder does not contain a valid backup', {
appearance: 'negative',
})
.subscribe()
} else {
this.networkFolders.emit(target)
}
}
async add() {
this.formDialog.open(FormComponent, {
label: 'New Network Folder',
data: {
spec: await configBuilderToSpec(cifsSpec),
buttons: [
{
text: 'Execute',
handler: (value: RR.AddBackupTargetReq) => this.addTarget(value),
},
],
},
})
}
async edit(target: MappedBackupTarget<CifsBackupTarget>) {
this.formDialog.open(FormComponent, {
label: 'Update Network Folder',
data: {
spec: await configBuilderToSpec(cifsSpec),
buttons: [
{
text: 'Execute',
handler: async (value: RR.AddBackupTargetReq) => {
const loader = this.loader
.open('Testing connectivity to shared folder...')
.subscribe()
try {
const res = await this.api.updateBackupTarget({
id: target.id,
...value,
})
target.entry = Object.values(res)[0]
this.service.cifs.update(cifs => [...cifs])
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
},
},
],
value: { ...target.entry },
},
})
}
forget({ id }: MappedBackupTarget<CifsBackupTarget>, index: number) {
this.dialogs
.open(TUI_CONFIRM, { label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Removing...').subscribe()
try {
await this.api.removeBackupTarget({ id })
this.service.cifs.update(cifs => cifs.filter((_, i) => i !== index))
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
private async addTarget(v: RR.AddBackupTargetReq): Promise<boolean> {
const loader = this.loader
.open('Testing connectivity to shared folder...')
.subscribe()
try {
const [[id, entry]] = Object.entries(await this.api.addBackupTarget(v))
const hasAnyBackup = this.service.hasAnyBackup(entry)
const added = { id, entry, hasAnyBackup }
this.service.cifs.update(cifs => [added, ...cifs])
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}
const cifsSpec = ISB.InputSpec.of({
hostname: ISB.Value.text({
name: 'Hostname',
description:
'The hostname of your target device on the Local Area Network.',
warning: null,
placeholder: `e.g. 'My Computer' OR 'my-computer.local'`,
required: true,
default: null,
patterns: [],
}),
path: ISB.Value.text({
name: 'Path',
description: `On Windows, this is the fully qualified path to the shared folder, (e.g. /Desktop/my-folder).\n\n On Linux and Mac, this is the literal name of the shared folder (e.g. my-shared-folder).`,
placeholder: 'e.g. my-shared-folder or /Desktop/my-folder',
required: true,
default: null,
}),
username: ISB.Value.text({
name: 'Username',
description: `On Linux, this is the samba username you created when sharing the folder.\n\n On Mac and Windows, this is the username of the user who is sharing the folder.`,
required: true,
default: null,
placeholder: 'My Network Folder',
}),
password: ISB.Value.text({
name: 'Password',
description: `On Linux, this is the samba password you created when sharing the folder.\n\n On Mac and Windows, this is the password of the user who is sharing the folder.`,
required: false,
default: null,
masked: true,
placeholder: 'My Network Folder',
}),
})

View File

@@ -0,0 +1,105 @@
import {
ChangeDetectionStrategy,
Component,
inject,
output,
} from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { UnitConversionPipesModule } from '@start9labs/shared'
import {
TuiAlertService,
TuiButton,
TuiIcon,
TuiNotification,
TuiTitle,
} from '@taiga-ui/core'
import { TuiTooltip } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { DiskBackupTarget } from 'src/app/services/api/api.types'
import { BackupService, MappedBackupTarget } from './backup.service'
import { BackupStatusComponent } from './status.component'
@Component({
standalone: true,
selector: '[physicalFolders]',
template: `
<header>
Physical Drives
<tui-icon [tuiTooltip]="drives" />
<ng-template #drives><ng-content /></ng-template>
</header>
<tui-notification appearance="warning">
Warning. Do not use this option if you are using a Raspberry Pi with an
external SSD. The Raspberry Pi does not support more than one external
drive without additional power and can cause data corruption.
</tui-notification>
@for (target of service.drives(); track $index) {
<button tuiCell (click)="select(target)">
<tui-icon icon="@tui.save" />
<span tuiTitle>
<strong>{{ target.entry.label || target.entry.logicalname }}</strong>
<span tuiSubtitle [backupStatus]="target.hasAnyBackup"></span>
<span tuiSubtitle>
{{ target.entry.vendor || 'Unknown Vendor' }} -
{{ target.entry.model || 'Unknown Model' }}
</span>
<span tuiSubtitle>
<b>Capacity:</b>
{{ target.entry.capacity | convertBytes }}
</span>
</span>
</button>
} @empty {
<app-placeholder icon="@tui.save-off">
No drives detected
<button
tuiButton
iconStart="@tui.refresh-cw"
(click)="service.getBackupTargets()"
>
Refresh
</button>
</app-placeholder>
}
`,
styles: `
tui-notification {
margin: 0.5rem 0 0.75rem;
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
TuiCell,
TuiIcon,
TuiTitle,
TuiTooltip,
TuiNotification,
UnitConversionPipesModule,
PlaceholderComponent,
BackupStatusComponent,
],
})
export class BackupPhysicalComponent {
private readonly alerts = inject(TuiAlertService)
private readonly type = inject(ActivatedRoute).snapshot.data['type']
readonly service = inject(BackupService)
readonly physicalFolders = output<MappedBackupTarget<DiskBackupTarget>>()
select(target: MappedBackupTarget<DiskBackupTarget>) {
if (this.type === 'restore' && !target.hasAnyBackup) {
this.alerts
.open('Drive partition does not contain a valid backup', {
appearance: 'negative',
})
.subscribe()
} else {
this.physicalFolders.emit(target)
}
}
}

View File

@@ -0,0 +1,68 @@
import { AsyncPipe, KeyValuePipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { TuiMapperPipe } from '@taiga-ui/cdk'
import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core'
import { TuiAvatar } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { take } from 'rxjs'
import { ToManifestPipe } from 'src/app/routes/portal/pipes/to-manifest'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
standalone: true,
selector: '[backupProgress]',
template: `
<header>Backup Progress</header>
@for (pkg of pkgs() | keyvalue; track $index) {
@if (backupProgress()?.[pkg.key]; as progress) {
<div tuiCell>
<tui-avatar>
<img alt="" [src]="pkg.value.icon" />
</tui-avatar>
<span tuiTitle>
{{ (pkg.value | toManifest).title }}
<span tuiSubtitle>
@if (progress.complete) {
<tui-icon icon="@tui.check" class="g-positive" />
Complete
} @else {
@if ((pkg.key | tuiMapper: toStatus | async) === 'backingUp') {
<tui-loader size="s" />
Backing up
} @else {
Waiting...
}
}
</span>
</span>
</div>
}
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
KeyValuePipe,
AsyncPipe,
TuiCell,
TuiAvatar,
TuiTitle,
TuiIcon,
TuiLoader,
TuiMapperPipe,
ToManifestPipe,
],
})
export class BackupProgressComponent {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
readonly pkgs = toSignal(this.patch.watch$('packageData').pipe(take(1)))
readonly backupProgress = toSignal(
this.patch.watch$('serverInfo', 'statusInfo', 'backupProgress'),
)
readonly toStatus = (pkgId: string) =>
this.patch.watch$('packageData', pkgId, 'status', 'main')
}

View File

@@ -0,0 +1,154 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { Version } from '@start9labs/start-sdk'
import { TuiMapperPipe } from '@taiga-ui/cdk'
import { TuiButton, TuiDialogContext, TuiGroup, TuiTitle } from '@taiga-ui/core'
import { TuiBlock, TuiCheckbox } from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { map, take } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { RecoverData, RecoverOption } from './backup.types'
@Component({
template: `
@if (packageData(); as options) {
<div tuiGroup orientation="vertical" [collapsed]="true">
@for (option of options; track $index) {
<label tuiBlock>
<span tuiTitle>
<strong>{{ option.title }}</strong>
<span tuiSubtitle>Version {{ option.version }}</span>
<span tuiSubtitle>
Backup made: {{ option.timestamp | date: 'medium' }}
</span>
@if (option | tuiMapper: toMessage; as message) {
<span [style.color]="message.color">{{ message.text }}</span>
}
</span>
<input
type="checkbox"
tuiCheckbox
[disabled]="option.installed || option.newerOs"
[(ngModel)]="option.checked"
/>
</label>
}
</div>
<footer class="g-buttons">
<button
tuiButton
[disabled]="isDisabled(options)"
(click)="restore(options)"
>
Restore Selected
</button>
</footer>
}
`,
styles: `
[tuiGroup] {
width: 100%;
margin: 1.5rem 0 0;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
FormsModule,
TuiButton,
TuiGroup,
TuiMapperPipe,
TuiCheckbox,
TuiBlock,
TuiTitle,
],
})
export class BackupsRecoverComponent {
private readonly config = inject(ConfigService)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly router = inject(Router)
private readonly context =
injectContext<TuiDialogContext<void, RecoverData>>()
readonly packageData = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData')
.pipe(
take(1),
map(packageData => {
const backups = this.context.data.backupInfo.packageBackups
return Object.keys(backups)
.map(id => ({
...backups[id],
id,
installed: !!packageData[id],
checked: false,
newerOs:
Version.parse(backups[id].osVersion).compare(
Version.parse(this.config.version),
) === 'greater',
}))
.sort((a, b) =>
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
)
}),
),
)
readonly toMessage = ({ newerOs, installed, title }: RecoverOption) => {
if (newerOs) {
return {
text: `Unavailable. Backup was made on a newer version of StartOS.`,
color: 'var(--tui-status-negative)',
}
}
if (installed) {
return {
text: `Unavailable. ${title} is already installed.`,
color: 'var(--tui-status-warning)',
}
}
return {
text: 'Ready to restore',
color: 'var(--tui-status-positive)',
}
}
isDisabled(options: RecoverOption[]): boolean {
return options.every(o => !o.checked)
}
async restore(options: RecoverOption[]): Promise<void> {
const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id)
const { targetId, serverId, password } = this.context.data
const params = { ids, targetId, serverId, password }
const loader = this.loader.open('Initializing...').subscribe()
try {
await this.api.restorePackages(params)
this.context.$implicit.complete()
this.router.navigate(['portal', 'services'])
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}
export const RECOVER = new PolymorpheusComponent(BackupsRecoverComponent)

View File

@@ -0,0 +1,88 @@
import { DatePipe, KeyValuePipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import {
ErrorService,
LoadingService,
StartOSDiskInfo,
} from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { TuiTitle } from '@taiga-ui/core'
import { TuiCell } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { verifyPassword } from 'src/app/utils/verify-password'
import { RESTORE_OPTIONS } from './backup.const'
import { BackupContext } from './backup.types'
import { RECOVER } from './recover.component'
@Component({
standalone: true,
template: `
@for (server of target.entry.startOs | keyvalue; track $index) {
<button tuiCell (click)="onClick(server.key, server.value)">
<span tuiTitle>
<span tuiSubtitle>
<b>Local Hostname</b>
: {{ server.value.hostname }}.local
</span>
<span tuiSubtitle>
<b>StartOS Version</b>
: {{ server.value.version }}
</span>
<span tuiSubtitle>
<b>Created</b>
: {{ server.value.timestamp | date: 'medium' }}
</span>
</span>
</button>
}
`,
styles: `
[tuiCell] {
width: stretch;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [KeyValuePipe, DatePipe, TuiCell, TuiTitle],
})
export class BackupRestoreComponent {
private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly loader = inject(LoadingService)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly context = injectContext<BackupContext>()
readonly target = this.context.data
onClick(serverId: string, { passwordHash }: StartOSDiskInfo) {
this.dialogs
.open<string>(PROMPT, RESTORE_OPTIONS)
.pipe(verifyPassword(passwordHash, e => this.errorService.handleError(e)))
.subscribe(async password => await this.restore(serverId, password))
}
private async restore(serverId: string, password: string): Promise<void> {
const loader = this.loader.open('Decrypting drive...').subscribe()
const params = { targetId: this.target.id, serverId, password }
try {
const backupInfo = await this.api.getBackupInfo(params)
const data = {
targetId: this.target.id,
serverId,
backupInfo,
password,
}
this.context.$implicit.complete()
this.dialogs
.open(RECOVER, { label: 'Select Services to Restore', data })
.subscribe()
} finally {
loader.unsubscribe()
}
}
}
export const BACKUP_RESTORE = new PolymorpheusComponent(BackupRestoreComponent)

View File

@@ -0,0 +1,42 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { TuiIcon } from '@taiga-ui/core'
@Component({
standalone: true,
selector: '[backupStatus]',
template: `
@if (type === 'create') {
<tui-icon icon="@tui.cloud" class="g-positive" />
Available for backup
} @else {
@if (backupStatus()) {
<tui-icon icon="@tui.cloud-upload" class="g-positive" />
StartOS backups detected
} @else {
<tui-icon icon="@tui.cloud-off" class="g-negative" />
No StartOS backups
}
}
`,
styles: `
:host {
color: var(--tui-text-primary);
}
tui-icon {
font-size: 1rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIcon],
})
export class BackupStatusComponent {
readonly type = inject(ActivatedRoute).snapshot.data['type']
readonly backupStatus = input(false)
}

View File

@@ -71,6 +71,17 @@ import { EmailInfoComponent } from './info.component'
</form>
</ng-container>
`,
styles: `
:host {
display: grid !important;
grid-template-columns: 1fr 1fr;
align-items: start;
}
:host-context(tui-root._mobile) {
grid-template-columns: 1fr;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [

View File

@@ -12,11 +12,20 @@ import { TuiLink, TuiNotification } from '@taiga-ui/core'
href="https://docs.start9.com/latest/user-manual/smtp"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
iconEnd="@tui.external-link"
[textContent]="'View instructions'"
></a>
</tui-notification>
`,
styles: `
:host {
grid-column: span 2;
}
:host-context(tui-root._mobile) {
grid-column: 1;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiNotification, TuiLink],

View File

@@ -0,0 +1,285 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
INJECTOR,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import {
TuiAlertService,
TuiAppearance,
TuiButton,
tuiFadeIn,
TuiIcon,
tuiScaleIn,
TuiTitle,
} from '@taiga-ui/core'
import { TUI_CONFIRM } from '@taiga-ui/kit'
import { TuiCell, tuiCellOptionsProvider, TuiHeader } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs'
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { EOSService } from 'src/app/services/eos.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { SystemSyncComponent } from './sync.component'
import { UPDATE } from './update.component'
import { SystemWipeComponent } from './wipe.component'
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
General Settings
</ng-container>
<header tuiHeader>
<hgroup tuiTitle>
<h3>General</h3>
<p tuiSubtitle>Manage your overall setup and preferences</p>
</hgroup>
</header>
@if (server(); as server) {
@if (!server.ntpSynced) {
<system-sync />
}
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.zap" />
<span tuiTitle>
<strong>Software Update</strong>
<span tuiSubtitle>{{ server.version }}</span>
</span>
<button
tuiButton
appearance="accent"
[disabled]="eos.updatingOrBackingUp$ | async"
(click)="onUpdate()"
>
@if (server.statusInfo.updated) {
Restart to apply
} @else {
@if (eos.showUpdate$ | async) {
Update
} @else {
Check for updates
}
}
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.app-window" />
<span tuiTitle>
<strong>Browser Tab Title</strong>
<span tuiSubtitle>{{ name() }}</span>
</span>
<button tuiButton (click)="onTitle()">Change</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.languages" />
<span tuiTitle>
<strong>Language</strong>
<span tuiSubtitle>English</span>
</span>
<button tuiButton>Change</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.circle-power" (click)="count = count + 1" />
<span tuiTitle>
<strong>Reset Tor</strong>
<span tuiSubtitle>Restart the Tor daemon on your server</span>
</span>
<button tuiButton appearance="glass" (click)="onReset()">Reset</button>
</div>
@if (count > 4) {
<div tuiCell tuiAppearance="outline-grayscale" @tuiScaleIn @tuiFadeIn>
<tui-icon icon="@tui.briefcase-medical" />
<span tuiTitle>
<strong>Disk Repair</strong>
<span tuiSubtitle>Attempt automatic repair</span>
</span>
<button tuiButton appearance="glass" (click)="onRepair()">
Repair
</button>
</div>
}
}
`,
styles: `
:host {
max-inline-size: 40rem;
}
strong {
line-height: 1.25rem;
}
[tuiCell] {
background: var(--tui-background-neutral-1);
}
`,
providers: [tuiCellOptionsProvider({ height: 'spacious' })],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [tuiScaleIn, tuiFadeIn],
standalone: true,
imports: [
AsyncPipe,
RouterLink,
TuiTitle,
TuiHeader,
TuiCell,
TuiAppearance,
TuiButton,
TitleDirective,
SystemSyncComponent,
TuiIcon,
],
})
export default class SystemGeneralComponent {
private readonly alerts = inject(TuiAlertService)
private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
private readonly isTor = inject(ConfigService).isTor()
private readonly reset = new PolymorpheusComponent(
SystemWipeComponent,
inject(INJECTOR),
)
wipe = false
count = 0
readonly server = toSignal(this.patch.watch$('serverInfo'))
readonly name = toSignal(this.patch.watch$('ui', 'name'))
readonly eos = inject(EOSService)
onUpdate() {
if (this.server()?.statusInfo.updated) {
this.restart()
} else if (this.eos.updateAvailable$.value) {
this.update()
} else {
this.check()
}
}
onTitle() {
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: this.name(),
},
})
.subscribe(async name => {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.setDbValue(['name'], name || null)
} finally {
loader.unsubscribe()
}
})
}
onReset() {
this.wipe = false
this.dialogs
.open(TUI_CONFIRM, {
label: this.isTor ? 'Warning' : 'Confirm',
data: {
content: this.reset,
yes: 'Reset',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.resetTor(this.wipe))
}
async onRepair() {
this.dialogs
.open(TUI_CONFIRM, {
label: 'Warning',
data: {
content: `<p>This action should only be executed if directed by a Start9 support specialist. We recommend backing up your device before preforming this action.</p><p>If anything happens to the device during the reboot, such as losing power or unplugging the drive, the filesystem <i>will</i> be in an unrecoverable state. Please proceed with caution.</p>`,
yes: 'Repair',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(async () => {
try {
await this.api.repairDisk({})
this.restart()
} catch (e: any) {
this.errorService.handleError(e)
}
})
}
private async resetTor(wipeState: boolean) {
const loader = this.loader.open('Resetting Tor...').subscribe()
try {
await this.api.resetTor({ wipeState, reason: 'User triggered' })
this.alerts.open('Tor reset in progress').subscribe()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private update() {
this.dialogs.open(UPDATE).subscribe()
}
private async check(): Promise<void> {
const loader = this.loader.open('Checking for updates').subscribe()
try {
await this.eos.loadEos()
if (this.eos.updateAvailable$.value) {
this.update()
} else {
this.dialogs
.open('You are on the latest version of StartOS.', {
label: 'Up to date!',
size: 's',
})
.subscribe()
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async restart() {
const loader = this.loader.open(`Beginning restart...`).subscribe()
try {
await this.api.restartServer({})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,29 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiLink, TuiNotification, TuiTitle } from '@taiga-ui/core'
@Component({
selector: 'system-sync',
template: `
<tui-notification appearance="warning">
<div tuiTitle>
Clock sync failure
<div tuiSubtitle>
This will cause connectivity issues. Refer to the
<a
tuiLink
iconEnd="@tui.external-link"
href="https://docs.start9.com/0.3.5.x/support/common-issues#clock-sync-failure"
target="_blank"
rel="noreferrer"
[textContent]="'StartOS docs'"
></a>
to resolve it
</div>
</div>
</tui-notification>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiNotification, TuiTitle, TuiLink],
})
export class SystemSyncComponent {}

View File

@@ -23,12 +23,10 @@ import { EOSService } from 'src/app/services/eos.service'
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>
@for (v of versions; track $index) {
<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

View File

@@ -0,0 +1,34 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { TuiLabel } from '@taiga-ui/core'
import { TuiCheckbox } from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import SystemGeneralComponent from './general.component'
@Component({
standalone: true,
template: `
<p>
@if (isTor) {
You are currently connected over Tor. If you reset the Tor daemon, you
will lose connectivity until it comes back online.
} @else {
Reset Tor?
}
</p>
<p>
Optionally wipe state to forcibly acquire new guard nodes. It is
recommended to try without wiping state first.
</p>
<label tuiLabel>
<input type="checkbox" tuiCheckbox [(ngModel)]="component.wipe" />
Wipe state
</label>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiLabel, FormsModule, TuiCheckbox],
})
export class SystemWipeComponent {
readonly isTor = inject(ConfigService).isTor()
readonly component = inject(SystemGeneralComponent)
}

View File

@@ -1,15 +1,12 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { T } from '@start9labs/start-sdk'
import { TuiButton } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { Observable, map } from 'rxjs'
import { map } from 'rxjs'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import {
getAddresses,
MappedServiceInterface,
} from 'src/app/routes/portal/components/interfaces/interface.utils'
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
@@ -37,34 +34,26 @@ const iface: T.ServiceInterface = {
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
Web Addresses
</ng-container>
<app-interface
*ngIf="ui$ | async as ui"
[style.max-width.rem]="50"
[serviceInterface]="ui"
/>
@if (ui(); as ui) {
<app-interface [serviceInterface]="ui" />
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
InterfaceComponent,
RouterLink,
TuiButton,
TitleDirective,
],
imports: [InterfaceComponent, RouterLink, TuiButton, TitleDirective],
})
export default class StartOsUiComponent {
private readonly config = inject(ConfigService)
readonly ui$: Observable<MappedServiceInterface> = inject<PatchDB<DataModel>>(
PatchDB,
readonly ui = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'host')
.pipe(
map(host => ({
...iface,
public: host.bindings[iface.addressInfo.internalPort].net.public,
addresses: getAddresses(iface, host, this.config),
})),
),
)
.watch$('serverInfo', 'network', 'host')
.pipe(
map(host => ({
...iface,
public: host.bindings[iface.addressInfo.internalPort].net.public,
addresses: getAddresses(iface, host, this.config),
})),
)
}

View File

@@ -0,0 +1,145 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import * as argon2 from '@start9labs/argon2'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { ISB } from '@start9labs/start-sdk'
import {
TuiAlertService,
TuiButton,
TuiNotification,
TuiTitle,
} from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { from } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { getServerInfo } from 'src/app/utils/get-server-info'
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
Password Reset
</ng-container>
<tui-notification appearance="warning">
<div tuiTitle>
<strong>Warning</strong>
<div tuiSubtitle>
You will still need your current password to decrypt existing backups!
</div>
</div>
</tui-notification>
<section class="g-card">
<header>Change Master Password</header>
@if (spec(); as spec) {
<app-form [spec]="spec" [buttons]="buttons" />
}
</section>
`,
styles: `
:host {
max-inline-size: 40rem;
::ng-deep footer {
background: transparent !important;
}
}
section {
padding: 4rem 1rem 0;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
TuiNotification,
TuiTitle,
FormComponent,
RouterLink,
TuiButton,
TitleDirective,
],
})
export default class SystemPasswordComponent {
private readonly alerts = inject(TuiAlertService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
readonly spec = toSignal(from(configBuilderToSpec(passwordSpec)))
readonly buttons = [
{
text: 'Save',
handler: (value: PasswordSpec) => this.resetPassword(value),
},
]
private async resetPassword({
newPassword,
newPasswordConfirm,
oldPassword,
}: PasswordSpec) {
let error = ''
if (newPassword !== newPasswordConfirm) {
error = 'New passwords do not match'
} else if (newPassword.length < 12) {
error = 'New password must be 12 characters or greater'
} else if (newPassword.length > 64) {
error = 'New password must be less than 65 characters'
}
// confirm current password is correct
const { passwordHash } = await getServerInfo(this.patch)
try {
argon2.verify(passwordHash, oldPassword)
} catch (e) {
error = 'Current password is invalid'
}
if (error) {
this.errorService.handleError(error)
return
}
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.resetPassword({ oldPassword, newPassword })
this.alerts.open('Password changed!').subscribe()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}
const passwordSpec = ISB.InputSpec.of({
oldPassword: ISB.Value.text({
name: 'Current Password',
required: true,
default: null,
masked: true,
}),
newPassword: ISB.Value.text({
name: 'New Password',
required: true,
default: null,
masked: true,
}),
newPasswordConfirm: ISB.Value.text({
name: 'Retype New Password',
required: true,
default: null,
masked: true,
}),
})
export type PasswordSpec = typeof passwordSpec.validator._TYPE

View File

@@ -13,9 +13,9 @@ import { TuiLink, TuiNotification } from '@taiga-ui/core'
href="https://docs.start9.com/latest/user-manual/wifi"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
iconEnd="@tui.external-link"
[textContent]="'View instructions'"
></a>
</tui-notification>
`,
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -1,43 +1,114 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { RouterModule } from '@angular/router'
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiBadgeNotification } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { BadgeService } from 'src/app/services/badge.service'
import { TitleDirective } from 'src/app/services/title.service'
import { SystemMenuComponent } from './components/menu.component'
import { SYSTEM_MENU } from './system.const'
@Component({
template: `
<ng-container *title><span>System System</span></ng-container>
<system-menu />
<span *title>System</span>
<aside class="g-aside">
@for (cat of menu; track $index) {
@if ($index) {
<hr [style.margin.rem]="0.5" />
}
@for (page of cat; track $index) {
<a
tuiCell="s"
routerLinkActive="active"
[routerLink]="page.routerLink"
>
<tui-icon [icon]="page.icon" />
<span tuiTitle>
<span>
{{ page.title }}
@if (page.routerLink === 'general' && badge()) {
<tui-badge-notification>{{ badge() }}</tui-badge-notification>
}
</span>
</span>
</a>
}
}
</aside>
<router-outlet />
`,
styles: [
`
:host {
padding-top: 1rem;
display: flex;
padding: 0;
}
::ng-deep tui-notification {
position: sticky;
left: 0;
[tuiCell] {
color: var(--tui-text-secondary);
&.active {
color: var(--tui-text-primary);
[tuiTitle] {
font-weight: bold;
}
}
}
span:not(:last-child),
system-menu:not(:nth-last-child(2)) {
span:not(:last-child) {
display: none;
}
system-menu,
router-outlet + ::ng-deep * {
height: fit-content;
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0 auto;
max-width: 45rem;
padding: 1rem;
}
:host-context(tui-root._mobile) {
aside {
padding: 0;
width: 100%;
background: none;
box-shadow: none;
&:not(:nth-last-child(2)) {
display: none;
}
}
[tuiCell] {
color: var(--tui-text-primary);
margin: 0.5rem 0;
[tuiTitle] {
font: var(--tui-font-text-l);
}
}
hr {
background: var(--tui-border-normal);
}
}
`,
],
host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [RouterModule, SystemMenuComponent, TitleDirective],
imports: [
RouterModule,
TuiCell,
TuiIcon,
TuiTitle,
TitleDirective,
TuiBadgeNotification,
],
})
export class SystemComponent {}
export class SystemComponent {
readonly menu = SYSTEM_MENU
readonly badge = toSignal(inject(BadgeService).getCount('/portal/system'))
}

View File

@@ -0,0 +1,60 @@
export const SYSTEM_MENU = [
[
{
title: 'General',
icon: '@tui.settings',
routerLink: 'general',
},
{
title: 'Email',
icon: '@tui.mail',
routerLink: 'email',
},
],
[
{
title: 'Create Backup',
icon: '@tui.copy-plus',
routerLink: 'backup',
},
{
title: 'Restore Backup',
icon: '@tui.database-backup',
routerLink: 'restore',
},
],
[
{
title: 'User Interface Addresses',
icon: '@tui.monitor',
routerLink: 'interfaces',
},
{
title: 'ACME',
icon: '@tui.award',
routerLink: 'acme',
},
{
title: 'WiFi',
icon: '@tui.wifi',
routerLink: 'wifi',
},
],
[
{
title: 'Active Sessions',
icon: '@tui.clock',
routerLink: 'sessions',
},
{
title: 'SSH',
icon: '@tui.terminal',
routerLink: 'ssh',
},
{
title: 'Change Password',
icon: '@tui.key',
routerLink: 'password',
},
],
]

View File

@@ -1,17 +1,65 @@
import { inject } from '@angular/core'
import {
ActivatedRouteSnapshot,
Router,
RouterStateSnapshot,
Routes,
} from '@angular/router'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { SystemComponent } from './system.component'
export default [
{
path: '',
component: SystemComponent,
canActivate: [
({ firstChild }: ActivatedRouteSnapshot, state: RouterStateSnapshot) =>
!!firstChild ||
inject(TUI_IS_MOBILE) ||
inject(Router).parseUrl(`${state.url}/general`),
],
children: [
{
path: 'general',
loadComponent: () => import('./routes/general/general.component'),
},
{
path: 'email',
loadComponent: () => import('./routes/email/email.component'),
},
{
path: 'backup',
loadComponent: () => import('./routes/backups/backups.component'),
data: { type: 'create' },
},
{
path: 'restore',
loadComponent: () => import('./routes/backups/backups.component'),
data: { type: 'restore' },
},
{
path: 'interfaces',
loadComponent: () => import('./routes/interfaces/interfaces.component'),
},
{
path: 'acme',
loadComponent: () => import('./routes/acme/acme.component'),
},
{
path: 'email',
loadComponent: () => import('./routes/email/email.component'),
path: 'wifi',
loadComponent: () => import('./routes/wifi/wifi.component'),
},
{
path: 'sessions',
loadComponent: () => import('./routes/sessions/sessions.component'),
},
{
path: 'ssh',
loadComponent: () => import('./routes/ssh/ssh.component'),
},
{
path: 'password',
loadComponent: () => import('./routes/password/password.component'),
},
// {
// path: 'domains',
@@ -25,22 +73,6 @@ export default [
// path: 'router',
// loadComponent: () => import('./routes/router/router.component')
// },
{
path: 'wifi',
loadComponent: () => import('./routes/wifi/wifi.component'),
},
{
path: 'ui',
loadComponent: () => import('./routes/interfaces/ui.component'),
},
{
path: 'ssh',
loadComponent: () => import('./routes/ssh/ssh.component'),
},
{
path: 'sessions',
loadComponent: () => import('./routes/sessions/sessions.component'),
},
],
},
]
] satisfies Routes

View File

@@ -1,306 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Injectable,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import * as argon2 from '@start9labs/argon2'
import { ErrorService, LoadingService } from '@start9labs/shared'
import {
TuiAlertService,
TuiDialogOptions,
TuiDialogService,
TuiLabel,
} from '@taiga-ui/core'
import { TUI_CONFIRM, TuiCheckbox, TuiConfirmData } from '@taiga-ui/kit'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { filter, from, take } from 'rxjs'
import { switchMap } from 'rxjs/operators'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { getServerInfo } from 'src/app/utils/get-server-info'
import { passwordSpec, PasswordSpec, SettingBtn } from './system.types'
@Injectable({ providedIn: 'root' })
export class SystemService {
private readonly alerts = inject(TuiAlertService)
private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly formDialog = inject(FormDialogService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
private readonly isTor = inject(ConfigService).isTor()
wipe = false
readonly settings: Record<string, readonly SettingBtn[]> = {
General: [
{
title: 'Email',
description: 'Connect to an external SMTP server for sending emails',
icon: '@tui.mail',
routerLink: 'email',
},
],
Network: [
// {
// title: 'Domains',
// description: 'Manage domains for clearnet connectivity',
// icon: '@tui.globe',
// routerLink: 'domains',
// },
// {
// title: 'Proxies',
// description: 'Manage proxies for inbound and outbound connections',
// icon: '@tui.shuffle',
// routerLink: 'proxies',
// },
// {
// title: 'Router Config',
// description: 'Connect or configure your router for clearnet',
// icon: '@tui.radio',
// routerLink: 'router',
// },
{
title: 'User Interface Addresses',
description: 'View and manage your Start OS UI addresses',
icon: '@tui.monitor',
routerLink: 'ui',
},
{
title: 'ACME',
description:
'Add ACME providers to create SSL certificates for clearnet access',
icon: '@tui.award',
routerLink: 'acme',
},
{
title: 'WiFi',
description: 'Add or remove WiFi networks',
icon: '@tui.wifi',
routerLink: 'wifi',
},
{
title: 'Reset Tor',
description: `May help resolve Tor connectivity issues`,
icon: '@tui.refresh-cw',
action: () => this.promptResetTor(),
},
],
Customize: [
{
title: 'Browser Tab Title',
description: `Customize the display name of your browser tab`,
icon: '@tui.tag',
action: () => this.setBrowserTab(),
},
],
Security: [
// {
// title: 'Outbound Proxy',
// description: 'Proxy outbound traffic from the StartOS main process',
// icon: '@tui.shield',
// action: () => this.setOutboundProxy(),
// },
{
title: 'Active Sessions',
description: 'View and manage device access',
icon: '@tui.clock',
routerLink: 'sessions',
},
{
title: 'SSH',
description:
'Manage your SSH keys to access your server from the command line',
icon: '@tui.terminal',
routerLink: 'ssh',
},
{
title: 'Change Password',
description: `Change your StartOS master password`,
icon: '@tui.key',
action: () => this.promptNewPassword(),
},
],
}
// private async setOutboundProxy(): Promise<void> {
// const proxy = await firstValueFrom(
// this.patch.watch$('serverInfo', 'network', 'outboundProxy'),
// )
// await this.proxyService.presentModalSetOutboundProxy(proxy)
// }
private promptResetTor() {
this.wipe = false
this.dialogs
.open(TUI_CONFIRM, {
label: this.isTor ? 'Warning' : 'Confirm',
data: {
content: new PolymorpheusComponent(WipeComponent),
yes: 'Reset',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.resetTor(this.wipe))
}
private async resetTor(wipeState: boolean) {
const loader = this.loader.open('Resetting Tor...').subscribe()
try {
await this.api.resetTor({
wipeState: wipeState,
reason: 'User triggered',
})
this.alerts.open('Tor reset in progress').subscribe()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
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_CONFIRM, {
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(FormComponent, {
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 { 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({
oldPassword: value.currentPassword,
newPassword: value.newPassword1,
})
this.alerts.open('Password changed!').subscribe()
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}
@Component({
standalone: true,
template: `
<p>
@if (isTor) {
You are currently connected over Tor. If you reset the Tor daemon, you
will lose connectivity until it comes back online.
} @else {
Reset Tor?
}
</p>
<p>
Optionally wipe state to forcibly acquire new guard nodes. It is
recommended to try without wiping state first.
</p>
<label tuiLabel>
<input type="checkbox" tuiCheckbox [(ngModel)]="service.wipe" />
Wipe state
</label>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiLabel, FormsModule, TuiCheckbox],
})
class WipeComponent {
readonly isTor = inject(ConfigService).isTor()
readonly service = inject(SystemService)
}

View File

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

View File

@@ -188,7 +188,7 @@ export const mockPatchData: DataModel = {
updateProgress: null,
restarting: false,
shuttingDown: false,
backupProgress: {},
backupProgress: null,
},
hostname: 'random-words',
pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',

View File

@@ -0,0 +1,31 @@
import * as argon2 from '@start9labs/argon2'
import {
EMPTY,
filter,
MonoTypeOperatorFunction,
of,
pipe,
switchMap,
take,
} from 'rxjs'
export function verifyPassword(
passwordHash: string | null,
handler: (e: any) => void,
): MonoTypeOperatorFunction<string> {
return pipe(
filter(Boolean),
switchMap(password => {
try {
argon2.verify(passwordHash || '', password)
return of(password)
} catch (e: any) {
handler(e)
return EMPTY
}
}),
take(1),
)
}

View File

@@ -61,7 +61,6 @@ hr {
tui-root._mobile & {
// For tui-tab-bar
height: calc(100vh - 3.875rem - var(--tui-height-l));
padding: 1rem;
}
}
@@ -72,11 +71,28 @@ hr {
padding: 1rem;
}
.g-aside {
position: sticky;
top: 1px;
left: 1px;
margin: 1px;
width: 16rem;
padding: 0.5rem;
text-transform: capitalize;
box-shadow: 1px 0 var(--tui-border-normal);
backdrop-filter: blur(1rem);
background-color: color-mix(
in hsl,
var(--tui-background-base) 90%,
transparent
);
}
.g-card {
position: relative;
display: flex;
flex-direction: column;
padding: 3.125rem 1rem 0.5rem;
padding: 3.25rem 1rem 0.375rem;
border-radius: 0.5rem;
overflow: hidden;
background-color: color-mix(in hsl, var(--start9-base-1) 50%, transparent);
@@ -102,7 +118,7 @@ hr {
inset 0 0 1rem rgba(0, 0, 0, 0.25);
&:is(form) {
padding-top: 3.75rem;
padding-top: 4rem;
}
[tuiCell] {