Feature/cloud backups (#889)

* cifs for cloud backups on lan

* password spelling fix

* fix spelling and fix rpc method

* fix other methods

* remove old code and rename method

* add support for cifs backup targets

wip

cifs api

simplify idiom

add doc comment

wip

wip

should work™

* add password hash to server info

* fix type

* fix types for cifs

* minor fixes for cifs feature

* fix rpc structure

* fix copy, address some TODOs

* add subcommand

* backup path and navigation

* wizard edits

* rebased success page

* wiz conflicts resolved

* current change actually

* only unsub if done

* no fileter if necessary

* fix copy for cifs old password

* setup complete (#913)

* setup complete

* minor fixes

* setup.complete

* complete bool

* setup-wizard: complete boolean

Co-authored-by: Matt Hill <matthewonthemoon@gmail.com>
Co-authored-by: Drew Ansbacher <drew.ansbacher@spiredigital.com>
Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com>
This commit is contained in:
Aiden McClelland
2021-12-07 11:51:04 -07:00
parent 6ee0bf8636
commit e6fb74a800
140 changed files with 3968 additions and 2399 deletions

View File

@@ -62,11 +62,17 @@
<ion-icon name="arrow-up"></ion-icon>
<ion-icon name="briefcase-outline"></ion-icon>
<ion-icon name="bookmark-outline"></ion-icon>
<ion-icon name="cellular-outline"></ion-icon>
<ion-icon name="checkmark"></ion-icon>
<ion-icon name="chevron-down"></ion-icon>
<ion-icon name="chevron-up"></ion-icon>
<ion-icon name="chevron-forward"></ion-icon> <!-- needed for detail="true" on ion-item button -->
<ion-icon name="close"></ion-icon>
<ion-icon name="cloud-outline"></ion-icon>
<ion-icon name="cloud-done-outline"></ion-icon>
<ion-icon name="cloud-download-outline"></ion-icon>
<ion-icon name="cloud-offline-outline"></ion-icon>
<ion-icon name="cloud-upload-outline"></ion-icon>
<ion-icon name="code-outline"></ion-icon>
<ion-icon name="color-wand-outline"></ion-icon>
<ion-icon name="construct-outline"></ion-icon>
@@ -81,6 +87,7 @@
<ion-icon name="file-tray-stacked-outline"></ion-icon>
<ion-icon name="finger-print-outline"></ion-icon>
<ion-icon name="flash-outline"></ion-icon>
<ion-icon name="folder-open-outline"></ion-icon>
<ion-icon name="grid-outline"></ion-icon>
<ion-icon name="help-circle-outline"></ion-icon>
<ion-icon name="home-outline"></ion-icon>

View File

@@ -160,10 +160,9 @@ export class AppComponent {
}
async presentAlertLogout () {
// @TODO warn user no way to recover Embassy if logout and forget password. Maybe require password to logout?
const alert = await this.alertCtrl.create({
header: 'Caution',
message: 'Are you sure you want to logout?',
message: 'Do you know your password? If you log out and forget your password, you may permanently lose access to your Embassy.',
buttons: [
{
text: 'Cancel',

View File

@@ -0,0 +1,16 @@
<div class="inline">
<h2 *ngIf="type === 'create'; else restore">
<ion-icon name="cloud-outline" color="success"></ion-icon>
{{ hasValidBackup ? 'Available, contains existing backup' : 'Available for fresh backup' }}
</h2>
<ng-template #restore>
<h2 *ngIf="hasValidBackup">
<ion-icon name="cloud-done-outline" color="success"></ion-icon>
Embassy backup detected
</h2>
<h2 *ngIf="!hasValidBackup">
<ion-icon name="cloud-offline-outline" color="danger"></ion-icon>
No Embassy backup
</h2>
</ng-template>
</div>

View File

@@ -1,70 +1,82 @@
<ion-item-group>
<!-- always -->
<ion-item class="ion-margin-bottom">
<ion-label>{{ message }}</ion-label>
<!-- loading -->
<text-spinner *ngIf="backupService.loading; else loaded" [text]="loadingText"></text-spinner>
<!-- loaded -->
<ng-template #loaded>
<!-- error -->
<ion-item *ngIf="backupService.loadingError; else noError">
<ion-label>
<ion-text color="danger">
{{ backupService.loadingError }}
</ion-text>
</ion-label>
</ion-item>
<!-- loading -->
<ng-container *ngIf="backupService.loading; else loaded">
<ion-item-divider>
<ion-skeleton-text animated style="width: 120px; height: 16px;"></ion-skeleton-text>
</ion-item-divider>
<ion-item>
<ion-avatar slot="start" style="margin-right: 24px;">
<ion-skeleton-text animated style="width: 30px; height: 30px; border-radius: 0;"></ion-skeleton-text>
</ion-avatar>
<ion-label>
<ion-skeleton-text animated style="width: 100px; height: 20px; margin-bottom: 12px;"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 50px; height: 16px; margin-bottom: 16px;"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 100px;"></ion-skeleton-text>
</ion-label>
</ion-item>
</ng-container>
<!-- loaded -->
<ng-template #loaded>
<!-- error -->
<ion-item *ngIf="backupService.loadingError; else noError">
<ion-label>
<ion-text color="danger">
{{ backupService.loadingError }}
</ion-text>
</ion-label>
</ion-item>
<ng-template #noError>
<ion-item *ngIf="!backupService.drives.length; else hasDrives">
<ng-template #noError>
<ion-item-group>
<!-- ** cifs ** -->
<ion-item-divider>Shared Network Folders</ion-item-divider>
<ion-item>
<ion-label>
<ion-text color="warning">
No drives found. Insert a backup drive into your Embassy and click "Refresh" above.
</ion-text>
<h2>
Shared folders are the recommended way to create Embassy backups.
</h2>
</ion-label>
</ion-item>
<!-- add new cifs -->
<ion-item button detail="false" (click)="presentModalAddCifs()">
<ion-icon slot="start" name="add" size="large" color="dark"></ion-icon>
<ion-label>New shared folder</ion-label>
</ion-item>
<!-- cifs list -->
<ng-container *ngFor="let target of backupService.cifs; let i = index">
<ion-item button *ngIf="target.entry as cifs" (click)="presentActionCifs(target, i)">
<ion-icon slot="start" name="folder-open-outline" size="large"></ion-icon>
<ion-label>
<h1>{{ cifs.path.split('/').pop() }}</h1>
<ng-container *ngIf="cifs.mountable">
<backup-drives-status [type]="type" [hasValidBackup]="target.hasValidBackup"></backup-drives-status>
</ng-container>
<h2 *ngIf="!cifs.mountable" class="inline">
<ion-icon name="cellular-outline" color="danger"></ion-icon>
Unable to connect
</h2>
<p>Hostname: {{ cifs.hostname }}</p>
<p>Path: {{ cifs.path }}</p>
</ion-label>
</ion-item>
</ng-container>
<!-- ** drives ** -->
<ion-item-divider>Physical Drives</ion-item-divider>
<!-- no drives -->
<ion-item *ngIf="!backupService.drives.length; else hasDrives" class="ion-padding-bottom">
<ion-label>
<h2>
<ion-text color="warning">
Warning! Plugging a 2nd physical drive directly into your Embassy can lead to data corruption.
</ion-text>
</h2>
<br />
<h2>
To backup to a physical drive, please follow the <a href="https://docs.start9.com/user-manual/general/backups.html" target="blank" noreferrer>instructions</a>.
</h2>
</ion-label>
</ion-item>
<!-- drives detected -->
<ng-template #hasDrives>
<ion-item-group>
<div *ngFor="let drive of backupService.drives">
<ion-item-divider>
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model || 'Unknown Model' }} - {{ drive.capacity | convertBytes }}
</ion-item-divider>
<ion-item button *ngFor="let partition of drive.partitions" [disabled]="type === 'restore' && !partition.hasBackup" (click)="handleSelect(partition)">
<ion-icon slot="start" name="save-outline" size="large"></ion-icon>
<ion-label>
<h1>{{ partition.label || partition.logicalname }}</h1>
<h2>{{ partition.capacity | convertBytes }}</h2>
<p *ngIf="partition.hasBackup">
<ion-text color="success">
Embassy backups detected
</ion-text>
</p>
</ion-label>
<ion-icon *ngIf="partition.hasBackup" slot="end" color="danger" name="lock-closed-outline"></ion-icon>
</ion-item>
</div>
</ion-item-group>
<ion-item button *ngFor="let target of backupService.drives" (click)="select(target)">
<ion-icon slot="start" name="save-outline" size="large"></ion-icon>
<ng-container *ngIf="target.entry as drive">
<ion-label>
<h1>{{ drive.label || drive.logicalname }}</h1>
<backup-drives-status [type]="type" [hasValidBackup]="target.hasValidBackup"></backup-drives-status>
<p>{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model || 'Unknown Model' }}</p>
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
</ion-label>
</ng-container>
</ion-item>
</ng-template>
</ng-template>
</ion-item-group>
</ng-template>
</ion-item-group>
</ng-template>

View File

@@ -1,22 +1,26 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { BackupDrivesComponent, BackupDrivesHeaderComponent } from './backup-drives.component'
import { BackupDrivesComponent, BackupDrivesHeaderComponent, BackupDrivesStatusComponent } from './backup-drives.component'
import { SharingModule } from '../../modules/sharing.module'
import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module'
@NgModule({
declarations: [
BackupDrivesComponent,
BackupDrivesHeaderComponent,
BackupDrivesStatusComponent,
],
imports: [
CommonModule,
IonicModule,
SharingModule,
GenericFormPageModule,
],
exports: [
BackupDrivesComponent,
BackupDrivesHeaderComponent,
BackupDrivesStatusComponent,
],
})
export class BackupDrivesComponentModule { }

View File

@@ -1,6 +1,12 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { BackupService } from './backup.service'
import { MappedPartitionInfo } from 'src/app/util/misc.util'
import { CifsBackupTarget, DiskBackupTarget, RR } from 'src/app/services/api/api.types'
import { ActionSheetController, AlertController, LoadingController, ModalController } from '@ionic/angular'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { MappedBackupTarget } from 'src/app/util/misc.util'
@Component({
selector: 'backup-drives',
@@ -8,25 +14,193 @@ import { MappedPartitionInfo } from 'src/app/util/misc.util'
styleUrls: ['./backup-drives.component.scss'],
})
export class BackupDrivesComponent {
@Input() type: 'backup' | 'restore'
@Output() onSelect: EventEmitter<MappedPartitionInfo> = new EventEmitter()
message: string
@Input() type: 'create' | 'restore'
@Output() onSelect: EventEmitter<MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>> = new EventEmitter()
loadingText: string
constructor (
private readonly loadingCtrl: LoadingController,
private readonly actionCtrl: ActionSheetController,
private readonly alertCtrl: AlertController,
private readonly modalCtrl: ModalController,
private readonly embassyApi: ApiService,
private readonly errToast: ErrorToastService,
public readonly backupService: BackupService,
) { }
ngOnInit () {
if (this.type === 'backup') {
this.message = 'Select the drive where you want to create a backup of your Embassy.'
} else {
this.message = 'Select the drive containing backups you would like to restore.'
}
this.backupService.getExternalDrives()
this.loadingText = this.type === 'create' ? 'Fetching Backup Targets' : 'Fetching Backup Sources'
this.backupService.getBackupTargets()
}
handleSelect (partition: MappedPartitionInfo): void {
this.onSelect.emit(partition)
select (target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>): void {
if (target.entry.type === 'cifs' && !target.entry.mountable) {
const message = 'Unable to connect to shared folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.'
this.presentAlertError(message)
return
}
if (this.type === 'restore' && !target.hasValidBackup) {
const message = `${target.entry.type === 'cifs' ? 'Shared folder' : 'Drive partition'} does not contain a valid Embassy backup.`
this.presentAlertError(message)
return
}
this.onSelect.emit(target)
}
async presentModalAddCifs (): Promise<void> {
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: 'New Shared Folder',
spec: CifsSpec,
buttons: [
{
text: 'Save',
handler: (value: RR.AddBackupTargetReq) => {
return this.addCifs(value)
},
isSubmit: true,
},
],
},
})
await modal.present()
}
async presentActionCifs (target: MappedBackupTarget<CifsBackupTarget>, index: number): Promise<void> {
const entry = target.entry as CifsBackupTarget
const action = await this.actionCtrl.create({
header: entry.hostname,
subHeader: 'Shared Folder',
mode: 'ios',
buttons: [
{
text: 'Forget',
icon: 'trash',
role: 'destructive',
handler: () => {
this.deleteCifs(target.id, index)
},
},
{
text: 'Edit',
icon: 'pencil',
handler: () => {
this.presentModalEditCifs(target.id, entry, index)
},
},
{
text: this.type === 'create' ? 'Create Backup' : 'Restore From Backup',
icon: this.type === 'create' ? 'cloud-upload-outline' : 'cloud-download-outline',
handler: () => {
this.select(target)
},
},
],
})
await action.present()
}
private async presentAlertError (message: string): Promise<void> {
const alert = await this.alertCtrl.create({
header: 'Error',
message,
buttons: ['OK'],
})
await alert.present()
}
private async addCifs (value: RR.AddBackupTargetReq): Promise<boolean> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Testing connectivity to shared folder...',
cssClass: 'loader',
})
await loader.present()
try {
const res = await this.embassyApi.addBackupTarget(value)
const [id, entry] = Object.entries(res)[0]
this.backupService.cifs.unshift({
id,
hasValidBackup: this.backupService.hasValidBackup(entry),
entry,
})
return true
} catch (e) {
this.errToast.present(e)
return false
} finally {
loader.dismiss()
}
}
private async presentModalEditCifs (id: string, entry: CifsBackupTarget, index: number): Promise<void> {
const { hostname, path, username } = entry
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: 'Update Shared Folder',
spec: CifsSpec,
buttons: [
{
text: 'Save',
handler: (value: RR.AddBackupTargetReq) => {
return this.editCifs({ id, ...value }, index)
},
isSubmit: true,
},
],
initialValue: {
hostname,
path,
username,
},
},
})
await modal.present()
}
private async editCifs (value: RR.UpdateBackupTargetReq, index: number): Promise<void> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Testing connectivity to shared folder...',
cssClass: 'loader',
})
await loader.present()
try {
const res = await this.embassyApi.updateBackupTarget(value)
const entry = Object.values(res)[0]
this.backupService.cifs[index].entry = entry
} catch (e) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async deleteCifs (id: string, index: number): Promise<void> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Removing...',
cssClass: 'loader',
})
await loader.present()
try {
await this.embassyApi.removeBackupTarget({ id })
this.backupService.cifs.splice(index, 1)
} catch (e) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
}
@@ -45,6 +219,54 @@ export class BackupDrivesHeaderComponent {
) { }
refresh () {
this.backupService.getExternalDrives()
this.backupService.getBackupTargets()
}
}
@Component({
selector: 'backup-drives-status',
templateUrl: './backup-drives-status.component.html',
styleUrls: ['./backup-drives.component.scss'],
})
export class BackupDrivesStatusComponent {
@Input() type: string
@Input() hasValidBackup: boolean
}
const CifsSpec: ConfigSpec = {
hostname: {
type: 'string',
name: 'Hostname',
description: 'The local URL of the shared folder.',
placeholder: `e.g. My Computer, Bob's Laptop`,
nullable: false,
masked: false,
copyable: false,
},
path: {
type: 'string',
name: 'Path',
description: 'The path to the shared folder on the target device.',
placeholder: 'e.g. /Desktop/my-folder',
nullable: false,
masked: false,
copyable: false,
},
username: {
type: 'string',
name: 'Username',
description: 'The username of the user account on your target device.',
nullable: false,
masked: false,
copyable: false,
},
password: {
type: 'string',
name: 'Password',
description: 'The password of the user account on your target device.',
nullable: true,
masked: true,
copyable: false,
},
}

View File

@@ -2,14 +2,16 @@ import { Injectable } from '@angular/core'
import { IonicSafeString } from '@ionic/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { getErrorMessage } from 'src/app/services/error-toast.service'
import { MappedDriveInfo, MappedPartitionInfo } from 'src/app/util/misc.util'
import { BackupTarget, CifsBackupTarget, DiskBackupTarget } from 'src/app/services/api/api.types'
import { Emver } from 'src/app/services/emver.service'
import { MappedBackupTarget } from 'src/app/util/misc.util'
@Injectable({
providedIn: 'root',
})
export class BackupService {
drives: MappedDriveInfo[]
cifs: MappedBackupTarget<CifsBackupTarget>[]
drives: MappedBackupTarget<DiskBackupTarget>[]
loading = true
loadingError: string | IonicSafeString
@@ -18,25 +20,31 @@ export class BackupService {
private readonly emver: Emver,
) { }
async getExternalDrives (): Promise<void> {
async getBackupTargets (): Promise<void> {
this.loading = true
try {
const drives = await this.embassyApi.getDrives({ })
this.drives = drives
.filter(d => !d.guid)
.map(d => {
const partionInfo: MappedPartitionInfo[] = d.partitions.map(p => {
return {
...p,
hasBackup: [0, 1].includes(this.emver.compare(p['embassy-os']?.version, '0.3.0')),
}
})
return {
...d,
partitions: partionInfo,
}
})
const targets = await this.embassyApi.getBackupTargets({ })
// cifs
this.cifs = Object.entries(targets)
.filter(([_, target]) => target.type === 'cifs')
.map(([id, cifs]) => {
return {
id,
hasValidBackup: this.hasValidBackup(cifs),
entry: cifs as CifsBackupTarget,
}
})
// drives
this.drives = Object.entries(targets)
.filter(([_, target]) => target.type === 'disk')
.map(([id, drive]) => {
return {
id,
hasValidBackup: this.hasValidBackup(drive),
entry: drive as DiskBackupTarget,
}
})
} catch (e) {
this.loadingError = getErrorMessage(e)
} finally {
@@ -44,4 +52,7 @@ export class BackupService {
}
}
}
hasValidBackup (target: BackupTarget): boolean {
return [0, 1].includes(this.emver.compare(target['embassy-os']?.version, '0.3.0'))
}
}

View File

@@ -38,7 +38,7 @@
<ion-input
[type]="spec.type === 'string' && spec.masked && !unmasked[entry.key] ? 'password' : 'text'"
[inputmode]="spec.type === 'number' ? 'tel' : 'text'"
[placeholder]="'Enter ' + spec.name"
[placeholder]="spec.placeholder || 'Enter ' + spec.name"
[formControlName]="entry.key"
(ionFocus)="presentAlertChangeWarning(entry.key, spec)"
(ionChange)="handleInputChange()"
@@ -188,7 +188,7 @@
<ion-input
[type]="$any(spec.spec).masked ? 'password' : 'text'"
[inputmode]="spec.subtype === 'number' ? 'tel' : 'text'"
[placeholder]="'Enter ' + spec.name"
[placeholder]="$any(spec.spec).placeholder || 'Enter ' + spec.name"
[formControlName]="i"
>
</ion-input>

View File

@@ -100,9 +100,6 @@ export class FormObjectComponent {
addListItem (key: string, markDirty = true, val?: string): void {
const arr = this.formGroup.get(key) as FormArray
if (markDirty) arr.markAsDirty()
// @TODO why are these commented out?
// const validators = this.formService.getListItemValidators(this.objectSpec[key] as ValueSpecList, key, arr.length)
// arr.push(new FormControl(value, validators))
const listSpec = this.objectSpec[key] as ValueSpecList
const newItem = this.formService.getListItem(listSpec, val)
newItem.markAllAsTouched()

View File

@@ -28,14 +28,11 @@ export class AuthGuard implements CanActivate, CanActivateChild {
}
private runAuthCheck (): boolean {
switch (this.authState){
case AuthState.VERIFIED:
return true
case AuthState.UNVERIFIED:
// @TODO could initializing cause a loop?
case AuthState.INITIALIZING:
this.router.navigate(['/auth'], { replaceUrl: true })
return false
if (this.authState === AuthState.VERIFIED) {
return true
} else {
this.router.navigate(['/login'], { replaceUrl: true })
return false
}
}
}

View File

@@ -20,15 +20,11 @@ export class UnauthGuard implements CanActivate {
}
canActivate (): boolean {
switch (this.authState){
case AuthState.VERIFIED: {
this.router.navigateByUrl('')
return false
}
case AuthState.UNVERIFIED:
case AuthState.INITIALIZING:
return true
if (this.authState === AuthState.VERIFIED) {
this.router.navigateByUrl('')
return false
} else {
return true
}
}
}

View File

@@ -13,9 +13,10 @@ import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
styleUrls: ['./app-recover-select.page.scss'],
})
export class AppRecoverSelectPage {
@Input() logicalname: string
@Input() password: string
@Input() id: string
@Input() backupInfo: BackupInfo
@Input() password: string
@Input() oldPassword: string
options: (PackageBackupInfo & {
id: string
checked: boolean
@@ -69,7 +70,8 @@ export class AppRecoverSelectPage {
try {
await this.embassyApi.restorePackages({
ids,
logicalname: this.logicalname,
'target-id': this.id,
'old-password': this.oldPassword,
password: this.password,
})
this.modalCtrl.dismiss(undefined, 'success')

View File

@@ -19,6 +19,7 @@ export class GenericFormPage {
@Input() title: string
@Input() spec: ConfigSpec
@Input() buttons: ActionButton[]
@Input() initialValue: object = { }
submitBtn: ActionButton
formGroup: FormGroup
@@ -28,7 +29,7 @@ export class GenericFormPage {
) { }
ngOnInit () {
this.formGroup = this.formService.createForm(this.spec)
this.formGroup = this.formService.createForm(this.spec, this.initialValue)
this.submitBtn = this.buttons.find(btn => btn.isSubmit) || {
text: '',
handler: () => Promise.resolve(true),
@@ -48,6 +49,7 @@ export class GenericFormPage {
return
}
// @TODO make this more like generic input component dismissal
const success = await handler(this.formGroup.value)
if (success !== false) this.modalCtrl.dismiss()
}

View File

@@ -3,13 +3,13 @@
<ion-item style="padding-bottom: 8px;">
<ion-label>
<h1>{{ title }}</h1>
<h1>{{ options.title }}</h1>
<br />
<p>{{ message }}</p>
<ng-container *ngIf="warning">
<p>{{ options.message }}</p>
<ng-container *ngIf="options.warning">
<br />
<p>
<ion-text color="warning">{{ warning }}</ion-text>
<ion-text color="warning">{{ options.warning }}</ion-text>
</p>
</ng-container>
</ion-label>
@@ -17,10 +17,10 @@
<form (ngSubmit)="submit()">
<div style="margin: 0 0 24px 16px;">
<p class="input-label">{{ label }}</p>
<p class="input-label">{{ options.label }}</p>
<ion-item lines="none" color="dark">
<ion-input #mainInput [type]="useMask && !unmasked ? 'password' : 'text'" [(ngModel)]="value" name="value" [placeholder]="placeholder" (ionChange)="error = ''"></ion-input>
<ion-button slot="end" *ngIf="useMask" fill="clear" color="light" (click)="toggleMask()">
<ion-input #mainInput [type]="options.useMask && !unmasked ? 'password' : 'text'" [(ngModel)]="value" name="value" [placeholder]="options.placeholder" (ionChange)="error = ''"></ion-input>
<ion-button slot="end" *ngIf="options.useMask" fill="clear" color="light" (click)="toggleMask()">
<ion-icon slot="icon-only" [name]="unmasked ? 'eye-off-outline' : 'eye-outline'" size="small"></ion-icon>
</ion-button>
</ion-item>
@@ -32,8 +32,8 @@
<ion-button fill="clear" (click)="cancel()">
Cancel
</ion-button>
<ion-button fill="clear" type="submit" [disabled]="!value && !nullable">
{{ buttonText }}
<ion-button fill="clear" type="submit" [disabled]="!value && !options.nullable">
{{ options.buttonText }}
</ion-button>
</div>
</form>

View File

@@ -1,5 +1,5 @@
import { Component, Input, ViewChild } from '@angular/core'
import { ModalController, IonicSafeString, LoadingController, IonInput } from '@ionic/angular'
import { ModalController, IonicSafeString, IonInput } from '@ionic/angular'
import { getErrorMessage } from 'src/app/services/error-toast.service'
@Component({
@@ -9,25 +9,31 @@ import { getErrorMessage } from 'src/app/services/error-toast.service'
})
export class GenericInputComponent {
@ViewChild('mainInput') elem: IonInput
@Input() title: string
@Input() message: string
@Input() warning: string
@Input() label: string
@Input() buttonText = 'Submit'
@Input() placeholder = 'Enter Value'
@Input() nullable = false
@Input() useMask = false
@Input() value = ''
@Input() loadingText = ''
@Input() submitFn: (value: string) => Promise<any>
@Input() options: GenericInputOptions
value: string
unmasked = false
error: string | IonicSafeString
constructor (
private readonly modalCtrl: ModalController,
private readonly loadingCtrl: LoadingController,
) { }
ngOnInit () {
const defaultOptions: Partial<GenericInputOptions> = {
buttonText: 'Submit',
placeholder: 'Enter value',
nullable: false,
useMask: false,
initialValue: '',
}
this.options = {
...defaultOptions,
...this.options,
}
this.value = this.options.initialValue
}
ngAfterViewInit () {
setTimeout(() => this.elem.setFocus(), 400)
}
@@ -43,25 +49,28 @@ export class GenericInputComponent {
async submit () {
const value = this.value.trim()
if (!value && !this.nullable) {
return
}
const loader = await this.loadingCtrl.create({
spinner: 'lines',
cssClass: 'loader',
message: this.loadingText,
})
await loader.present()
if (!value && !this.options.nullable) return
try {
await this.submitFn(value)
await this.options.submitFn(value)
this.modalCtrl.dismiss(undefined, 'success')
} catch (e) {
this.error = getErrorMessage(e)
}
finally {
loader.dismiss()
}
}
}
export interface GenericInputOptions {
// required
title: string
message: string
label: string
submitFn: (value: string) => Promise<any>
// optional
warning?: string
buttonText?: string
placeholder?: string
nullable?: boolean
useMask?: boolean
initialValue?: string
}

View File

@@ -1,7 +1,6 @@
import { Injectable } from '@angular/core'
import { MarketplaceData, MarketplaceEOS, MarketplacePkg } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { Emver } from 'src/app/services/emver.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'

View File

@@ -1,7 +1,7 @@
import { Component, ViewChild } from '@angular/core'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { IonContent, ModalController } from '@ionic/angular'
import { GenericInputComponent } from 'src/app/modals/generic-input/generic-input.component'
import { IonContent, LoadingController, ModalController } from '@ionic/angular'
import { GenericInputComponent, GenericInputOptions } from 'src/app/modals/generic-input/generic-input.component'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ServerConfigService } from 'src/app/services/server-config.service'
@@ -17,6 +17,7 @@ export class PreferencesPage {
defaultName: string
constructor (
private readonly loadingCtrl: LoadingController,
private readonly modalCtrl: ModalController,
private readonly api: ApiService,
public readonly serverConfig: ServerConfigService,
@@ -32,19 +33,20 @@ export class PreferencesPage {
}
async presentModalName (): Promise<void> {
const options: GenericInputOptions = {
title: 'Edit Device Name',
message: 'This is for your reference only.',
label: 'Device Name',
useMask: false,
placeholder: this.defaultName,
nullable: true,
initialValue: this.patch.getData().ui.name,
buttonText: 'Save',
submitFn: (value: string) => this.setDbValue('name', value || this.defaultName),
}
const modal = await this.modalCtrl.create({
componentProps: {
title: 'Edit Device Name',
message: 'This is for your reference only.',
label: 'Device Name',
useMask: false,
placeholder: this.defaultName,
nullable: true,
value: this.patch.getData().ui.name,
buttonText: 'Save',
loadingText: 'Saving',
submitFn: (value: string) => this.setDbValue('name', value || this.defaultName),
},
componentProps: { options },
cssClass: 'alertlike-modal',
presentingElement: await this.modalCtrl.getTop(),
component: GenericInputComponent,
@@ -54,7 +56,20 @@ export class PreferencesPage {
}
private async setDbValue (key: string, value: string): Promise<void> {
await this.api.setDbValue({ pointer: `/${key}`, value })
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Saving...',
cssClass: 'loader',
})
await loader.present()
try {
await this.api.setDbValue({ pointer: `/${key}`, value })
} catch (e) {
throw new Error(e)
} finally {
loader.dismiss()
}
}
}

View File

@@ -1,5 +1,5 @@
<backup-drives-header title="Restore From Backup"></backup-drives-header>
<ion-content class="ion-padding-top">
<ion-content class="ion-padding">
<backup-drives type="restore" (onSelect)="presentModalPassword($event)"></backup-drives>
</ion-content>

View File

@@ -1,10 +1,12 @@
import { Component } from '@angular/core'
import { ModalController, NavController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GenericInputComponent } from 'src/app/modals/generic-input/generic-input.component'
import { MappedPartitionInfo } from 'src/app/util/misc.util'
import { BackupInfo } from 'src/app/services/api/api.types'
import { GenericInputComponent, GenericInputOptions } from 'src/app/modals/generic-input/generic-input.component'
import { MappedBackupTarget } from 'src/app/util/misc.util'
import { BackupInfo, CifsBackupTarget, DiskBackupTarget } from 'src/app/services/api/api.types'
import { AppRecoverSelectPage } from 'src/app/modals/app-recover-select/app-recover-select.page'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import * as argon2 from '@start9labs/argon2'
@Component({
selector: 'restore',
@@ -17,20 +19,22 @@ export class RestorePage {
private readonly modalCtrl: ModalController,
private readonly navCtrl: NavController,
private readonly embassyApi: ApiService,
private readonly patch: PatchDbService,
) { }
async presentModalPassword (partition: MappedPartitionInfo): Promise<void> {
async presentModalPassword (target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>): Promise<void> {
const options: GenericInputOptions = {
title: 'Master Password Required',
message: 'Enter your master password. On the next screen, you will select the individual services you want to restore.',
label: 'Master Password',
placeholder: 'Enter master password',
useMask: true,
buttonText: 'Next',
submitFn: (password: string) => this.decryptDrive(target, password),
}
const modal = await this.modalCtrl.create({
componentProps: {
title: 'Decryption Required',
message: 'Enter the password that was originally used to encrypt this backup. After decrypting the drive, you will select the services you want to restore.',
label: 'Password',
placeholder: 'Enter password',
useMask: true,
buttonText: 'Restore',
loadingText: 'Decrypting drive...',
submitFn: (password: string) => this.decryptDrive(partition.logicalname, password),
},
componentProps: { options },
cssClass: 'alertlike-modal',
presentingElement: await this.modalCtrl.getTop(),
component: GenericInputComponent,
@@ -39,20 +43,54 @@ export class RestorePage {
await modal.present()
}
private async decryptDrive (logicalname: string, password: string): Promise<void> {
const backupInfo = await this.embassyApi.getBackupInfo({
logicalname,
password,
})
this.presentModalSelect(logicalname, password, backupInfo)
private async decryptDrive (target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>, password: string): Promise<void> {
const passwordHash = this.patch.getData()['server-info']['password-hash']
argon2.verify(passwordHash, password)
try {
argon2.verify(target.entry['embassy-os']['password-hash'], password)
await this.restoreFromBackup(target, password)
} catch (e) {
setTimeout(() => this.presentModalOldPassword(target, password), 500)
}
}
async presentModalSelect (logicalname: string, password: string, backupInfo: BackupInfo): Promise<void> {
private async presentModalOldPassword (target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>, password: string): Promise<void> {
const options: GenericInputOptions = {
title: 'Original Password Needed',
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: 'Restore From Backup',
submitFn: (oldPassword: string) => this.restoreFromBackup(target, password, oldPassword),
}
const m = await this.modalCtrl.create({
component: GenericInputComponent,
componentProps: { options },
presentingElement: await this.modalCtrl.getTop(),
cssClass: 'alertlike-modal',
})
await m.present()
}
private async restoreFromBackup (target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>, password: string, oldPassword?: string): Promise<void> {
const backupInfo = await this.embassyApi.getBackupInfo({
'target-id': target.id,
password,
})
this.presentModalSelect(target.id, backupInfo, password, oldPassword)
}
private async presentModalSelect (id: string, backupInfo: BackupInfo, password: string, oldPassword?: string): Promise<void> {
const modal = await this.modalCtrl.create({
componentProps: {
logicalname,
password,
id,
backupInfo,
password,
oldPassword,
},
presentingElement: await this.modalCtrl.getTop(),
component: AppRecoverSelectPage,

View File

@@ -1,8 +1,8 @@
<!-- not backing up -->
<ng-container *ngIf="!backingUp">
<backup-drives-header title="Create Backup"></backup-drives-header>
<ion-content class="ion-padding-top">
<backup-drives type="backup" (onSelect)="presentModalPassword($event)"></backup-drives>
<ion-content class="ion-padding">
<backup-drives type="create" (onSelect)="presentModalPassword($event)"></backup-drives>
</ion-content>
</ng-container>
@@ -38,9 +38,8 @@
</ion-note>
<!-- active -->
<ion-note *ngIf="pkg.active" class="inline" slot="end">
<ion-spinner color="dark" style="height: 12px; width: 12px; margin-right: 6px;"></ion-spinner>
<ion-text color="dark">Backing up</ion-text>
&nbsp;
<ion-spinner color="dark" style="height: 12px; width: 12px;"></ion-spinner>
</ion-note>
<!-- queued -->
<ion-note *ngIf="!pkg.complete && !pkg.active" slot="end">

View File

@@ -1,12 +1,14 @@
import { Component } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { LoadingController, ModalController, NavController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GenericInputComponent } from 'src/app/modals/generic-input/generic-input.component'
import { MappedPartitionInfo } from 'src/app/util/misc.util'
import { GenericInputComponent, GenericInputOptions } from 'src/app/modals/generic-input/generic-input.component'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { PackageDataEntry, PackageMainStatus, ServerStatus } from 'src/app/services/patch-db/data-model'
import { Subscription } from 'rxjs'
import { take } from 'rxjs/operators'
import { MappedBackupTarget } from 'src/app/util/misc.util'
import * as argon2 from '@start9labs/argon2'
import { CifsBackupTarget, DiskBackupTarget } from 'src/app/services/api/api.types'
@Component({
selector: 'server-backup',
@@ -19,22 +21,31 @@ export class ServerBackupPage {
subs: Subscription[]
constructor (
private readonly loadingCtrl: LoadingController,
private readonly modalCtrl: ModalController,
private readonly embassyApi: ApiService,
private readonly patch: PatchDbService,
private readonly navCtrl: NavController,
) { }
ngOnInit () {
this.subs = [
this.patch.watch$('server-info', 'status').subscribe(status => {
if (status === ServerStatus.BackingUp) {
this.backingUp = true
this.subscribeToBackup()
} else {
this.backingUp = false
this.pkgs.forEach(pkg => pkg.sub.unsubscribe())
}
}),
this.patch.watch$('server-info', 'status')
.pipe()
.subscribe(status => {
if (status === ServerStatus.BackingUp) {
if (!this.backingUp) {
this.backingUp = true
this.subscribeToBackup()
}
} else {
if (this.backingUp) {
this.backingUp = false
this.pkgs.forEach(pkg => pkg.sub.unsubscribe())
this.navCtrl.navigateRoot('/embassy')
}
}
}),
]
}
@@ -43,34 +54,90 @@ export class ServerBackupPage {
this.pkgs.forEach(pkg => pkg.sub.unsubscribe())
}
async presentModalPassword (partition: MappedPartitionInfo): Promise<void> {
let message: string
if (partition.hasBackup) {
message = 'Enter your master password to decrypt this drive and update its backup. Depending on how much data was added or changed, this could be very fast, or it could take a while.'
} else {
message = 'Enter your master password to create an encrypted backup of your Embassy and all its installed services. Since this is a fresh backup, it could take a while. Future backups will likely be much faster.'
async presentModalPassword (target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>): Promise<void> {
let message = 'Enter your master password to create an encrypted backup of your Embassy and all its services.'
if (!target.hasValidBackup) {
message = message + ' Since this is a fresh backup, it could take a while. Future backups will likely be much faster.'
}
const options: GenericInputOptions = {
title: 'Master Password Needed',
message,
label: 'Master Password',
placeholder: 'Enter master password',
useMask: true,
buttonText: 'Create Backup',
submitFn: (password: string) => this.test(target, password),
}
const m = await this.modalCtrl.create({
componentProps: {
title: 'Create Backup',
message,
label: 'Password',
placeholder: 'Enter password',
useMask: true,
buttonText: 'Create Backup',
loadingText: 'Beginning backup...',
submitFn: (password: string) => this.create(partition.logicalname, password),
},
cssClass: 'alertlike-modal',
component: GenericInputComponent,
componentProps: { options },
cssClass: 'alertlike-modal',
})
await m.present()
}
private async create (logicalname: string, password: string): Promise<void> {
await this.embassyApi.createBackup({ logicalname, password })
private async test (target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>, password: string, oldPassword?: string): Promise<void> {
const passwordHash = this.patch.getData()['server-info']['password-hash']
argon2.verify(passwordHash, password)
if (!target.hasValidBackup) {
await this.createBackup(target.id, password)
} else {
try {
argon2.verify(target.entry['embassy-os']['password-hash'], oldPassword || password)
await this.createBackup(target.id, password)
} catch (e) {
if (oldPassword) {
throw new Error(e)
} else {
setTimeout(() => this.presentModalOldPassword(target, password), 500)
}
}
}
}
private async presentModalOldPassword (target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>, password: string): Promise<void> {
const options: GenericInputOptions = {
title: 'Original Password Needed',
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',
submitFn: (oldPassword: string) => this.test(target, password, oldPassword),
}
const m = await this.modalCtrl.create({
component: GenericInputComponent,
componentProps: { options },
cssClass: 'alertlike-modal',
})
await m.present()
}
private async createBackup (id: string, password: string, oldPassword?: string): Promise<void> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Beginning backup...',
cssClass: 'loader',
})
await loader.present()
try {
await this.embassyApi.createBackup({
'target-id': id,
'old-password': oldPassword || null,
password,
})
} catch (e) {
throw new Error(e)
} finally {
loader.dismiss()
}
}
private subscribeToBackup () {

View File

@@ -11,15 +11,23 @@
<ion-item-group>
<div *ngFor="let cat of settings | keyvalue : asIsOrder">
<ion-item-divider><ion-text color="dark">{{ cat.key }}</ion-text></ion-item-divider>
<ion-item [detail]="button.detail" button *ngFor="let button of cat.value" (click)="button.action()">
<ion-item button *ngFor="let button of cat.value" [detail]="button.detail" [disabled]="button.disabled | async" (click)="button.action()">
<ion-icon slot="start" [name]="button.icon"></ion-icon>
<ion-label>
<h2>{{ button.title }}</h2>
<p *ngIf="button.description">{{ button.description }}</p>
<p *ngIf="button.title === 'Create Backup'">
<ion-text color="warning">
Last Backup: {{ patch.data['server-info']['last-backup'] ? (patch.data['server-info']['last-backup'] | date: 'short') : 'never' }}
</ion-text>
<ng-container *ngIf="patch.data['server-info'].status as status">
<ion-text color="warning" *ngIf="status === ServerStatus.Running">
Last Backup: {{ patch.data['server-info']['last-backup'] ? (patch.data['server-info']['last-backup'] | date: 'short') : 'never' }}
</ion-text>
<span *ngIf="status === ServerStatus.BackingUp" class="inline">
<ion-spinner color="success" style="height: 12px; width: 12px; margin-right: 6px;"></ion-spinner>
<ion-text color="success">
Backing up
</ion-text>
</span>
</ng-container>
</p>
</ion-label>
</ion-item>

View File

@@ -4,6 +4,9 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ActivatedRoute } from '@angular/router'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ServerStatus } from 'src/app/services/patch-db/data-model'
import { Observable, of } from 'rxjs'
import { map } from 'rxjs/operators'
@Component({
selector: 'server-show',
@@ -11,7 +14,7 @@ import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
styleUrls: ['server-show.page.scss'],
})
export class ServerShowPage {
settings: ServerSettings = { }
ServerStatus = ServerStatus
constructor (
private readonly alertCtrl: AlertController,
@@ -23,10 +26,6 @@ export class ServerShowPage {
public readonly patch: PatchDbService,
) { }
ngOnInit () {
this.setButtons()
}
async presentAlertRestart () {
const alert = await this.alertCtrl.create({
header: 'Confirm',
@@ -49,6 +48,7 @@ export class ServerShowPage {
}
async presentAlertShutdown () {
const sts = this.patch.data['server-info'].status
const alert = await this.alertCtrl.create({
header: 'Warning',
message: 'Are you sure you want to power down your Embassy? This can take several minutes, and your Embassy will not come back online automatically. To power on again, You will need to physically unplug your Embassy and plug it back in.',
@@ -103,101 +103,111 @@ export class ServerShowPage {
}
}
private setButtons (): void {
this.settings = {
'Backups': [
{
title: 'Create Backup',
description: 'Back up your Embassy and all its services',
icon: 'save-outline',
action: () => this.navCtrl.navigateForward(['backup'], { relativeTo: this.route }),
detail: true,
},
{
title: 'Restore From Backup',
description: 'Restore one or more services from a prior backup',
icon: 'color-wand-outline',
action: () => this.navCtrl.navigateForward(['restore'], { relativeTo: this.route }),
detail: true,
},
],
'Insights': [
{
title: 'About',
description: 'Basic information about your Embassy',
icon: 'information-circle-outline',
action: () => this.navCtrl.navigateForward(['specs'], { relativeTo: this.route }),
detail: true,
},
{
title: 'Monitor',
description: 'CPU, disk, memory, and other useful metrics',
icon: 'pulse',
action: () => this.navCtrl.navigateForward(['metrics'], { relativeTo: this.route }),
detail: true,
},
{
title: 'Logs',
description: 'Raw, unfiltered device logs',
icon: 'newspaper-outline',
action: () => this.navCtrl.navigateForward(['logs'], { relativeTo: this.route }),
detail: true,
},
],
'Settings': [
{
title: 'Preferences',
description: 'Device name, background tasks',
icon: 'options-outline',
action: () => this.navCtrl.navigateForward(['preferences'], { relativeTo: this.route }),
detail: true,
},
{
title: 'LAN',
description: 'Access your Embassy on the Local Area Network',
icon: 'home-outline',
action: () => this.navCtrl.navigateForward(['lan'], { relativeTo: this.route }),
detail: true,
},
{
title: 'SSH',
description: 'Access your Embassy from the command line',
icon: 'terminal-outline',
action: () => this.navCtrl.navigateForward(['ssh'], { relativeTo: this.route }),
detail: true,
},
{
title: 'WiFi',
description: 'Add or remove WiFi networks',
icon: 'wifi',
action: () => this.navCtrl.navigateForward(['wifi'], { relativeTo: this.route }),
detail: true,
},
{
title: 'Active Sessions',
description: 'View and manage device access',
icon: 'desktop-outline',
action: () => this.navCtrl.navigateForward(['sessions'], { relativeTo: this.route }),
detail: true,
},
],
'Power': [
{
title: 'Restart',
description: '',
icon: 'reload',
action: () => this.presentAlertRestart(),
detail: false,
},
{
title: 'Shutdown',
description: '',
icon: 'power',
action: () => this.presentAlertShutdown(),
detail: false,
},
],
}
settings: ServerSettings = {
'Backups': [
{
title: 'Create Backup',
description: 'Back up your Embassy and all its services',
icon: 'save-outline',
action: () => this.navCtrl.navigateForward(['backup'], { relativeTo: this.route }),
detail: true,
disabled: of(false),
},
{
title: 'Restore From Backup',
description: 'Restore one or more services from a prior backup',
icon: 'color-wand-outline',
action: () => this.navCtrl.navigateForward(['restore'], { relativeTo: this.route }),
detail: true,
disabled: this.patch.watch$('server-info', 'status').pipe(map(status => [ServerStatus.Updated, ServerStatus.BackingUp].includes(status))),
},
],
'Insights': [
{
title: 'About',
description: 'Basic information about your Embassy',
icon: 'information-circle-outline',
action: () => this.navCtrl.navigateForward(['specs'], { relativeTo: this.route }),
detail: true,
disabled: of(false),
},
{
title: 'Monitor',
description: 'CPU, disk, memory, and other useful metrics',
icon: 'pulse',
action: () => this.navCtrl.navigateForward(['metrics'], { relativeTo: this.route }),
detail: true,
disabled: of(false),
},
{
title: 'Logs',
description: 'Raw, unfiltered device logs',
icon: 'newspaper-outline',
action: () => this.navCtrl.navigateForward(['logs'], { relativeTo: this.route }),
detail: true,
disabled: of(false),
},
],
'Settings': [
{
title: 'Preferences',
description: 'Device name, background tasks',
icon: 'options-outline',
action: () => this.navCtrl.navigateForward(['preferences'], { relativeTo: this.route }),
detail: true,
disabled: of(false),
},
{
title: 'LAN',
description: 'Access your Embassy on the Local Area Network',
icon: 'home-outline',
action: () => this.navCtrl.navigateForward(['lan'], { relativeTo: this.route }),
detail: true,
disabled: of(false),
},
{
title: 'SSH',
description: 'Access your Embassy from the command line',
icon: 'terminal-outline',
action: () => this.navCtrl.navigateForward(['ssh'], { relativeTo: this.route }),
detail: true,
disabled: of(false),
},
{
title: 'WiFi',
description: 'Add or remove WiFi networks',
icon: 'wifi',
action: () => this.navCtrl.navigateForward(['wifi'], { relativeTo: this.route }),
detail: true,
disabled: of(false),
},
{
title: 'Active Sessions',
description: 'View and manage device access',
icon: 'desktop-outline',
action: () => this.navCtrl.navigateForward(['sessions'], { relativeTo: this.route }),
detail: true,
disabled: of(false),
},
],
'Power': [
{
title: 'Restart',
description: '',
icon: 'reload',
action: () => this.presentAlertRestart(),
detail: false,
disabled: of(false),
},
{
title: 'Shutdown',
description: '',
icon: 'power',
action: () => this.presentAlertShutdown(),
detail: false,
disabled: of(false),
},
],
}
asIsOrder () {
@@ -212,5 +222,6 @@ interface ServerSettings {
icon: string
action: Function
detail: boolean
disabled: Observable<boolean>
}[]
}

View File

@@ -3,7 +3,7 @@ import { AlertController, LoadingController, ModalController } from '@ionic/angu
import { SSHKey } from 'src/app/services/api/api.types'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GenericInputComponent } from 'src/app/modals/generic-input/generic-input.component'
import { GenericInputComponent, GenericInputOptions } from 'src/app/modals/generic-input/generic-input.component'
@Component({
selector: 'ssh-keys',
@@ -40,23 +40,37 @@ export class SSHKeysPage {
async presentModalAdd () {
const { name, description } = sshSpec
const options: GenericInputOptions = {
title: name,
message: description,
label: name,
submitFn: (pk: string) => this.add(pk),
}
const modal = await this.modalCtrl.create({
component: GenericInputComponent,
componentProps: {
title: name,
message: description,
label: name,
loadingText: 'Saving',
submitFn: (pk: string) => this.add(pk),
},
componentProps: { options },
cssClass: 'alertlike-modal',
})
await modal.present()
}
async add (pubkey: string): Promise<void> {
const key = await this.embassyApi.addSshKey({ key: pubkey })
this.sshKeys.push(key)
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Saving...',
cssClass: 'loader',
})
await loader.present()
try {
const key = await this.embassyApi.addSshKey({ key: pubkey })
this.sshKeys.push(key)
} catch (e) {
throw new Error(e)
} finally {
loader.dismiss()
}
}
async presentAlertDelete (i: number) {

View File

@@ -109,6 +109,7 @@ export class WifiPage {
{
text: 'Forget',
icon: 'trash',
role: 'destructive',
handler: () => {
this.delete(ssid, i)
},

View File

@@ -93,12 +93,14 @@ export interface ListValueSpecString {
'pattern-description'?: string
masked: boolean
copyable: boolean
placeholder?: string
}
export interface ListValueSpecNumber {
range: string
integral: boolean
units?: string
placeholder?: string
}
export interface ListValueSpecEnum {

View File

@@ -975,50 +975,54 @@ export module Mock {
'signal-strength': 50,
}
export const Drives: RR.GetDrivesRes = [
{
logicalname: '/dev/sda',
model: null,
vendor: 'SSK',
partitions: [
{
logicalname: 'sdba1',
label: 'Matt Stuff',
capacity: 1000000000000,
used: 0,
'embassy-os': null,
},
],
capacity: 1000000000000,
guid: 'asdfasdf',
export const BackupTargets: RR.GetBackupTargetsRes = {
'hsbdjhasbasda': {
type: 'cifs',
hostname: 'smb://192.169.10.0',
path: '/Desktop/embassy-backups',
username: 'TestUser',
mountable: false,
'embassy-os': {
version: '0.3.0',
full: true,
'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNK',
'wrapped-key': '',
},
},
{
logicalname: '/dev/sdb',
model: 'JMS567 SATA 6Gb/s bridge',
vendor: 'Samsung',
partitions: [
{
logicalname: 'sdba1',
label: 'Partition 1',
capacity: 1000000000,
used: 1000000000,
'embassy-os': {
version: '0.3.0',
full: true,
},
},
{
logicalname: 'sdba2',
label: 'Partition 2',
capacity: 900000000,
used: 300000000,
'embassy-os': null,
},
],
capacity: 10000000000,
guid: null,
// 'ftcvewdnkemfksdm': {
// type: 'disk',
// logicalname: 'sdba1',
// label: 'Matt Stuff',
// capacity: 1000000000000,
// used: 0,
// model: 'Evo SATA 2.5',
// vendor: 'Samsung',
// 'embassy-os': null,
// },
'csgashbdjkasnd': {
type: 'cifs',
hostname: 'smb://192.169.10.0',
path: '/Desktop/embassy-backups-2',
username: 'TestUser',
mountable: true,
'embassy-os': null,
},
]
// 'powjefhjbnwhdva': {
// type: 'disk',
// logicalname: 'sdba1',
// label: 'Another Drive',
// capacity: 2000000000000,
// used: 100000000000,
// model: null,
// vendor: 'SSK',
// 'embassy-os': {
// version: '0.3.0',
// full: true,
// 'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
// 'wrapped-key': '',
// },
// },
}
export const BackupInfo: RR.GetBackupInfoRes = {
version: '0.3.0',

View File

@@ -118,17 +118,33 @@ export module RR {
// backup
export type CreateBackupReq = WithExpire<{ logicalname: string, password: string }> // backup.create
export type CreateBackupRes = WithRevision<null>
export type GetBackupTargetsReq = { } // backup.target.list
export type GetBackupTargetsRes = { [id: string]: BackupTarget }
// drive
export type AddBackupTargetReq = { // backup.target.cifs.add
hostname: string
path: string
username: string
password: string | null
}
export type AddBackupTargetRes = { [id: string]: CifsBackupTarget }
export type GetDrivesReq = { } // disk.list
export type GetDrivesRes = DriveInfo[]
export type UpdateBackupTargetReq = AddBackupTargetReq & { id: string } // backup.target.cifs.update
export type UpdateBackupTargetRes = AddBackupTargetRes
export type GetBackupInfoReq = { logicalname: string, password: string } // disk.backup-info
export type RemoveBackupTargetReq = { id: string } // backup.target.cifs.remove
export type RemoveBackupTargetRes = null
export type GetBackupInfoReq = { 'target-id': string, password: string } // backup.target.info
export type GetBackupInfoRes = BackupInfo
export type CreateBackupReq = WithExpire<{ // backup.create
'target-id': string
'old-password': string | null
password: string
}>
export type CreateBackupRes = WithRevision<null>
// package
export type GetPackagePropertiesReq = { id: string } // package.properties
@@ -157,7 +173,12 @@ export module RR {
export type SetPackageConfigReq = WithExpire<DrySetPackageConfigReq> // package.config.set
export type SetPackageConfigRes = WithRevision<null>
export type RestorePackagesReq = WithExpire<{ ids: string[], logicalname: string, password: string }> // package.backup.restore
export type RestorePackagesReq = WithExpire<{ // package.backup.restore
ids: string[]
'target-id': string
'old-password': string | null,
password: string
}>
export type RestorePackagesRes = WithRevision<null>
export type ExecutePackageActionReq = { id: string, 'action-id': string, input?: object } // package.action
@@ -200,8 +221,8 @@ export module RR {
export type GetMarketplacePackagesReq = {
ids?: { id: string, version: string }[]
// iff !id
'eos-version-compat': string
// iff !ids
category?: string
query?: string
page?: string
@@ -294,7 +315,51 @@ export interface SessionMetadata {
export type PlatformType = 'cli' | 'ios' | 'ipad' | 'iphone' | 'android' | 'phablet' | 'tablet' | 'cordova' | 'capacitor' | 'electron' | 'pwa' | 'mobile' | 'mobileweb' | 'desktop' | 'hybrid'
export interface DriveInfo {
export type BackupTarget = DiskBackupTarget | CifsBackupTarget
export interface EmbassyOSRecoveryInfo {
version: string
full: boolean
'password-hash': string | null
'wrapped-key': string | null
}
export interface DiskBackupTarget {
type: 'disk'
vendor: string | null
model: string | null
logicalname: string | null
label: string | null
capacity: number
used: number | null
'embassy-os': EmbassyOSRecoveryInfo | null
}
export interface CifsBackupTarget {
type: 'cifs'
hostname: string
path: string
username: string
mountable: boolean
'embassy-os': EmbassyOSRecoveryInfo | null
}
export type RecoverySource = DiskRecoverySource | CifsRecoverySource
export interface DiskRecoverySource {
type: 'disk'
logicalname: string // partition logicalname
}
export interface CifsRecoverySource {
type: 'cifs'
hostname: string
path: string
username: string
password: string
}
export interface DiskInfo {
logicalname: string
vendor: string | null
model: string | null
@@ -308,10 +373,10 @@ export interface PartitionInfo {
label: string | null
capacity: number
used: number | null
'embassy-os': EmbassyOsDriveInfo | null
'embassy-os': EmbassyOsDiskInfo | null
}
export interface EmbassyOsDriveInfo {
export interface EmbassyOsDiskInfo {
version: string
full: boolean
}

View File

@@ -121,17 +121,21 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
// backup
abstract getBackupTargets (params: RR.GetBackupTargetsReq): Promise<RR.GetBackupTargetsRes>
abstract addBackupTarget (params: RR.AddBackupTargetReq): Promise<RR.AddBackupTargetRes>
abstract updateBackupTarget (params: RR.UpdateBackupTargetReq): Promise<RR.UpdateBackupTargetRes>
abstract removeBackupTarget (params: RR.RemoveBackupTargetReq): Promise<RR.RemoveBackupTargetRes>
abstract getBackupInfo (params: RR.GetBackupInfoReq): Promise<RR.GetBackupInfoRes>
protected abstract createBackupRaw (params: RR.CreateBackupReq): Promise<RR.CreateBackupRes>
createBackup = (params: RR.CreateBackupReq) => this.syncResponse(
() => this.createBackupRaw(params),
)()
// drive
abstract getDrives (params: RR.GetDrivesReq): Promise<RR.GetDrivesRes>
abstract getBackupInfo (params: RR.GetBackupInfoReq): Promise<RR.GetBackupInfoRes>
// package
abstract getPackageProperties (params: RR.GetPackagePropertiesReq): Promise<RR.GetPackagePropertiesRes<any>['data']>

View File

@@ -90,7 +90,7 @@ export class LiveApiService extends ApiService {
})
}
async getMarketplaceData (params: RR.GetMarketplaceDataReq): Promise <RR.GetMarketplaceDataRes> {
async getMarketplaceData (params: RR.GetMarketplaceDataReq): Promise<RR.GetMarketplaceDataRes> {
return this.http.httpRequest({
method: Method.GET,
url: '/marketplace/package/data',
@@ -98,7 +98,7 @@ export class LiveApiService extends ApiService {
})
}
async getMarketplacePkgs (params: RR.GetMarketplacePackagesReq): Promise <RR.GetMarketplacePackagesRes> {
async getMarketplacePkgs (params: RR.GetMarketplacePackagesReq): Promise<RR.GetMarketplacePackagesRes> {
if (params.query) params.category = undefined
return this.http.httpRequest({
method: Method.GET,
@@ -110,7 +110,7 @@ export class LiveApiService extends ApiService {
})
}
async getReleaseNotes (params: RR.GetReleaseNotesReq): Promise <RR.GetReleaseNotesRes> {
async getReleaseNotes (params: RR.GetReleaseNotesReq): Promise<RR.GetReleaseNotesRes> {
return this.http.httpRequest({
method: Method.GET,
url: '/marketplace/package/release-notes',
@@ -118,7 +118,7 @@ export class LiveApiService extends ApiService {
})
}
async getLatestVersion (params: RR.GetLatestVersionReq): Promise <RR.GetLatestVersionRes> {
async getLatestVersion (params: RR.GetLatestVersionReq): Promise<RR.GetLatestVersionRes> {
return this.http.httpRequest({
method: Method.GET,
url: '/marketplace/latest-version',
@@ -137,138 +137,149 @@ export class LiveApiService extends ApiService {
// notification
async getNotificationsRaw (params: RR.GetNotificationsReq): Promise <RR.GetNotificationsRes> {
async getNotificationsRaw (params: RR.GetNotificationsReq): Promise<RR.GetNotificationsRes> {
return this.http.rpcRequest({ method: 'notification.list', params })
}
async deleteNotification (params: RR.DeleteNotificationReq): Promise <RR.DeleteNotificationRes> {
async deleteNotification (params: RR.DeleteNotificationReq): Promise<RR.DeleteNotificationRes> {
return this.http.rpcRequest({ method: 'notification.delete', params })
}
async deleteAllNotifications (params: RR.DeleteAllNotificationsReq): Promise <RR.DeleteAllNotificationsRes> {
async deleteAllNotifications (params: RR.DeleteAllNotificationsReq): Promise<RR.DeleteAllNotificationsRes> {
return this.http.rpcRequest({ method: 'notification.delete-before', params })
}
// wifi
async getWifi (params: RR.GetWifiReq, timeout?: number): Promise <RR.GetWifiRes> {
async getWifi (params: RR.GetWifiReq, timeout?: number): Promise<RR.GetWifiRes> {
return this.http.rpcRequest({ method: 'wifi.get', params, timeout })
}
async setWifiCountry (params: RR.SetWifiCountryReq): Promise <RR.SetWifiCountryRes> {
async setWifiCountry (params: RR.SetWifiCountryReq): Promise<RR.SetWifiCountryRes> {
return this.http.rpcRequest({ method: 'wifi.country.set', params })
}
async addWifi (params: RR.AddWifiReq): Promise <RR.AddWifiRes> {
async addWifi (params: RR.AddWifiReq): Promise<RR.AddWifiRes> {
return this.http.rpcRequest({ method: 'wifi.add', params })
}
async connectWifi (params: RR.ConnectWifiReq): Promise <RR.ConnectWifiRes> {
async connectWifi (params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes> {
return this.http.rpcRequest({ method: 'wifi.connect', params })
}
async deleteWifi (params: RR.DeleteWifiReq): Promise <RR.DeleteWifiRes> {
async deleteWifi (params: RR.DeleteWifiReq): Promise<RR.DeleteWifiRes> {
return this.http.rpcRequest({ method: 'wifi.delete', params })
}
// ssh
async getSshKeys (params: RR.GetSSHKeysReq): Promise <RR.GetSSHKeysRes> {
async getSshKeys (params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes> {
return this.http.rpcRequest({ method: 'ssh.list', params })
}
async addSshKey (params: RR.AddSSHKeyReq): Promise <RR.AddSSHKeyRes> {
async addSshKey (params: RR.AddSSHKeyReq): Promise<RR.AddSSHKeyRes> {
return this.http.rpcRequest({ method: 'ssh.add', params })
}
async deleteSshKey (params: RR.DeleteSSHKeyReq): Promise <RR.DeleteSSHKeyRes> {
async deleteSshKey (params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes> {
return this.http.rpcRequest({ method: 'ssh.delete', params })
}
// backup
async getBackupTargets (params: RR.GetBackupTargetsReq): Promise<RR.GetBackupTargetsRes> {
return this.http.rpcRequest({ method: 'backup.target.list', params })
}
async addBackupTarget (params: RR.AddBackupTargetReq): Promise<RR.AddBackupTargetRes> {
params.path = params.path.replace('/\\/g', '/')
return this.http.rpcRequest({ method: 'backup.target.cifs.add', params })
}
async updateBackupTarget (params: RR.UpdateBackupTargetReq): Promise<RR.UpdateBackupTargetRes> {
return this.http.rpcRequest({ method: 'backup.target.cifs.update', params })
}
async removeBackupTarget (params: RR.RemoveBackupTargetReq): Promise<RR.RemoveBackupTargetRes> {
return this.http.rpcRequest({ method: 'backup.target.cifs.remove', params })
}
async getBackupInfo (params: RR.GetBackupInfoReq): Promise<RR.GetBackupInfoRes> {
return this.http.rpcRequest({ method: 'backup.target.info', params })
}
async createBackupRaw (params: RR.CreateBackupReq): Promise <RR.CreateBackupRes> {
return this.http.rpcRequest({ method: 'backup.create', params })
}
// drives
getDrives (params: RR.GetDrivesReq): Promise <RR.GetDrivesRes> {
return this.http.rpcRequest({ method: 'disk.list', params })
}
getBackupInfo (params: RR.GetBackupInfoReq): Promise <RR.GetBackupInfoRes> {
return this.http.rpcRequest({ method: 'disk.backup-info', params })
}
// package
async getPackageProperties (params: RR.GetPackagePropertiesReq): Promise <RR.GetPackagePropertiesRes < any > ['data'] > {
async getPackageProperties (params: RR.GetPackagePropertiesReq): Promise<RR.GetPackagePropertiesRes < any > ['data'] > {
return this.http.rpcRequest({ method: 'package.properties', params })
.then(parsePropertiesPermissive)
}
async getPackageLogs (params: RR.GetPackageLogsReq): Promise <RR.GetPackageLogsRes> {
async getPackageLogs (params: RR.GetPackageLogsReq): Promise<RR.GetPackageLogsRes> {
return this.http.rpcRequest( { method: 'package.logs', params })
}
async getPkgMetrics (params: RR.GetPackageMetricsReq): Promise <RR.GetPackageMetricsRes> {
async getPkgMetrics (params: RR.GetPackageMetricsReq): Promise<RR.GetPackageMetricsRes> {
return this.http.rpcRequest({ method: 'package.metrics', params })
}
async installPackageRaw (params: RR.InstallPackageReq): Promise <RR.InstallPackageRes> {
async installPackageRaw (params: RR.InstallPackageReq): Promise<RR.InstallPackageRes> {
return this.http.rpcRequest({ method: 'package.install', params })
}
async dryUpdatePackage (params: RR.DryUpdatePackageReq): Promise <RR.DryUpdatePackageRes> {
async dryUpdatePackage (params: RR.DryUpdatePackageReq): Promise<RR.DryUpdatePackageRes> {
return this.http.rpcRequest({ method: 'package.update.dry', params })
}
async getPackageConfig (params: RR.GetPackageConfigReq): Promise <RR.GetPackageConfigRes> {
async getPackageConfig (params: RR.GetPackageConfigReq): Promise<RR.GetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.get', params })
}
async drySetPackageConfig (params: RR.DrySetPackageConfigReq): Promise <RR.DrySetPackageConfigRes> {
async drySetPackageConfig (params: RR.DrySetPackageConfigReq): Promise<RR.DrySetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.set.dry', params })
}
async setPackageConfigRaw (params: RR.SetPackageConfigReq): Promise <RR.SetPackageConfigRes> {
async setPackageConfigRaw (params: RR.SetPackageConfigReq): Promise<RR.SetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.set', params })
}
async restorePackagesRaw (params: RR.RestorePackagesReq): Promise <RR.RestorePackagesRes> {
async restorePackagesRaw (params: RR.RestorePackagesReq): Promise<RR.RestorePackagesRes> {
return this.http.rpcRequest({ method: 'package.backup.restore', params })
}
async executePackageAction (params: RR.ExecutePackageActionReq): Promise <RR.ExecutePackageActionRes> {
async executePackageAction (params: RR.ExecutePackageActionReq): Promise<RR.ExecutePackageActionRes> {
return this.http.rpcRequest({ method: 'package.action', params })
}
async startPackageRaw (params: RR.StartPackageReq): Promise <RR.StartPackageRes> {
async startPackageRaw (params: RR.StartPackageReq): Promise<RR.StartPackageRes> {
return this.http.rpcRequest({ method: 'package.start', params })
}
async dryStopPackage (params: RR.DryStopPackageReq): Promise <RR.DryStopPackageRes> {
async dryStopPackage (params: RR.DryStopPackageReq): Promise<RR.DryStopPackageRes> {
return this.http.rpcRequest({ method: 'package.stop.dry', params })
}
async stopPackageRaw (params: RR.StopPackageReq): Promise <RR.StopPackageRes> {
async stopPackageRaw (params: RR.StopPackageReq): Promise<RR.StopPackageRes> {
return this.http.rpcRequest({ method: 'package.stop', params })
}
async dryUninstallPackage (params: RR.DryUninstallPackageReq): Promise <RR.DryUninstallPackageRes> {
async dryUninstallPackage (params: RR.DryUninstallPackageReq): Promise<RR.DryUninstallPackageRes> {
return this.http.rpcRequest({ method: 'package.uninstall.dry', params })
}
async deleteRecoveredPackageRaw (params: RR.DeleteRecoveredPackageReq): Promise <RR.DeleteRecoveredPackageRes> {
async deleteRecoveredPackageRaw (params: RR.DeleteRecoveredPackageReq): Promise<RR.DeleteRecoveredPackageRes> {
return this.http.rpcRequest({ method: 'package.delete-recovered', params })
}
async uninstallPackageRaw (params: RR.UninstallPackageReq): Promise <RR.UninstallPackageRes> {
async uninstallPackageRaw (params: RR.UninstallPackageReq): Promise<RR.UninstallPackageRes> {
return this.http.rpcRequest({ method: 'package.uninstall', params })
}
async dryConfigureDependency (params: RR.DryConfigureDependencyReq): Promise <RR.DryConfigureDependencyRes> {
async dryConfigureDependency (params: RR.DryConfigureDependencyReq): Promise<RR.DryConfigureDependencyRes> {
return this.http.rpcRequest({ method: 'package.dependency.configure.dry', params })
}
}

View File

@@ -3,7 +3,7 @@ import { pauseFor } from '../../util/misc.util'
import { ApiService } from './embassy-api.service'
import { PatchOp, Update, Operation, RemoveOperation } from 'patch-db-client'
import { DataModel, DependencyErrorType, InstallProgress, PackageDataEntry, PackageMainStatus, PackageState, ServerStatus } from 'src/app/services/patch-db/data-model'
import { Log, RR, WithRevision } from './api.types'
import { CifsBackupTarget, Log, RR, WithRevision } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { Mock } from './api.fixures'
import markdown from 'raw-loader!src/assets/markdown/md-sample.md'
@@ -277,6 +277,49 @@ export class MockApiService extends ApiService {
// backup
async getBackupTargets (params: RR.GetBackupTargetsReq): Promise<RR.GetBackupTargetsRes> {
await pauseFor(2000)
return Mock.BackupTargets
}
async addBackupTarget (params: RR.AddBackupTargetReq): Promise<RR.AddBackupTargetRes> {
await pauseFor(2000)
const { hostname, path, username } = params
return {
'latfgvwdbhjsndmk': {
type: 'cifs',
hostname,
path: path.replace(/\\/g, '/'),
username,
mountable: true,
'embassy-os': null,
},
}
}
async updateBackupTarget (params: RR.UpdateBackupTargetReq): Promise<RR.UpdateBackupTargetRes> {
await pauseFor(2000)
const { id, hostname, path, username } = params
return {
[id]: {
...Mock.BackupTargets[id] as CifsBackupTarget,
hostname,
path,
username,
},
}
}
async removeBackupTarget (params: RR.RemoveBackupTargetReq): Promise<RR.RemoveBackupTargetRes> {
await pauseFor(2000)
return null
}
async getBackupInfo (params: RR.GetBackupInfoReq): Promise<RR.GetBackupInfoRes> {
await pauseFor(2000)
return Mock.BackupInfo
}
async createBackupRaw (params: RR.CreateBackupReq): Promise<RR.CreateBackupRes> {
await pauseFor(2000)
const path = '/server-info/status'
@@ -324,18 +367,6 @@ export class MockApiService extends ApiService {
return this.withRevision(originalPatch)
}
// drives
async getDrives (params: RR.GetDrivesReq): Promise<RR.GetDrivesRes> {
await pauseFor(2000)
return Mock.Drives
}
async getBackupInfo (params: RR.GetBackupInfoReq): Promise<RR.GetBackupInfoRes> {
await pauseFor(2000)
return Mock.BackupInfo
}
// package
async getPackageProperties (params: RR.GetPackagePropertiesReq): Promise<RR.GetPackagePropertiesRes<any>['data']> {

View File

@@ -19,7 +19,7 @@ export const mockPatchData: DataModel = {
'package-marketplace': null,
'share-stats': false,
'unread-notification-count': 4,
// 'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
'eos-version-compat': '>=0.3.0',
},
'recovered-packages': {

View File

@@ -6,14 +6,13 @@ import { Storage } from '@ionic/storage-angular'
export enum AuthState {
UNVERIFIED,
VERIFIED,
INITIALIZING,
}
@Injectable({
providedIn: 'root',
})
export class AuthService {
private readonly LOGGED_IN_KEY = 'loggedInKey'
private readonly authState$: BehaviorSubject<AuthState> = new BehaviorSubject(AuthState.INITIALIZING)
private readonly authState$: BehaviorSubject<AuthState> = new BehaviorSubject(undefined)
constructor (
private readonly storage: Storage,

View File

@@ -133,10 +133,7 @@ export enum Method {
export interface RPCOptions {
method: string
// @TODO what are valid params? object, bool?
params?: {
[param: string]: string | number | boolean | object | string[] | number[];
}
params?: object
timeout?: number
}

View File

@@ -31,6 +31,7 @@ export interface ServerInfo {
downloaded: number
}
'eos-version-compat': string
'password-hash': string
}
export enum ServerStatus {
@@ -129,7 +130,7 @@ export interface Manifest {
backup: BackupActions
migrations: Migrations
actions: { [id: string]: Action }
permissions: any // @TODO
permissions: any // @TODO 0.3.1
dependencies: DependencyInfo
}

View File

@@ -145,7 +145,7 @@ export const serverConfig: ConfigSpec = {
'share-stats': {
type: 'boolean',
name: 'Report Bugs',
description: new IonicSafeString(`If enabled, generic error codes will be anonymously transmitted over Tor to the Start9 team. This helps us identify and fix bugs quickly. <a href="https://docs.start9.com" target="_blank" rel="noreferrer">Read more</a> `) as any, // @TODO get actual link
description: new IonicSafeString(`If enabled, generic error codes will be anonymously transmitted over Tor to the Start9 team. This helps us identify and fix bugs quickly. <a href="https://docs.start9.com/user-manual/general/user-preferences/report-bugs.html" target="_blank" rel="noreferrer">Read more</a> `) as any,
default: false,
},
// password: {

View File

@@ -1,10 +1,15 @@
import { OperatorFunction } from 'rxjs'
import { map } from 'rxjs/operators'
import { DriveInfo, PartitionInfo } from '../services/api/api.types'
export type Omit<ObjectType, KeysType extends keyof ObjectType> = Pick<ObjectType, Exclude<keyof ObjectType, KeysType>>
export type PromiseRes<T> = { result: 'resolve', value: T } | { result: 'reject', value: Error }
export interface MappedBackupTarget<T> {
id: string
hasValidBackup: boolean
entry: T
}
export interface DependentInfo {
id: string
title: string
@@ -190,11 +195,3 @@ export function debounce (delay: number = 300): MethodDecorator {
return descriptor
}
}
export interface MappedDriveInfo extends DriveInfo {
partitions: MappedPartitionInfo[]
}
export interface MappedPartitionInfo extends PartitionInfo {
hasBackup: boolean
}