mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
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:
19
web/package-lock.json
generated
19
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
})),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -188,7 +188,7 @@ export const mockPatchData: DataModel = {
|
||||
updateProgress: null,
|
||||
restarting: false,
|
||||
shuttingDown: false,
|
||||
backupProgress: {},
|
||||
backupProgress: null,
|
||||
},
|
||||
hostname: 'random-words',
|
||||
pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
|
||||
|
||||
31
web/projects/ui/src/app/utils/verify-password.ts
Normal file
31
web/projects/ui/src/app/utils/verify-password.ts
Normal 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),
|
||||
)
|
||||
}
|
||||
@@ -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] {
|
||||
|
||||
Reference in New Issue
Block a user