mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-01 21:13:09 +00:00
feat: move all frontend projects under the same Angular workspace (#1141)
* feat: move all frontend projects under the same Angular workspace * Refactor/angular workspace (#1154) * update frontend build steps Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Matt Hill <matthewonthemoon@gmail.com> Co-authored-by: Lucy Cifferello <12953208+elvece@users.noreply.github.com>
This commit is contained in:
49
frontend/projects/setup-wizard/src/app/app-routing.module.ts
Normal file
49
frontend/projects/setup-wizard/src/app/app-routing.module.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
|
||||
import { NavGuard, RecoveryNavGuard } from './guards/nav-guard'
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', redirectTo: '/product-key', pathMatch: 'full' },
|
||||
{
|
||||
path: 'init',
|
||||
loadChildren: () => import('./pages/init/init.module').then( m => m.InitPageModule),
|
||||
canActivate: [NavGuard],
|
||||
},
|
||||
{
|
||||
path: 'product-key',
|
||||
loadChildren: () => import('./pages/product-key/product-key.module').then( m => m.ProductKeyPageModule),
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () => import('./pages/home/home.module').then( m => m.HomePageModule),
|
||||
canActivate: [NavGuard],
|
||||
},
|
||||
{
|
||||
path: 'recover',
|
||||
loadChildren: () => import('./pages/recover/recover.module').then( m => m.RecoverPageModule),
|
||||
canActivate: [RecoveryNavGuard],
|
||||
},
|
||||
{
|
||||
path: 'embassy',
|
||||
loadChildren: () => import('./pages/embassy/embassy.module').then( m => m.EmbassyPageModule),
|
||||
canActivate: [NavGuard],
|
||||
},
|
||||
{
|
||||
path: 'loading',
|
||||
loadChildren: () => import('./pages/loading/loading.module').then( m => m.LoadingPageModule),
|
||||
canActivate: [NavGuard],
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, {
|
||||
scrollPositionRestoration: 'enabled',
|
||||
preloadingStrategy: PreloadAllModules,
|
||||
useHash: true,
|
||||
initialNavigation: 'disabled',
|
||||
}),
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
@@ -0,0 +1,3 @@
|
||||
<ion-app>
|
||||
<ion-router-outlet></ion-router-outlet>
|
||||
</ion-app>
|
||||
36
frontend/projects/setup-wizard/src/app/app.component.ts
Normal file
36
frontend/projects/setup-wizard/src/app/app.component.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { ErrorToastService } from './services/error-toast.service'
|
||||
import { StateService } from './services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
styleUrls: ['app.component.scss'],
|
||||
})
|
||||
export class AppComponent {
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly errorToastService: ErrorToastService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly stateService: StateService,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
try {
|
||||
const status = await this.apiService.getStatus()
|
||||
if (status.migrating || status['product-key']) {
|
||||
this.stateService.hasProductKey = true
|
||||
this.stateService.isMigrating = status.migrating
|
||||
await this.navCtrl.navigateForward(`/product-key`)
|
||||
} else {
|
||||
this.stateService.hasProductKey = false
|
||||
this.stateService.isMigrating = false
|
||||
await this.navCtrl.navigateForward(`/recover`)
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorToastService.present(`${e.message}: ${e.details}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
64
frontend/projects/setup-wizard/src/app/app.module.ts
Normal file
64
frontend/projects/setup-wizard/src/app/app.module.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ErrorHandler, NgModule } from '@angular/core'
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
import { RouteReuseStrategy } from '@angular/router'
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { MockApiService } from './services/api/mock-api.service'
|
||||
import { LiveApiService } from './services/api/live-api.service'
|
||||
import { HttpService } from './services/api/http.service'
|
||||
import {
|
||||
IonicModule,
|
||||
IonicRouteStrategy,
|
||||
iosTransitionAnimation,
|
||||
} from '@ionic/angular'
|
||||
import { AppComponent } from './app.component'
|
||||
import { AppRoutingModule } from './app-routing.module'
|
||||
import { GlobalErrorHandler } from './services/global-error-handler.service'
|
||||
import { SuccessPageModule } from './pages/success/success.module'
|
||||
import { InitPageModule } from './pages/init/init.module'
|
||||
import { HomePageModule } from './pages/home/home.module'
|
||||
import { LoadingPageModule } from './pages/loading/loading.module'
|
||||
import { ProdKeyModalModule } from './modals/prod-key-modal/prod-key-modal.module'
|
||||
import { ProductKeyPageModule } from './pages/product-key/product-key.module'
|
||||
import { RecoverPageModule } from './pages/recover/recover.module'
|
||||
import { WorkspaceConfig } from '@shared'
|
||||
|
||||
const { useMocks } = require('../../../../config.json') as WorkspaceConfig
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
entryComponents: [],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
IonicModule.forRoot({
|
||||
mode: 'md',
|
||||
navAnimation: iosTransitionAnimation,
|
||||
}),
|
||||
AppRoutingModule,
|
||||
HttpClientModule,
|
||||
SuccessPageModule,
|
||||
HomePageModule,
|
||||
LoadingPageModule,
|
||||
ProdKeyModalModule,
|
||||
ProductKeyPageModule,
|
||||
RecoverPageModule,
|
||||
InitPageModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||
{
|
||||
provide: ApiService,
|
||||
useFactory: (http: HttpService) => {
|
||||
if (useMocks) {
|
||||
return new MockApiService()
|
||||
} else {
|
||||
return new LiveApiService(http)
|
||||
}
|
||||
},
|
||||
deps: [HttpService],
|
||||
},
|
||||
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
43
frontend/projects/setup-wizard/src/app/guards/nav-guard.ts
Normal file
43
frontend/projects/setup-wizard/src/app/guards/nav-guard.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { CanActivate, Router } from '@angular/router'
|
||||
import { HttpService } from '../services/api/http.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class NavGuard implements CanActivate {
|
||||
constructor (
|
||||
private readonly router: Router,
|
||||
private readonly httpService: HttpService,
|
||||
) { }
|
||||
|
||||
canActivate (): boolean {
|
||||
if (this.httpService.productKey) {
|
||||
return true
|
||||
} else {
|
||||
this.router.navigateByUrl('product-key')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class RecoveryNavGuard implements CanActivate {
|
||||
constructor (
|
||||
private readonly router: Router,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly stateService: StateService,
|
||||
) { }
|
||||
|
||||
canActivate (): boolean {
|
||||
if (this.httpService.productKey || !this.stateService.hasProductKey) {
|
||||
return true
|
||||
} else {
|
||||
this.router.navigateByUrl('product-key')
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { CifsModal } from './cifs-modal.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
CifsModal,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
],
|
||||
exports: [
|
||||
CifsModal,
|
||||
],
|
||||
})
|
||||
export class CifsModalModule { }
|
||||
@@ -0,0 +1,82 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>
|
||||
Connect Shared Folder
|
||||
</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<form (ngSubmit)="submit()" #cifsForm="ngForm">
|
||||
<p>Hostname *</p>
|
||||
<ion-item>
|
||||
<ion-input
|
||||
id="hostname"
|
||||
required
|
||||
[(ngModel)]="cifs.hostname"
|
||||
name="hostname"
|
||||
#hostname="ngModel"
|
||||
placeholder="e.g. 'My Computer' OR 'my-computer.local'"
|
||||
pattern="^[a-zA-Z0-9._-]+( [a-zA-Z0-9]+)*$"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
<p [hidden]="hostname.valid || hostname.pristine">
|
||||
<ion-text color="danger">Hostname is required. e.g. 'My Computer' OR 'my-computer.local'</ion-text>
|
||||
</p>
|
||||
|
||||
<p>Path *</p>
|
||||
<ion-item>
|
||||
<ion-input
|
||||
id="path"
|
||||
required
|
||||
[(ngModel)]="cifs.path"
|
||||
name="path"
|
||||
#path="ngModel"
|
||||
placeholder="ex. /Desktop/my-folder'"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
<p [hidden]="path.valid || path.pristine">
|
||||
<ion-text color="danger">Path is required</ion-text>
|
||||
</p>
|
||||
|
||||
<p>Username *</p>
|
||||
<ion-item>
|
||||
<ion-input
|
||||
id="username"
|
||||
required
|
||||
[(ngModel)]="cifs.username"
|
||||
name="username"
|
||||
#username="ngModel"
|
||||
placeholder="Enter username"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
<p [hidden]="username.valid || username.pristine">
|
||||
<ion-text color="danger">Username is required</ion-text>
|
||||
</p>
|
||||
|
||||
<p>Password</p>
|
||||
<ion-item>
|
||||
<ion-input
|
||||
id="password"
|
||||
type="password"
|
||||
[(ngModel)]="cifs.password"
|
||||
name="password"
|
||||
#password="ngModel"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
|
||||
<button hidden type="submit"></button>
|
||||
</form>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" (click)="cancel()">
|
||||
Cancel
|
||||
</ion-button>
|
||||
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" strong="true" [disabled]="!cifsForm.form.valid" (click)="submit()">
|
||||
Verify
|
||||
</ion-button>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ion-content {
|
||||
--ion-text-color: var(--ion-color-dark);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { AlertController, LoadingController, ModalController } from '@ionic/angular'
|
||||
import { ApiService, CifsBackupTarget, EmbassyOSRecoveryInfo } from 'src/app/services/api/api.service'
|
||||
import { PasswordPage } from '../password/password.page'
|
||||
|
||||
@Component({
|
||||
selector: 'cifs-modal',
|
||||
templateUrl: 'cifs-modal.page.html',
|
||||
styleUrls: ['cifs-modal.page.scss'],
|
||||
})
|
||||
export class CifsModal {
|
||||
cifs = {
|
||||
type: 'cifs' as 'cifs',
|
||||
hostname: '',
|
||||
path: '',
|
||||
username: '',
|
||||
password: '',
|
||||
}
|
||||
|
||||
constructor (
|
||||
private readonly modalController: ModalController,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
) { }
|
||||
|
||||
cancel () {
|
||||
this.modalController.dismiss()
|
||||
}
|
||||
|
||||
async submit (): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
message: 'Connecting to shared folder...',
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
const embassyOS = await this.apiService.verifyCifs(this.cifs)
|
||||
const is02x = embassyOS.version.startsWith('0.2')
|
||||
|
||||
if (is02x) {
|
||||
this.modalController.dismiss({
|
||||
cifs: this.cifs,
|
||||
}, 'success')
|
||||
} else {
|
||||
this.presentModalPassword(embassyOS)
|
||||
}
|
||||
} catch (e) {
|
||||
this.presentAlertFailed()
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async presentModalPassword (embassyOS: EmbassyOSRecoveryInfo): Promise<void> {
|
||||
const target: CifsBackupTarget = {
|
||||
...this.cifs,
|
||||
mountable: true,
|
||||
'embassy-os': embassyOS,
|
||||
}
|
||||
|
||||
const modal = await this.modalController.create({
|
||||
component: PasswordPage,
|
||||
componentProps: { target },
|
||||
cssClass: 'alertlike-modal',
|
||||
})
|
||||
modal.onDidDismiss().then(res => {
|
||||
if (res.role === 'success') {
|
||||
this.modalController.dismiss({
|
||||
cifs: this.cifs,
|
||||
recoveryPassword: res.data.password,
|
||||
}, 'success')
|
||||
}
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async presentAlertFailed (): Promise<void> {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Connection Failed',
|
||||
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.',
|
||||
buttons: ['OK'],
|
||||
})
|
||||
alert.present()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { PasswordPage } from './password.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
PasswordPage,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
],
|
||||
exports: [
|
||||
PasswordPage,
|
||||
],
|
||||
})
|
||||
export class PasswordPageModule { }
|
||||
@@ -0,0 +1,72 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>
|
||||
{{ !!storageDrive ? 'Set Password' : 'Unlock Drive' }}
|
||||
</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<div style="padding: 8px 24px;">
|
||||
<div style="padding-bottom: 16px;">
|
||||
<ng-container *ngIf="!!storageDrive">
|
||||
<p>Choose a password for your Embassy. <i>Make it good. Write it down.</i></p>
|
||||
<p style="color: var(--ion-color-warning);">Losing your password can result in total loss of data.</p>
|
||||
</ng-container>
|
||||
<p *ngIf="!storageDrive">Enter the password that was used to encrypt this drive.</p>
|
||||
</div>
|
||||
|
||||
<form (ngSubmit)="!!storageDrive ? submitPw() : verifyPw()">
|
||||
<p>Password</p>
|
||||
<ion-item [class]="pwError ? 'error-border' : password && !!storageDrive ? 'success-border' : ''">
|
||||
<ion-input
|
||||
#focusInput
|
||||
[(ngModel)]="password"
|
||||
[ngModelOptions]="{'standalone': true}"
|
||||
[type]="!unmasked1 ? 'password' : 'text'"
|
||||
placeholder="Enter Password"
|
||||
(ionChange)="validate()"
|
||||
maxlength="64"
|
||||
></ion-input>
|
||||
<ion-button fill="clear" color="light" (click)="unmasked1 = !unmasked1">
|
||||
<ion-icon slot="icon-only" [name]="unmasked1 ? 'eye-off-outline' : 'eye-outline'" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<div style="height: 16px;">
|
||||
<p style="color: var(--ion-color-danger); font-size: x-small;">{{ pwError }}</p>
|
||||
</div>
|
||||
<ng-container *ngIf="!!storageDrive">
|
||||
<p>Confirm Password</p>
|
||||
<ion-item [class]="verError ? 'error-border' : passwordVer ? 'success-border' : ''">
|
||||
<ion-input
|
||||
[(ngModel)]="passwordVer"
|
||||
[ngModelOptions]="{'standalone': true}"
|
||||
[type]="!unmasked2 ? 'password' : 'text'"
|
||||
(ionChange)="checkVer()"
|
||||
maxlength="64"
|
||||
placeholder="Retype Password"
|
||||
></ion-input>
|
||||
<ion-button fill="clear" color="light" (click)="unmasked2 = !unmasked2">
|
||||
<ion-icon slot="icon-only" [name]="unmasked2 ? 'eye-off-outline' : 'eye-outline'" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<div style="height: 16px;">
|
||||
<p style="color: var(--ion-color-danger); font-size: x-small;">{{ verError }}</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
<input type="submit" style="display: none" />
|
||||
</form>
|
||||
</div>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" (click)="cancel()">
|
||||
Cancel
|
||||
</ion-button>
|
||||
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" strong="true" (click)="!!storageDrive ? submitPw() : verifyPw()">
|
||||
{{ !!storageDrive ? 'Finish' : 'Unlock' }}
|
||||
</ion-button>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ion-content {
|
||||
--ion-text-color: var(--ion-color-dark);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Component, Input, ViewChild } from '@angular/core'
|
||||
import { IonInput, ModalController } from '@ionic/angular'
|
||||
import { DiskInfo, CifsBackupTarget, DiskBackupTarget } from 'src/app/services/api/api.service'
|
||||
import * as argon2 from '@start9labs/argon2'
|
||||
|
||||
@Component({
|
||||
selector: 'app-password',
|
||||
templateUrl: 'password.page.html',
|
||||
styleUrls: ['password.page.scss'],
|
||||
})
|
||||
export class PasswordPage {
|
||||
@ViewChild('focusInput') elem: IonInput
|
||||
@Input() target: CifsBackupTarget | DiskBackupTarget
|
||||
@Input() storageDrive: DiskInfo
|
||||
|
||||
pwError = ''
|
||||
password = ''
|
||||
unmasked1 = false
|
||||
|
||||
verError = ''
|
||||
passwordVer = ''
|
||||
unmasked2 = false
|
||||
|
||||
constructor (
|
||||
private modalController: ModalController,
|
||||
) { }
|
||||
|
||||
ngAfterViewInit () {
|
||||
setTimeout(() => this.elem.setFocus(), 400)
|
||||
}
|
||||
|
||||
async verifyPw () {
|
||||
if (!this.target || !this.target['embassy-os']) this.pwError = 'No recovery target' // unreachable
|
||||
|
||||
try {
|
||||
argon2.verify(this.target['embassy-os']['password-hash'], this.password)
|
||||
this.modalController.dismiss({ password: this.password }, 'success')
|
||||
} catch (e) {
|
||||
this.pwError = 'Incorrect password provided'
|
||||
}
|
||||
}
|
||||
|
||||
async submitPw () {
|
||||
this.validate()
|
||||
if (this.password !== this.passwordVer) {
|
||||
this.verError = '*passwords do not match'
|
||||
}
|
||||
|
||||
if (this.pwError || this.verError) return
|
||||
this.modalController.dismiss({ password: this.password }, 'success')
|
||||
}
|
||||
|
||||
validate () {
|
||||
if (!!this.target) return this.pwError = ''
|
||||
|
||||
if (this.passwordVer) {
|
||||
this.checkVer()
|
||||
}
|
||||
|
||||
if (this.password.length < 12) {
|
||||
this.pwError = 'Must be 12 characters or greater'
|
||||
} else {
|
||||
this.pwError = ''
|
||||
}
|
||||
}
|
||||
|
||||
checkVer () {
|
||||
this.verError = this.password !== this.passwordVer ? 'Passwords do not match' : ''
|
||||
}
|
||||
|
||||
cancel () {
|
||||
this.modalController.dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ProdKeyModal } from './prod-key-modal.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
ProdKeyModal,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
],
|
||||
exports: [
|
||||
ProdKeyModal,
|
||||
],
|
||||
})
|
||||
export class ProdKeyModalModule { }
|
||||
@@ -0,0 +1,41 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>
|
||||
Enter Product Key
|
||||
</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<form (ngSubmit)="verifyProductKey()">
|
||||
<div style="padding: 8px 24px;">
|
||||
<div style="padding-bottom: 16px;">
|
||||
<p>Enter your 0.2.x Product Key to establish an encrypted connection with your new Embassy.</p>
|
||||
</div>
|
||||
<ion-item>
|
||||
<ion-input
|
||||
#focusInput
|
||||
[(ngModel)]="productKey"
|
||||
placeholder="Enter Product Key"
|
||||
maxlength="12"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
<div style="height: 16px;">
|
||||
<p style="color: var(--ion-color-danger); font-size: x-small;">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<input type="submit" style="display: none" />
|
||||
</form>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" (click)="cancel()">
|
||||
Cancel
|
||||
</ion-button>
|
||||
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" strong="true" (click)="verifyProductKey()">
|
||||
Submit
|
||||
</ion-button>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ion-content {
|
||||
--ion-text-color: var(--ion-color-dark);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Component, Input, ViewChild } from '@angular/core'
|
||||
import { IonInput, LoadingController, ModalController } from '@ionic/angular'
|
||||
import { ApiService, DiskBackupTarget } from 'src/app/services/api/api.service'
|
||||
import { HttpService } from 'src/app/services/api/http.service'
|
||||
|
||||
@Component({
|
||||
selector: 'prod-key-modal',
|
||||
templateUrl: 'prod-key-modal.page.html',
|
||||
styleUrls: ['prod-key-modal.page.scss'],
|
||||
})
|
||||
export class ProdKeyModal {
|
||||
@ViewChild('focusInput') elem: IonInput
|
||||
@Input() target: DiskBackupTarget
|
||||
|
||||
error = ''
|
||||
productKey = ''
|
||||
unmasked = false
|
||||
|
||||
constructor (
|
||||
private readonly modalController: ModalController,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly httpService: HttpService,
|
||||
) { }
|
||||
|
||||
ngAfterViewInit () {
|
||||
setTimeout(() => this.elem.setFocus(), 400)
|
||||
}
|
||||
|
||||
async verifyProductKey () {
|
||||
if (!this.productKey) return
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Verifying Product Key',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.apiService.set02XDrive(this.target.logicalname)
|
||||
this.httpService.productKey = this.productKey
|
||||
await this.apiService.verifyProductKey()
|
||||
this.modalController.dismiss({ productKey: this.productKey }, 'success')
|
||||
} catch (e) {
|
||||
this.httpService.productKey = undefined
|
||||
this.error = 'Invalid Product Key'
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
cancel () {
|
||||
this.modalController.dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { EmbassyPage } from './embassy.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: EmbassyPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class EmbassyPageRoutingModule { }
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { EmbassyPage } from './embassy.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
import { EmbassyPageRoutingModule } from './embassy-routing.module'
|
||||
import { PipesModule } from 'src/app/pipes/pipe.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
EmbassyPageRoutingModule,
|
||||
PasswordPageModule,
|
||||
PipesModule,
|
||||
],
|
||||
declarations: [EmbassyPage],
|
||||
})
|
||||
export class EmbassyPageModule { }
|
||||
@@ -0,0 +1,55 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col class="ion-text-center">
|
||||
|
||||
<div style="padding-bottom: 32px;">
|
||||
<img src="assets/img/logo.png" style="max-width: 240px;" />
|
||||
</div>
|
||||
|
||||
<ion-card color="dark">
|
||||
<ion-card-header class="ion-text-center" style="padding-bottom: 8px;" *ngIf="loading || storageDrives.length; else empty">
|
||||
<ion-card-title>Select Storage Drive</ion-card-title>
|
||||
<ion-card-subtitle>Select the drive where your Embassy data will be stored.</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
<ng-template #empty>
|
||||
<ion-card-header class="ion-text-center" style="padding-bottom: 8px;">
|
||||
<ion-card-title>No drives found</ion-card-title>
|
||||
<ion-card-subtitle>Please connect a storage drive to your Embassy and click "Refresh".</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
</ng-template>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<!-- loading -->
|
||||
<ion-spinner *ngIf="loading; else loaded" class="center-spinner" name="lines"></ion-spinner>
|
||||
|
||||
<!-- not loading -->
|
||||
<ng-template #loaded>
|
||||
<ng-container *ngIf="!storageDrives.length">
|
||||
<ion-button fill="clear" color="primary" (click)="getDrives()">
|
||||
<ion-icon slot="start" name='refresh'></ion-icon>
|
||||
Refresh
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
|
||||
<ion-item-group *ngIf="storageDrives.length">
|
||||
<ion-item (click)="chooseDrive(drive)" class="ion-margin-bottom" [disabled]="tooSmall(drive)" button lines="none" *ngFor="let drive of storageDrives">
|
||||
<ion-icon slot="start" name="save-outline" size="large" color="light"></ion-icon>
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h1>{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model || 'Unknown Model' }}</h1>
|
||||
<h2>{{ drive.logicalname }} - {{ drive.capacity | convertBytes }}</h2>
|
||||
<p *ngIf=tooSmall(drive)>
|
||||
<ion-text color="danger">
|
||||
Drive capacity too small.
|
||||
</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ng-template>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { AlertController, LoadingController, ModalController, NavController } from '@ionic/angular'
|
||||
import { ApiService, DiskInfo, DiskRecoverySource } from 'src/app/services/api/api.service'
|
||||
import { ErrorToastService } from 'src/app/services/error-toast.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PasswordPage } from '../../modals/password/password.page'
|
||||
|
||||
@Component({
|
||||
selector: 'app-embassy',
|
||||
templateUrl: 'embassy.page.html',
|
||||
styleUrls: ['embassy.page.scss'],
|
||||
})
|
||||
export class EmbassyPage {
|
||||
storageDrives: DiskInfo[] = []
|
||||
loading = true
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly modalController: ModalController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly stateService: StateService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errorToastService: ErrorToastService,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
tooSmall (drive: DiskInfo) {
|
||||
return drive.capacity < 34359738368
|
||||
}
|
||||
|
||||
async refresh () {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async getDrives () {
|
||||
this.loading = true
|
||||
try {
|
||||
const { disks, reconnect } = await this.apiService.getDrives()
|
||||
this.storageDrives = disks.filter(d => !d.partitions.map(p => p.logicalname).includes((this.stateService.recoverySource as DiskRecoverySource)?.logicalname))
|
||||
if (!this.storageDrives.length && reconnect.length) {
|
||||
const list = `<ul>${reconnect.map(recon => `<li>${recon}</li>`)}</ul>`
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message: `One or more devices you connected had to be reconfigured to support the current hardware platform. Please unplug and replug the following device(s), then refresh the page:<br> ${list}`,
|
||||
buttons: [
|
||||
{
|
||||
role: 'cancel',
|
||||
text: 'OK',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorToastService.present(e.message)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async chooseDrive (drive: DiskInfo) {
|
||||
if (!!drive.partitions.find(p => p.used) || !!drive.guid) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
subHeader: 'Drive contains data!',
|
||||
message: 'All data stored on this drive will be permanently deleted.',
|
||||
buttons: [
|
||||
{
|
||||
role: 'cancel',
|
||||
text: 'Cancel',
|
||||
},
|
||||
{
|
||||
text: 'Continue',
|
||||
handler: () => {
|
||||
if (this.stateService.recoveryPassword) {
|
||||
this.setupEmbassy(drive, this.stateService.recoveryPassword)
|
||||
} else {
|
||||
this.presentModalPassword(drive)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
} else {
|
||||
if (this.stateService.recoveryPassword) {
|
||||
this.setupEmbassy(drive, this.stateService.recoveryPassword)
|
||||
} else {
|
||||
this.presentModalPassword(drive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async presentModalPassword (drive: DiskInfo): Promise<void> {
|
||||
const modal = await this.modalController.create({
|
||||
component: PasswordPage,
|
||||
componentProps: {
|
||||
storageDrive: drive,
|
||||
},
|
||||
})
|
||||
modal.onDidDismiss().then(async ret => {
|
||||
if (!ret.data || !ret.data.password) return
|
||||
this.setupEmbassy(drive, ret.data.password)
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async setupEmbassy (drive: DiskInfo, password: string): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Transferring encrypted data. This could take a while...',
|
||||
})
|
||||
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.stateService.setupEmbassy(drive.logicalname, password)
|
||||
if (!!this.stateService.recoverySource) {
|
||||
await this.navCtrl.navigateForward(`/loading`)
|
||||
} else {
|
||||
await this.navCtrl.navigateForward(`/init`)
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorToastService.present(`${e.message}: ${e.details}. Restart Embassy to try again.`)
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { HomePage } from './home.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomePage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class HomePageRoutingModule { }
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { HomePage } from './home.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
|
||||
import { HomePageRoutingModule } from './home-routing.module'
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
HomePageRoutingModule,
|
||||
PasswordPageModule,
|
||||
],
|
||||
declarations: [HomePage],
|
||||
})
|
||||
export class HomePageModule { }
|
||||
@@ -0,0 +1,40 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col class="ion-text-center">
|
||||
|
||||
<div style="padding-bottom: 32px;">
|
||||
<img src="assets/img/logo.png" style="max-width: 240px;" />
|
||||
</div>
|
||||
|
||||
<ion-card color="dark">
|
||||
<ion-card-content class="ion-margin">
|
||||
<!-- fresh -->
|
||||
<ion-card
|
||||
routerLink="/embassy"
|
||||
color="light"
|
||||
style="text-align: center; background-color: #00919b !important; height: 160px; margin-bottom: 20px; box-shadow: 4px 4px 16px var(--ion-color-light);"
|
||||
>
|
||||
<ion-card-header>
|
||||
<ion-card-title style="font-size: 40px;">Start Fresh</ion-card-title>
|
||||
<ion-card-subtitle>Get started with a brand new Embassy</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
|
||||
<!-- recover -->
|
||||
</ion-card>
|
||||
<ion-card
|
||||
routerLink="/recover"
|
||||
color="light"
|
||||
style="text-align: center; background-color: #bf5900 !important; height: 160px; box-shadow: 4px 4px 16px var(--ion-color-light);"
|
||||
>
|
||||
<ion-card-header>
|
||||
<ion-card-title style="font-size: 40px;">Recover</ion-card-title>
|
||||
<ion-card-subtitle>Restore from backup or recover an old Embassy</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
</ion-card>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: 'home.page.html',
|
||||
styleUrls: ['home.page.scss'],
|
||||
})
|
||||
export class HomePage { }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { InitPage } from './init.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: InitPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class InitPageRoutingModule { }
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { InitPage } from './init.page'
|
||||
import { InitPageRoutingModule } from './init-routing.module'
|
||||
import { SuccessPageModule } from '../success/success.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
InitPageRoutingModule,
|
||||
SuccessPageModule,
|
||||
],
|
||||
declarations: [InitPage],
|
||||
exports: [InitPage],
|
||||
})
|
||||
export class InitPageModule { }
|
||||
@@ -0,0 +1,26 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col class="ion-text-center">
|
||||
|
||||
<div style="padding-bottom: 32px;">
|
||||
<img src="assets/img/logo.png" style="max-width: 240px;" />
|
||||
</div>
|
||||
|
||||
<success [hidden]="!stateService.embassyLoaded" (onDownload)="download()"></success>
|
||||
|
||||
<ion-card [hidden]="stateService.embassyLoaded" color="dark">
|
||||
<ion-card-header>
|
||||
<ion-card-title style="font-size: 40px;">Initializing Embassy</ion-card-title>
|
||||
<ion-card-subtitle>Progress: {{ progress }}%</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<ion-progress-bar color="primary" style="max-width: 700px; margin: auto; padding-bottom: 20px; margin-bottom: 40px;" [value]="progress / 100"></ion-progress-bar>
|
||||
<p class="ion-text-start">After completion, you will be prompted to download a file from your Embassy. Save the file somewhere safe, it is the easiest way to recover your Embassy's addresses and SSL certificate in case you lose them.</p>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { interval, Subscription } from 'rxjs'
|
||||
import { finalize, take, tap } from 'rxjs/operators'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-init',
|
||||
templateUrl: 'init.page.html',
|
||||
styleUrls: ['init.page.scss'],
|
||||
})
|
||||
export class InitPage {
|
||||
progress = 0
|
||||
sub: Subscription
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
public readonly stateService: StateService,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
// call setup.complete to tear down embassy.local and spin up embassy-[id].local
|
||||
this.apiService.setupComplete()
|
||||
|
||||
this.sub = interval(130)
|
||||
.pipe(
|
||||
take(101),
|
||||
tap(num => {
|
||||
this.progress = num
|
||||
}),
|
||||
finalize(() => {
|
||||
setTimeout(() => {
|
||||
this.stateService.embassyLoaded = true
|
||||
this.download()
|
||||
}, 500)
|
||||
}),
|
||||
).subscribe()
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
if (this.sub) this.sub.unsubscribe()
|
||||
}
|
||||
|
||||
download () {
|
||||
document.getElementById('tor-addr').innerHTML = this.stateService.torAddress
|
||||
document.getElementById('lan-addr').innerHTML = this.stateService.lanAddress
|
||||
document.getElementById('cert').setAttribute('href', 'data:application/x-x509-ca-cert;base64,' + encodeURIComponent(this.stateService.cert))
|
||||
let html = document.getElementById('downloadable').innerHTML
|
||||
const filename = 'embassy-info.html'
|
||||
|
||||
const elem = document.createElement('a')
|
||||
elem.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(html))
|
||||
elem.setAttribute('download', filename)
|
||||
elem.style.display = 'none'
|
||||
|
||||
document.body.appendChild(elem)
|
||||
elem.click()
|
||||
document.body.removeChild(elem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { LoadingPage } from './loading.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LoadingPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class LoadingPageRoutingModule { }
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { LoadingPage } from './loading.page'
|
||||
import { LoadingPageRoutingModule } from './loading-routing.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
LoadingPageRoutingModule,
|
||||
],
|
||||
declarations: [LoadingPage],
|
||||
})
|
||||
export class LoadingPageModule { }
|
||||
@@ -0,0 +1,24 @@
|
||||
<ion-content color="light">
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col class="ion-text-center">
|
||||
|
||||
<div style="padding-bottom: 32px;">
|
||||
<img src="assets/img/logo.png" style="max-width: 240px;" />
|
||||
</div>
|
||||
|
||||
<ion-card color="dark">
|
||||
<ion-card-header>
|
||||
<ion-card-title style="font-size: 40px;">Recovering</ion-card-title>
|
||||
<ion-card-subtitle>Progress: {{ (stateService.dataProgress * 100).toFixed(0) }}%</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<ion-progress-bar color="primary" style="max-width: 700px; margin: auto; padding-bottom: 20px; margin-bottom: 40px;" [value]="stateService.dataProgress"></ion-progress-bar>
|
||||
<p class="ion-text-start">After completion, you will be prompted to download a file from your Embassy. Save the file somewhere safe, it is the easiest way to recover your Embassy's addresses and SSL certificate in case you lose them.</p>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-loading',
|
||||
templateUrl: 'loading.page.html',
|
||||
styleUrls: ['loading.page.scss'],
|
||||
})
|
||||
export class LoadingPage {
|
||||
constructor (
|
||||
public stateService: StateService,
|
||||
private navCtrl: NavController,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.stateService.pollDataTransferProgress()
|
||||
const progSub = this.stateService.dataCompletionSubject.subscribe(async complete => {
|
||||
if (complete) {
|
||||
progSub.unsubscribe()
|
||||
await this.navCtrl.navigateForward(`/init`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { ProductKeyPage } from './product-key.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ProductKeyPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class ProductKeyPageRoutingModule { }
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ProductKeyPage } from './product-key.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
import { ProductKeyPageRoutingModule } from './product-key-routing.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
ProductKeyPageRoutingModule,
|
||||
PasswordPageModule,
|
||||
],
|
||||
declarations: [ProductKeyPage],
|
||||
})
|
||||
export class ProductKeyPageModule { }
|
||||
@@ -0,0 +1,43 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col class="ion-text-center">
|
||||
|
||||
<div style="padding-bottom: 32px;">
|
||||
<img src="assets/img/logo.png" style="max-width: 240px;" />
|
||||
</div>
|
||||
|
||||
<ion-card color="dark">
|
||||
<ion-card-header style="padding-bottom: 8px;">
|
||||
<ion-card-title>Product Key</ion-card-title>
|
||||
<ion-card-subtitle>Enter your product key to establish an encrypted connection with your Embassy</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<form (submit)="submit()" style="margin-bottom: 12px;">
|
||||
<ion-item-group class="ion-padding-bottom">
|
||||
<ion-item color="dark">
|
||||
<ion-icon slot="start" name="key-outline" style="margin-right: 16px;"></ion-icon>
|
||||
<ion-input
|
||||
#focusInput
|
||||
name="productKey"
|
||||
[(ngModel)]="productKey"
|
||||
(ionChange)="error = ''"
|
||||
maxlength="12"
|
||||
>
|
||||
</ion-input>
|
||||
</ion-item>
|
||||
<div class="ion-text-left">
|
||||
<p *ngIf="error" style="padding-top: 4px"><ion-text color="danger">{{ error }}</ion-text></p>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
<ion-button type="submit" color="light" class="claim-button">
|
||||
Submit
|
||||
</ion-button>
|
||||
</form>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,5 @@
|
||||
ion-item {
|
||||
--border-style: solid;
|
||||
--border-width: 1px;
|
||||
--border-color: var(--ion-color-medium);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Component, ViewChild } from '@angular/core'
|
||||
import { IonInput, LoadingController, NavController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { HttpService } from 'src/app/services/api/http.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-key',
|
||||
templateUrl: 'product-key.page.html',
|
||||
styleUrls: ['product-key.page.scss'],
|
||||
})
|
||||
export class ProductKeyPage {
|
||||
@ViewChild('focusInput') elem: IonInput
|
||||
productKey: string
|
||||
error: string
|
||||
|
||||
constructor (
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly stateService: StateService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly httpService: HttpService,
|
||||
) { }
|
||||
|
||||
ionViewDidEnter () {
|
||||
setTimeout(() => this.elem.setFocus(), 400)
|
||||
}
|
||||
|
||||
async submit () {
|
||||
if (!this.productKey) return this.error = 'Must enter product key'
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Verifying Product Key',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
this.httpService.productKey = this.productKey
|
||||
await this.apiService.verifyProductKey()
|
||||
if (this.stateService.isMigrating) {
|
||||
await this.navCtrl.navigateForward(`/loading`)
|
||||
} else {
|
||||
await this.navCtrl.navigateForward(`/home`)
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = 'Invalid Product Key'
|
||||
this.httpService.productKey = undefined
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="inline">
|
||||
<!-- has backup -->
|
||||
<h2 *ngIf="hasValidBackup; else noBackup">
|
||||
<ion-icon name="cloud-done" color="success"></ion-icon>
|
||||
{{ is02x ? 'Embassy 0.2.x detected' : 'Embassy backup detected' }}
|
||||
</h2>
|
||||
<!-- no backup -->
|
||||
<ng-template #noBackup>
|
||||
<h2>
|
||||
<ion-icon name="cloud-offline" color="danger"></ion-icon>
|
||||
No Embassy backup
|
||||
</h2>
|
||||
</ng-template>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { RecoverPage } from './recover.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: RecoverPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class RecoverPageRoutingModule { }
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { DriveStatusComponent, RecoverPage } from './recover.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
import { ProdKeyModalModule } from '../../modals/prod-key-modal/prod-key-modal.module'
|
||||
import { RecoverPageRoutingModule } from './recover-routing.module'
|
||||
import { PipesModule } from 'src/app/pipes/pipe.module'
|
||||
import { CifsModalModule } from 'src/app/modals/cifs-modal/cifs-modal.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [RecoverPage, DriveStatusComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
RecoverPageRoutingModule,
|
||||
PasswordPageModule,
|
||||
ProdKeyModalModule,
|
||||
PipesModule,
|
||||
CifsModalModule,
|
||||
],
|
||||
})
|
||||
export class RecoverPageModule { }
|
||||
@@ -0,0 +1,65 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col>
|
||||
|
||||
<div style="padding-bottom: 32px;" class="ion-text-center">
|
||||
<img src="assets/img/logo.png" style="max-width: 240px;" />
|
||||
</div>
|
||||
|
||||
<ion-card color="dark">
|
||||
<ion-card-header class="ion-text-center">
|
||||
<ion-card-title>Recover</ion-card-title>
|
||||
<ion-card-subtitle>Select the shared folder or drive containing your Embassy backup</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<ion-spinner *ngIf="loading" class="center-spinner" name="lines"></ion-spinner>
|
||||
|
||||
<!-- loaded -->
|
||||
<ion-item-group *ngIf="!loading">
|
||||
<!-- cifs -->
|
||||
<h2 class="target-label">
|
||||
Shared Network Folder
|
||||
</h2>
|
||||
<p class="ion-padding-bottom">
|
||||
Using a shared folder is the recommended way to recover from backup, since it works with all Embassy hardware configurations.
|
||||
To recover from a shared folder, please follow the <a href="https://docs.start9.com/user-manual/general/backups.html#shared-network-folder" target="_blank" noreferrer>instructions</a>.
|
||||
</p>
|
||||
|
||||
<!-- connect -->
|
||||
<ion-item button lines="none" (click)="presentModalCifs()">
|
||||
<ion-icon slot="start" name="folder-open-outline" size="large" color="light"></ion-icon>
|
||||
<ion-label>Open Shared Folder</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<!-- drives -->
|
||||
<h2 class="target-label">
|
||||
Physical Drives
|
||||
</h2>
|
||||
<p class="ion-padding-bottom">
|
||||
Warning! Plugging in more than one physical drive to Embassy can lead to power failure and data corruption.
|
||||
To recover from a physical drive, please follow the <a href="https://docs.start9.com/user-manual/general/backups.html#physical-drive" target="_blank" noreferrer>instructions</a>.
|
||||
</p>
|
||||
|
||||
<ng-container *ngFor="let mapped of mappedDrives">
|
||||
<ion-item button *ngIf="mapped.drive as drive" [disabled]="!driveClickable(mapped)" (click)="select(drive)">
|
||||
<ion-icon slot="start" name="save-outline" size="large" color="light"></ion-icon>
|
||||
<ion-label>
|
||||
<h1>{{ drive.label || drive.logicalname }}</h1>
|
||||
<drive-status [hasValidBackup]="mapped.hasValidBackup" [is02x]="mapped.is02x"></drive-status>
|
||||
<p>{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model || 'Unknown Model' }}</p>
|
||||
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ion-item-group>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,4 @@
|
||||
.target-label {
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { AlertController, IonicSafeString, LoadingController, ModalController, NavController } from '@ionic/angular'
|
||||
import { CifsModal } from 'src/app/modals/cifs-modal/cifs-modal.page'
|
||||
import { ApiService, DiskBackupTarget } from 'src/app/services/api/api.service'
|
||||
import { ErrorToastService } from 'src/app/services/error-toast.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PasswordPage } from '../../modals/password/password.page'
|
||||
import { ProdKeyModal } from '../../modals/prod-key-modal/prod-key-modal.page'
|
||||
|
||||
@Component({
|
||||
selector: 'app-recover',
|
||||
templateUrl: 'recover.page.html',
|
||||
styleUrls: ['recover.page.scss'],
|
||||
})
|
||||
export class RecoverPage {
|
||||
loading = true
|
||||
mappedDrives: MappedDisk[] = []
|
||||
hasShownGuidAlert = false
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly modalController: ModalController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errorToastService: ErrorToastService,
|
||||
public readonly stateService: StateService,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async refresh () {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
driveClickable (mapped: MappedDisk) {
|
||||
return mapped.drive['embassy-os']?.full && (this.stateService.hasProductKey || mapped.is02x)
|
||||
}
|
||||
|
||||
async getDrives () {
|
||||
this.mappedDrives = []
|
||||
try {
|
||||
const { disks, reconnect } = await this.apiService.getDrives()
|
||||
disks.filter(d => d.partitions.length).forEach(d => {
|
||||
d.partitions.forEach(p => {
|
||||
const drive: DiskBackupTarget = {
|
||||
vendor: d.vendor,
|
||||
model: d.model,
|
||||
logicalname: p.logicalname,
|
||||
label: p.label,
|
||||
capacity: p.capacity,
|
||||
used: p.used,
|
||||
'embassy-os': p['embassy-os'],
|
||||
}
|
||||
this.mappedDrives.push(
|
||||
{
|
||||
hasValidBackup: p['embassy-os']?.full,
|
||||
is02x: drive['embassy-os']?.version.startsWith('0.2'),
|
||||
drive,
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
if (!this.mappedDrives.length && reconnect.length) {
|
||||
const list = `<ul>${reconnect.map(recon => `<li>${recon}</li>`)}</ul>`
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message: `One or more devices you connected had to be reconfigured to support the current hardware platform. Please unplug and replug the following device(s), then refresh the page:<br> ${list}`,
|
||||
buttons: [
|
||||
{
|
||||
role: 'cancel',
|
||||
text: 'OK',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
const importableDrive = disks.find(d => !!d.guid)
|
||||
if (!!importableDrive && !this.hasShownGuidAlert) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Embassy Data Drive Detected',
|
||||
message: new IonicSafeString(`${importableDrive.vendor || 'Unknown Vendor'} - ${importableDrive.model || 'Unknown Model' } contains Embassy data. To use this drive and its data <i>as-is</i>, click "Use Drive". This will complete the setup process.<br /><br /><b>Important</b>. If you are trying to restore from backup or update from 0.2.x, DO NOT click "Use Drive". Instead, click "Cancel" and follow instructions.`),
|
||||
buttons: [
|
||||
{
|
||||
role: 'cancel',
|
||||
text: 'Cancel',
|
||||
},
|
||||
{
|
||||
text: 'Use Drive',
|
||||
handler: async () => {
|
||||
await this.importDrive(importableDrive.guid)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
this.hasShownGuidAlert = true
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorToastService.present(`${e.message}: ${e.details}`)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async presentModalCifs (): Promise<void> {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: CifsModal,
|
||||
})
|
||||
modal.onDidDismiss().then(res => {
|
||||
if (res.role === 'success') {
|
||||
const { hostname, path, username, password } = res.data.cifs
|
||||
this.stateService.recoverySource = {
|
||||
type: 'cifs',
|
||||
hostname,
|
||||
path,
|
||||
username,
|
||||
password,
|
||||
}
|
||||
this.stateService.recoveryPassword = res.data.recoveryPassword
|
||||
this.navCtrl.navigateForward('/embassy')
|
||||
}
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async select (target: DiskBackupTarget) {
|
||||
const is02x = target['embassy-os'].version.startsWith('0.2')
|
||||
|
||||
if (this.stateService.hasProductKey) {
|
||||
if (is02x) {
|
||||
this.selectRecoverySource(target.logicalname)
|
||||
} else {
|
||||
const modal = await this.modalController.create({
|
||||
component: PasswordPage,
|
||||
componentProps: { target },
|
||||
cssClass: 'alertlike-modal',
|
||||
})
|
||||
modal.onDidDismiss().then(res => {
|
||||
if (res.data && res.data.password) {
|
||||
this.selectRecoverySource(target.logicalname, res.data.password)
|
||||
}
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
// if no product key, it means they are an upgrade kit user
|
||||
} else {
|
||||
if (!is02x) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Error',
|
||||
message: 'In order to use this image, you must select a drive containing a valid 0.2.x Embassy.',
|
||||
buttons: [
|
||||
{
|
||||
role: 'cancel',
|
||||
text: 'OK',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
} else {
|
||||
const modal = await this.modalController.create({
|
||||
component: ProdKeyModal,
|
||||
componentProps: { target },
|
||||
cssClass: 'alertlike-modal',
|
||||
})
|
||||
modal.onDidDismiss().then(res => {
|
||||
if (res.data && res.data.productKey) {
|
||||
this.selectRecoverySource(target.logicalname)
|
||||
}
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async importDrive (guid: string) {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Importing Drive',
|
||||
})
|
||||
await loader.present()
|
||||
try {
|
||||
await this.stateService.importDrive(guid)
|
||||
await this.navCtrl.navigateForward(`/init`)
|
||||
} catch (e) {
|
||||
this.errorToastService.present(`${e.message}: ${e.details}`)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async selectRecoverySource (logicalname: string, password?: string) {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'disk',
|
||||
logicalname,
|
||||
}
|
||||
this.stateService.recoveryPassword = password
|
||||
this.navCtrl.navigateForward(`/embassy`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'drive-status',
|
||||
templateUrl: './drive-status.component.html',
|
||||
styleUrls: ['./recover.page.scss'],
|
||||
})
|
||||
export class DriveStatusComponent {
|
||||
@Input() hasValidBackup: boolean
|
||||
@Input() is02x: boolean
|
||||
}
|
||||
|
||||
|
||||
interface MappedDisk {
|
||||
is02x: boolean
|
||||
hasValidBackup: boolean
|
||||
drive: DiskBackupTarget
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { SuccessPage } from './success.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
PasswordPageModule,
|
||||
],
|
||||
declarations: [SuccessPage],
|
||||
exports: [SuccessPage],
|
||||
})
|
||||
export class SuccessPageModule { }
|
||||
@@ -0,0 +1,167 @@
|
||||
|
||||
<ion-card color="dark">
|
||||
<ion-card-header class="ion-text-center" color="success">
|
||||
<ion-icon style="font-size: 80px;" name="checkmark-circle-outline"></ion-icon>
|
||||
<ion-card-title>Setup Complete!</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<br />
|
||||
<ng-template [ngIf]="stateService.recoverySource && stateService.recoverySource.type === 'disk'">
|
||||
<h2>You can now safely unplug your backup drive.</h2>
|
||||
</ng-template>
|
||||
<!-- Tor Instructions -->
|
||||
<div (click)="toggleTor()" class="toggle-label">
|
||||
<h2>Tor Instructions:</h2>
|
||||
<ion-icon
|
||||
name="chevron-down-outline"
|
||||
[ngStyle]="{
|
||||
'transform': torOpen ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
'transition': 'transform 0.4s ease-out'
|
||||
}"
|
||||
></ion-icon>
|
||||
</div>
|
||||
|
||||
<div
|
||||
[ngStyle]="{
|
||||
'overflow' : 'hidden',
|
||||
'max-height': torOpen ? '500px' : '0px',
|
||||
'transition': 'max-height 0.4s ease-out'
|
||||
}"
|
||||
>
|
||||
<div class="ion-padding ion-text-start">
|
||||
<p>
|
||||
To use your Embassy over Tor, visit its unique Tor address from any Tor-enabled browser.
|
||||
For a list of recommended browsers, click <a href="https://docs.start9.com/user-manual/connecting.html" target="_blank" rel="noreferrer"><b>here</b></a>.
|
||||
</p>
|
||||
<br />
|
||||
<p>Tor Address</p>
|
||||
<ion-item lines="none" color="dark">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<code><ion-text color="light">{{ stateService.torAddress }}</ion-text></code>
|
||||
</ion-label>
|
||||
<ion-button color="light" fill="clear" (click)="copy(stateService.torAddress)">
|
||||
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</div>
|
||||
<div style="padding-bottom: 24px; border-bottom: solid 1px;"></div>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<!-- LAN Instructions -->
|
||||
<div (click)="toggleLan()" class="toggle-label">
|
||||
<h2>LAN Instructions (Slightly Advanced):</h2>
|
||||
<ion-icon
|
||||
name="chevron-down-outline"
|
||||
[ngStyle]="{
|
||||
'transform': lanOpen ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
'transition': 'transform 0.4s ease-out'
|
||||
}"
|
||||
></ion-icon>
|
||||
</div>
|
||||
|
||||
<div
|
||||
[ngStyle]="{
|
||||
'overflow' : 'hidden',
|
||||
'max-height': lanOpen ? '500px' : '0px',
|
||||
'transition': 'max-height 0.4s ease-out'
|
||||
}"
|
||||
>
|
||||
<div class="ion-padding ion-text-start">
|
||||
<p>To use your Embassy locally, you must:</p>
|
||||
<ol>
|
||||
<li>Currently be connected to the same Local Area Network (LAN) as your Embassy.</li>
|
||||
<li>Download your Embassy's Root Certificate Authority.</li>
|
||||
<li>Trust your Embassy's Root CA on <i>both</i> your computer/phone and in your browser settings.</li>
|
||||
</ol>
|
||||
<p>
|
||||
For step-by-step instructions, click
|
||||
<a href="https://docs.start9.com/user-manual/general/lan-setup.html" target="_blank" rel="noreferrer"><b>here</b></a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>Please note, once setup is complete, the embassy.local address will no longer connect to your Embassy.</b>
|
||||
</p>
|
||||
|
||||
<ion-button style="margin-top: 24px; margin-bottom: 24px;" color="light" (click)="installCert()">
|
||||
Download Root CA
|
||||
<ion-icon slot="end" name="download-outline"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<p>LAN Address</p>
|
||||
<ion-item lines="none" color="dark">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<code><ion-text color="light">{{ stateService.lanAddress }}</ion-text></code>
|
||||
</ion-label>
|
||||
<ion-button color="light" fill="clear" (click)="copy(stateService.lanAddress)">
|
||||
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</div>
|
||||
<div style="padding-bottom: 24px; border-bottom: solid 1px;"></div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="ion-text-center ion-padding-top">
|
||||
<ion-button color="light" fill="clear" color="primary" strong (click)="download()">
|
||||
Download this page
|
||||
<ion-icon slot="end" name="download-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
<br />
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
|
||||
<!-- cert elem -->
|
||||
<a hidden id="install-cert" download="embassy.crt"></a>
|
||||
|
||||
<!-- download elem -->
|
||||
<div hidden id="downloadable">
|
||||
<div style="padding: 0 24px; font-family: Courier;">
|
||||
<h1>Embassy Info</h1>
|
||||
|
||||
<section style="padding: 16px; border: solid 1px;">
|
||||
<h2>Tor Info</h2>
|
||||
<p>
|
||||
To use your Embassy over Tor, visit its unique Tor address from any Tor-enabled browser.
|
||||
</p>
|
||||
<p>
|
||||
For a list of recommended browsers, click <a href="https://docs.start9.com/user-manual/connecting.html" target="_blank" rel="noreferrer"><b>here</b></a>.
|
||||
</p>
|
||||
<p><b>Tor Address: </b><code id="tor-addr"></code></p>
|
||||
</section>
|
||||
|
||||
<section style="padding: 16px; border: solid 1px; border-top: none;">
|
||||
<h2>LAN Info</h2>
|
||||
<p>To use your Embassy locally, you must:</p>
|
||||
<ol>
|
||||
<li>Currently be connected to the same Local Area Network (LAN) as your Embassy.</li>
|
||||
<li>Download your Embassy's Root Certificate Authority.</li>
|
||||
<li>Trust your Embassy's Root CA on <i>both</i> your computer/phone and in your browser settings.</li>
|
||||
</ol>
|
||||
<p>
|
||||
For step-by-step instructions, click
|
||||
<a href="https://docs.start9.com/user-manual/general/lan-setup.html" target="_blank" rel="noreferrer"><b>here</b></a>.
|
||||
</p>
|
||||
|
||||
<div style="margin: 42px 0;">
|
||||
<a
|
||||
id="cert"
|
||||
download="embassy.crt"
|
||||
style="
|
||||
background: #25272b;
|
||||
padding: 10px;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
"
|
||||
>
|
||||
Download Root CA
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p><b>LAN Address: </b><code id="lan-addr"></code></p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
p {
|
||||
color: var(--ion-color-light);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
padding: 24px 0 8px 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
* {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
text-align: right;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Component, EventEmitter, Output } from '@angular/core'
|
||||
import { ToastController } from '@ionic/angular'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'success',
|
||||
templateUrl: 'success.page.html',
|
||||
styleUrls: ['success.page.scss'],
|
||||
})
|
||||
export class SuccessPage {
|
||||
@Output() onDownload = new EventEmitter()
|
||||
torOpen = true
|
||||
lanOpen = false
|
||||
|
||||
constructor (
|
||||
private readonly toastCtrl: ToastController,
|
||||
public readonly stateService: StateService,
|
||||
) { }
|
||||
|
||||
ngAfterViewInit () {
|
||||
document.getElementById('install-cert').setAttribute('href', 'data:application/x-x509-ca-cert;base64,' + encodeURIComponent(this.stateService.cert))
|
||||
}
|
||||
|
||||
async copy (address: string): Promise<void> {
|
||||
const success = await this.copyToClipboard(address)
|
||||
const message = success ? 'copied to clipboard!' : 'failed to copy'
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: message,
|
||||
position: 'bottom',
|
||||
duration: 1000,
|
||||
})
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
toggleTor () {
|
||||
this.torOpen = !this.torOpen
|
||||
}
|
||||
|
||||
toggleLan () {
|
||||
this.lanOpen = !this.lanOpen
|
||||
}
|
||||
|
||||
installCert () {
|
||||
document.getElementById('install-cert').click()
|
||||
}
|
||||
|
||||
download () {
|
||||
this.onDownload.emit()
|
||||
}
|
||||
|
||||
private async copyToClipboard (str: string): Promise<boolean> {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = str
|
||||
el.setAttribute('readonly', '')
|
||||
el.style.position = 'absolute'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
const copy = document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
|
||||
// converts bytes to gigabytes
|
||||
@Pipe({
|
||||
name: 'convertBytes',
|
||||
})
|
||||
export class ConvertBytesPipe implements PipeTransform {
|
||||
transform (bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
}
|
||||
10
frontend/projects/setup-wizard/src/app/pipes/pipe.module.ts
Normal file
10
frontend/projects/setup-wizard/src/app/pipes/pipe.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { ConvertBytesPipe } from './convert-bytes.pipe'
|
||||
|
||||
@NgModule({
|
||||
declarations: [ConvertBytesPipe],
|
||||
imports: [],
|
||||
exports: [ConvertBytesPipe],
|
||||
})
|
||||
|
||||
export class PipesModule { }
|
||||
@@ -0,0 +1,98 @@
|
||||
export abstract class ApiService {
|
||||
// unencrypted
|
||||
abstract getStatus (): Promise<GetStatusRes> // setup.status
|
||||
abstract getDrives (): Promise<DiskListResponse> // setup.disk.list
|
||||
abstract set02XDrive (logicalname: string): Promise<void> // setup.recovery.v2.set
|
||||
abstract getRecoveryStatus (): Promise<RecoveryStatusRes> // setup.recovery.status
|
||||
|
||||
// encrypted
|
||||
abstract verifyCifs (cifs: CifsRecoverySource): Promise<EmbassyOSRecoveryInfo> // setup.cifs.verify
|
||||
abstract verifyProductKey (): Promise<void> // echo - throws error if invalid
|
||||
abstract importDrive (guid: string): Promise<SetupEmbassyRes> // setup.execute
|
||||
abstract setupEmbassy (setupInfo: SetupEmbassyReq): Promise<SetupEmbassyRes> // setup.execute
|
||||
abstract setupComplete (): Promise<void> // setup.complete
|
||||
}
|
||||
|
||||
export interface GetStatusRes {
|
||||
'product-key': boolean
|
||||
migrating: boolean
|
||||
}
|
||||
|
||||
export interface SetupEmbassyReq {
|
||||
'embassy-logicalname': string
|
||||
'embassy-password': string
|
||||
'recovery-source': CifsRecoverySource | DiskRecoverySource | null
|
||||
'recovery-password': string | null
|
||||
}
|
||||
|
||||
export interface SetupEmbassyRes {
|
||||
'tor-address': string
|
||||
'lan-address': string
|
||||
'root-ca': string
|
||||
}
|
||||
|
||||
export interface EmbassyOSRecoveryInfo {
|
||||
version: string
|
||||
full: boolean
|
||||
'password-hash': string | null
|
||||
'wrapped-key': string | null
|
||||
}
|
||||
|
||||
export interface DiskListResponse {
|
||||
disks: DiskInfo[]
|
||||
reconnect: string[]
|
||||
}
|
||||
|
||||
export interface DiskBackupTarget {
|
||||
vendor: string | null
|
||||
model: string | null
|
||||
logicalname: string | null
|
||||
label: string | null
|
||||
capacity: number
|
||||
used: number | null
|
||||
'embassy-os': EmbassyOSRecoveryInfo | null
|
||||
}
|
||||
|
||||
export interface CifsBackupTarget {
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
mountable: boolean
|
||||
'embassy-os': EmbassyOSRecoveryInfo | null
|
||||
}
|
||||
|
||||
export interface DiskRecoverySource {
|
||||
type: 'disk'
|
||||
logicalname: string // partition logicalname
|
||||
}
|
||||
|
||||
export interface CifsRecoverySource {
|
||||
type: 'cifs'
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
password: string | null
|
||||
}
|
||||
|
||||
export interface DiskInfo {
|
||||
logicalname: string,
|
||||
vendor: string | null,
|
||||
model: string | null,
|
||||
partitions: PartitionInfo[],
|
||||
capacity: number,
|
||||
guid: string | null, // cant back up if guid exists
|
||||
}
|
||||
|
||||
export interface RecoveryStatusRes {
|
||||
'bytes-transferred': number
|
||||
'total-bytes': number
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
export interface PartitionInfo {
|
||||
logicalname: string,
|
||||
label: string | null,
|
||||
capacity: number,
|
||||
used: number | null,
|
||||
'embassy-os': EmbassyOSRecoveryInfo | null,
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'
|
||||
import { Observable } from 'rxjs'
|
||||
import * as aesjs from 'aes-js'
|
||||
import * as pbkdf2 from 'pbkdf2'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class HttpService {
|
||||
fullUrl: string
|
||||
productKey: string
|
||||
|
||||
constructor (
|
||||
private readonly http: HttpClient,
|
||||
) {
|
||||
const port = window.location.port
|
||||
this.fullUrl = `${window.location.protocol}//${window.location.hostname}:${port}/rpc/v1`
|
||||
}
|
||||
|
||||
async rpcRequest<T> (body: RPCOptions, encrypted = true): Promise<T> {
|
||||
|
||||
const httpOpts = {
|
||||
method: Method.POST,
|
||||
body,
|
||||
url: this.fullUrl,
|
||||
}
|
||||
|
||||
let res: RPCResponse<T>
|
||||
|
||||
if (encrypted) {
|
||||
res = await this.encryptedHttpRequest<RPCResponse<T>>(httpOpts)
|
||||
} else {
|
||||
res = await this.httpRequest<RPCResponse<T>>(httpOpts)
|
||||
}
|
||||
|
||||
if (isRpcError(res)) {
|
||||
console.error('RPC ERROR: ', res)
|
||||
throw new RpcError(res.error)
|
||||
}
|
||||
|
||||
if (isRpcSuccess(res)) return res.result
|
||||
}
|
||||
|
||||
async encryptedHttpRequest<T> (httpOpts: {
|
||||
body: RPCOptions;
|
||||
url: string;
|
||||
}): Promise<T> {
|
||||
|
||||
const urlIsRelative = httpOpts.url.startsWith('/')
|
||||
const url = urlIsRelative ?
|
||||
this.fullUrl + httpOpts.url :
|
||||
httpOpts.url
|
||||
|
||||
const encryptedBody = await AES_CTR.encryptPbkdf2(this.productKey, encodeUtf8(JSON.stringify(httpOpts.body)))
|
||||
const options = {
|
||||
responseType: 'arraybuffer',
|
||||
body: encryptedBody.buffer,
|
||||
observe: 'events',
|
||||
reportProgress: false,
|
||||
|
||||
headers: {
|
||||
'Content-Encoding': 'aesctr256',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
} as any
|
||||
|
||||
const req = this.http.post(url, options.body, options)
|
||||
|
||||
return (req)
|
||||
.toPromise()
|
||||
.then(res => AES_CTR.decryptPbkdf2(this.productKey, (res as any).body as ArrayBuffer))
|
||||
.then(res => JSON.parse(res))
|
||||
.catch(e => {
|
||||
if (!e.status && !e.statusText) {
|
||||
throw new EncryptionError(e)
|
||||
} else {
|
||||
throw new HttpError(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async httpRequest<T> (httpOpts: {
|
||||
body: RPCOptions;
|
||||
url: string;
|
||||
}): Promise<T> {
|
||||
const urlIsRelative = httpOpts.url.startsWith('/')
|
||||
const url = urlIsRelative ?
|
||||
this.fullUrl + httpOpts.url :
|
||||
httpOpts.url
|
||||
|
||||
const options = {
|
||||
responseType: 'json',
|
||||
body: httpOpts.body,
|
||||
observe: 'events',
|
||||
reportProgress: false,
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
} as any
|
||||
|
||||
const req: Observable<{ body: T }> = this.http.post(url, httpOpts.body, options) as any
|
||||
|
||||
return (req)
|
||||
.toPromise()
|
||||
.then(res => res.body)
|
||||
.catch(e => { throw new HttpError(e) })
|
||||
}
|
||||
}
|
||||
|
||||
function RpcError (e: RPCError['error']): void {
|
||||
const { code, message, data } = e
|
||||
|
||||
this.code = code
|
||||
this.message = message
|
||||
|
||||
if (typeof data !== 'string') {
|
||||
this.details = data.details
|
||||
} else {
|
||||
this.details = data
|
||||
}
|
||||
}
|
||||
|
||||
function HttpError (e: HttpErrorResponse): void {
|
||||
const { status, statusText } = e
|
||||
|
||||
this.code = status
|
||||
this.message = statusText
|
||||
this.details = null
|
||||
}
|
||||
|
||||
function EncryptionError (e: HttpErrorResponse): void {
|
||||
this.code = null
|
||||
this.message = 'Invalid Key'
|
||||
this.details = null
|
||||
}
|
||||
|
||||
function isRpcError<Error, Result> (arg: { error: Error } | { result: Result }): arg is { error: Error } {
|
||||
return !!(arg as any).error
|
||||
}
|
||||
|
||||
function isRpcSuccess<Error, Result> (arg: { error: Error } | { result: Result }): arg is { result: Result } {
|
||||
return !!(arg as any).result
|
||||
}
|
||||
|
||||
export enum Method {
|
||||
GET = 'GET',
|
||||
POST = 'POST',
|
||||
PUT = 'PUT',
|
||||
PATCH = 'PATCH',
|
||||
DELETE = 'DELETE',
|
||||
}
|
||||
|
||||
export interface RPCOptions {
|
||||
method: string
|
||||
params?: {
|
||||
[param: string]: string | number | boolean | object | string[] | number[];
|
||||
}
|
||||
}
|
||||
|
||||
interface RPCBase {
|
||||
jsonrpc: '2.0'
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface RPCRequest<T> extends RPCBase {
|
||||
method: string
|
||||
params?: T
|
||||
}
|
||||
|
||||
export interface RPCSuccess<T> extends RPCBase {
|
||||
result: T
|
||||
}
|
||||
|
||||
export interface RPCError extends RPCBase {
|
||||
error: {
|
||||
code: number,
|
||||
message: string
|
||||
data?: {
|
||||
details: string
|
||||
} | string
|
||||
}
|
||||
}
|
||||
|
||||
export type RPCResponse<T> = RPCSuccess<T> | RPCError
|
||||
|
||||
type HttpError = HttpErrorResponse & { error: { code: string, message: string } }
|
||||
|
||||
export interface HttpOptions {
|
||||
method: Method
|
||||
url: string
|
||||
headers?: HttpHeaders | {
|
||||
[header: string]: string | string[]
|
||||
}
|
||||
params?: HttpParams | {
|
||||
[param: string]: string | string[]
|
||||
}
|
||||
responseType?: 'json' | 'text' | 'arrayBuffer'
|
||||
withCredentials?: boolean
|
||||
body?: any
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
type AES_CTR = {
|
||||
encryptPbkdf2: (secretKey: string, messageBuffer: Uint8Array) => Promise<Uint8Array>
|
||||
decryptPbkdf2: (secretKey, arr: ArrayBuffer) => Promise<string>
|
||||
}
|
||||
|
||||
export const AES_CTR: AES_CTR = {
|
||||
encryptPbkdf2: async (secretKey: string, messageBuffer: Uint8Array) => {
|
||||
const salt = window.crypto.getRandomValues(new Uint8Array(16))
|
||||
const counter = window.crypto.getRandomValues(new Uint8Array(16))
|
||||
|
||||
const key = pbkdf2.pbkdf2Sync(secretKey, salt, 1000, 256 / 8, 'sha256')
|
||||
|
||||
const aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(counter))
|
||||
const encryptedBytes = aesCtr.encrypt(messageBuffer)
|
||||
return new Uint8Array([...counter, ...salt, ...encryptedBytes])
|
||||
},
|
||||
decryptPbkdf2: async (secretKey: string, arr: ArrayBuffer) => {
|
||||
const buff = new Uint8Array(arr)
|
||||
const counter = buff.slice(0, 16)
|
||||
const salt = buff.slice(16, 32)
|
||||
|
||||
const cipher = buff.slice(32)
|
||||
const key = pbkdf2.pbkdf2Sync(secretKey, salt, 1000, 256 / 8, 'sha256')
|
||||
|
||||
const aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(counter))
|
||||
const decryptedBytes = aesCtr.decrypt(cipher)
|
||||
|
||||
return aesjs.utils.utf8.fromBytes(decryptedBytes)
|
||||
},
|
||||
}
|
||||
|
||||
export const encode16 = (buffer: Uint8Array) => buffer.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '')
|
||||
export const decode16 = hexString => new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16)))
|
||||
|
||||
export function encodeUtf8 (str: string): Uint8Array {
|
||||
const encoder = new TextEncoder()
|
||||
return encoder.encode(str)
|
||||
}
|
||||
|
||||
export function decodeUtf8 (arr: Uint8Array): string {
|
||||
return new TextDecoder().decode(arr)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ApiService, CifsRecoverySource, DiskInfo, DiskListResponse, DiskRecoverySource, EmbassyOSRecoveryInfo, GetStatusRes, RecoveryStatusRes, SetupEmbassyReq, SetupEmbassyRes } from './api.service'
|
||||
import { HttpService } from './http.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LiveApiService extends ApiService {
|
||||
|
||||
constructor (
|
||||
private readonly http: HttpService,
|
||||
) { super() }
|
||||
|
||||
// ** UNENCRYPTED **
|
||||
|
||||
async getStatus () {
|
||||
return this.http.rpcRequest<GetStatusRes>({
|
||||
method: 'setup.status',
|
||||
params: { },
|
||||
}, false)
|
||||
}
|
||||
|
||||
async getDrives () {
|
||||
return this.http.rpcRequest<DiskListResponse>({
|
||||
method: 'setup.disk.list',
|
||||
params: { },
|
||||
}, false)
|
||||
}
|
||||
|
||||
async set02XDrive (logicalname) {
|
||||
return this.http.rpcRequest<void>({
|
||||
method: 'setup.recovery.v2.set',
|
||||
params: { logicalname },
|
||||
}, false)
|
||||
}
|
||||
|
||||
async getRecoveryStatus () {
|
||||
return this.http.rpcRequest<RecoveryStatusRes>({
|
||||
method: 'setup.recovery.status',
|
||||
params: { },
|
||||
}, false)
|
||||
}
|
||||
|
||||
// ** ENCRYPTED **
|
||||
|
||||
async verifyCifs (source: CifsRecoverySource) {
|
||||
source.path = source.path.replace('/\\/g', '/')
|
||||
return this.http.rpcRequest<EmbassyOSRecoveryInfo>({
|
||||
method: 'setup.cifs.verify',
|
||||
params: source as any,
|
||||
})
|
||||
}
|
||||
|
||||
async verifyProductKey () {
|
||||
return this.http.rpcRequest<void>({
|
||||
method: 'echo',
|
||||
params: { 'message': 'hello' },
|
||||
})
|
||||
}
|
||||
|
||||
async importDrive (guid: string) {
|
||||
const res = await this.http.rpcRequest<SetupEmbassyRes>({
|
||||
method: 'setup.attach',
|
||||
params: { guid },
|
||||
})
|
||||
|
||||
return {
|
||||
...res,
|
||||
'root-ca': btoa(res['root-ca']),
|
||||
}
|
||||
}
|
||||
|
||||
async setupEmbassy (setupInfo: SetupEmbassyReq) {
|
||||
if (isCifsSource(setupInfo['recovery-source'])) {
|
||||
setupInfo['recovery-source'].path = setupInfo['recovery-source'].path.replace('/\\/g', '/')
|
||||
}
|
||||
|
||||
const res = await this.http.rpcRequest<SetupEmbassyRes>({
|
||||
method: 'setup.execute',
|
||||
params: setupInfo as any,
|
||||
})
|
||||
|
||||
return {
|
||||
...res,
|
||||
'root-ca': btoa(res['root-ca']),
|
||||
}
|
||||
}
|
||||
|
||||
async setupComplete () {
|
||||
await this.http.rpcRequest<SetupEmbassyRes>({
|
||||
method: 'setup.complete',
|
||||
params: { },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isCifsSource (source: CifsRecoverySource | DiskRecoverySource | undefined): source is CifsRecoverySource {
|
||||
return !!(source as CifsRecoverySource)?.hostname
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
import { ApiService, CifsRecoverySource, SetupEmbassyReq } from './api.service'
|
||||
|
||||
let tries = 0
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MockApiService extends ApiService {
|
||||
|
||||
constructor () {
|
||||
super()
|
||||
}
|
||||
|
||||
// ** UNENCRYPTED **
|
||||
|
||||
async getStatus () {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
'product-key': true,
|
||||
migrating: false,
|
||||
}
|
||||
}
|
||||
|
||||
async getDrives () {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
disks: [
|
||||
{
|
||||
logicalname: 'abcd',
|
||||
vendor: 'Samsung',
|
||||
model: 'T5',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pabcd',
|
||||
label: null,
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
'embassy-os': {
|
||||
version: '0.2.17',
|
||||
full: true,
|
||||
'password-hash': null,
|
||||
'wrapped-key': null,
|
||||
},
|
||||
}
|
||||
],
|
||||
capacity: 123456789123,
|
||||
guid: 'uuid-uuid-uuid-uuid',
|
||||
}
|
||||
],
|
||||
reconnect: [],
|
||||
}
|
||||
}
|
||||
|
||||
async set02XDrive () {
|
||||
await pauseFor(1000)
|
||||
return
|
||||
}
|
||||
|
||||
async getRecoveryStatus () {
|
||||
tries = Math.min(tries + 1, 4)
|
||||
return {
|
||||
'bytes-transferred': tries,
|
||||
'total-bytes': 4,
|
||||
complete: tries === 4,
|
||||
}
|
||||
}
|
||||
|
||||
// ** ENCRYPTED **
|
||||
|
||||
async verifyCifs (params: CifsRecoverySource) {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
version: '0.3.0',
|
||||
full: true,
|
||||
'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
'wrapped-key': '',
|
||||
}
|
||||
}
|
||||
|
||||
async verifyProductKey () {
|
||||
await pauseFor(1000)
|
||||
return
|
||||
}
|
||||
|
||||
async importDrive (guid: string) {
|
||||
await pauseFor(3000)
|
||||
return setupRes
|
||||
}
|
||||
|
||||
async setupEmbassy (setupInfo: SetupEmbassyReq) {
|
||||
await pauseFor(3000)
|
||||
return setupRes
|
||||
}
|
||||
|
||||
async setupComplete () {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
}
|
||||
|
||||
const rootCA =
|
||||
`-----BEGIN CERTIFICATE-----
|
||||
MIIDpzCCAo+gAwIBAgIRAIIuOarlQETlUQEOZJGZYdIwDQYJKoZIhvcNAQELBQAw
|
||||
bTELMAkGA1UEBhMCVVMxFTATBgNVBAoMDEV4YW1wbGUgQ29ycDEOMAwGA1UECwwF
|
||||
U2FsZXMxCzAJBgNVBAgMAldBMRgwFgYDVQQDDA93d3cuZXhhbXBsZS5jb20xEDAO
|
||||
BgNVBAcMB1NlYXR0bGUwHhcNMjEwMzA4MTU0NjI3WhcNMjIwMzA4MTY0NjI3WjBt
|
||||
MQswCQYDVQQGEwJVUzEVMBMGA1UECgwMRXhhbXBsZSBDb3JwMQ4wDAYDVQQLDAVT
|
||||
YWxlczELMAkGA1UECAwCV0ExGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTEQMA4G
|
||||
A1UEBwwHU2VhdHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMP7
|
||||
t5AKFZQ7abqkeyUjsBVIWRa9tCh8oge9u/LvCbxU738G4jssT+Oud3WMajIjuNow
|
||||
cpc+0Q/e42ULO/6gTNrTs6OCOo9lV6G0Dprf/e91DWoKgPatem/pUjNyraifHZfu
|
||||
b5mLHCfahjWXUQtc/sjmDQaZRK3Kar6ljlUBE/Le9NEyOAIkSLPzDtW8LXm4iwcU
|
||||
BZrb828rKd1Aw9oI1+3bfzB6xXmzZxc5RLXveOCEhKGD32jKZ/RNFSC8AZAwJe+x
|
||||
bTsys/lUOYFTuT8Bn0TGxR8x7Y4H75+F9BavY3v+WkLj4M+olN9dMR7Et9FMt4u4
|
||||
YRokv5zp8zIb5iTne1kCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
|
||||
FgQUaW3+r328uTLokog2TklmoBK+yt4wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3
|
||||
DQEBCwUAA4IBAQAXjd/7UZ8RDE+PLWSDNGQdLemOBTcawF+tK+PzA4Evlmn9VuNc
|
||||
g+x3oZvVZSDQBANUz0b9oPeo54aE38dW1zQm2qfTab8822aqeWMLyJ1dMsAgqYX2
|
||||
t9+u6w3NzRCw8Pvz18V69+dFE5AeXmNP0Z5/gdz8H/NSpctjlzopbScRZKCSlPid
|
||||
Rf3ZOPm9QP92YpWyYDkfAU04xdDo1vR0MYjKPkl4LjRqSU/tcCJnPMbJiwq+bWpX
|
||||
2WJoEBXB/p15Kn6JxjI0ze2SnSI48JZ8it4fvxrhOo0VoLNIuCuNXJOwU17Rdl1W
|
||||
YJidaq7je6k18AdgPA0Kh8y1XtfUH3fTaVw4
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
const setupRes = {
|
||||
'tor-address': 'http://asdafsadasdasasdasdfasdfasdf.onion',
|
||||
'lan-address': 'https://embassy-abcdefgh.local',
|
||||
'root-ca': btoa(rootCA),
|
||||
}
|
||||
|
||||
const disks = [
|
||||
{
|
||||
vendor: 'Samsung',
|
||||
model: 'SATA',
|
||||
logicalname: '/dev/sda',
|
||||
guid: 'theguid',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'sda1',
|
||||
label: 'label 1',
|
||||
capacity: 100000,
|
||||
used: 200.1255312,
|
||||
'embassy-os': null,
|
||||
},
|
||||
{
|
||||
logicalname: 'sda2',
|
||||
label: 'label 2',
|
||||
capacity: 50000,
|
||||
used: 200.1255312,
|
||||
'embassy-os': null,
|
||||
},
|
||||
],
|
||||
capacity: 150000,
|
||||
},
|
||||
{
|
||||
vendor: 'Samsung',
|
||||
model: null,
|
||||
logicalname: 'dev/sdb',
|
||||
partitions: [],
|
||||
capacity: 34359738369,
|
||||
guid: null,
|
||||
},
|
||||
{
|
||||
vendor: 'Crucial',
|
||||
model: 'MX500',
|
||||
logicalname: 'dev/sdc',
|
||||
guid: null,
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'sdc1',
|
||||
label: 'label 1',
|
||||
capacity: 0,
|
||||
used: null,
|
||||
'embassy-os': {
|
||||
version: '0.3.3',
|
||||
full: true,
|
||||
'password-hash': 'asdfasdfasdf',
|
||||
'wrapped-key': '',
|
||||
},
|
||||
},
|
||||
{
|
||||
logicalname: 'sdc1MOCKTESTER',
|
||||
label: 'label 1',
|
||||
capacity: 0,
|
||||
used: null,
|
||||
'embassy-os': {
|
||||
version: '0.3.6',
|
||||
full: true,
|
||||
// password is 'asdfasdf'
|
||||
'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
'wrapped-key': '',
|
||||
},
|
||||
},
|
||||
{
|
||||
logicalname: 'sdc1',
|
||||
label: 'label 1',
|
||||
capacity: 0,
|
||||
used: null,
|
||||
'embassy-os': {
|
||||
version: '0.3.3',
|
||||
full: false,
|
||||
'password-hash': 'asdfasdfasdf',
|
||||
'wrapped-key': '',
|
||||
},
|
||||
},
|
||||
],
|
||||
capacity: 100000,
|
||||
},
|
||||
{
|
||||
vendor: 'Sandisk',
|
||||
model: null,
|
||||
logicalname: '/dev/sdd',
|
||||
guid: null,
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'sdd1',
|
||||
label: null,
|
||||
capacity: 10000,
|
||||
used: null,
|
||||
'embassy-os': {
|
||||
version: '0.2.7',
|
||||
full: true,
|
||||
'password-hash': 'asdfasdfasdf',
|
||||
'wrapped-key': '',
|
||||
},
|
||||
},
|
||||
],
|
||||
capacity: 10000,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ToastController } from '@ionic/angular'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ErrorToastService {
|
||||
private toast: HTMLIonToastElement
|
||||
|
||||
constructor (
|
||||
private readonly toastCtrl: ToastController,
|
||||
) { }
|
||||
|
||||
async present (message: string): Promise<void> {
|
||||
if (this.toast) return
|
||||
|
||||
this.toast = await this.toastCtrl.create({
|
||||
header: 'Error',
|
||||
message,
|
||||
duration: 0,
|
||||
position: 'top',
|
||||
cssClass: 'error-toast',
|
||||
animated: true,
|
||||
buttons: [
|
||||
{
|
||||
side: 'end',
|
||||
icon: 'close',
|
||||
handler: () => {
|
||||
this.dismiss()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await this.toast.present()
|
||||
}
|
||||
|
||||
async dismiss (): Promise<void> {
|
||||
if (this.toast) {
|
||||
await this.toast.dismiss()
|
||||
this.toast = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ErrorHandler, Injectable } from '@angular/core'
|
||||
|
||||
@Injectable()
|
||||
export class GlobalErrorHandler implements ErrorHandler {
|
||||
|
||||
handleError (e: any): void {
|
||||
console.error(e)
|
||||
const chunkFailedMessage = /Loading chunk [\d]+ failed/
|
||||
|
||||
if (chunkFailedMessage.test(e.message)) {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { ApiService, CifsRecoverySource, DiskRecoverySource } from './api/api.service'
|
||||
import { ErrorToastService } from './error-toast.service'
|
||||
import { pauseFor } from '../util/misc.util'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StateService {
|
||||
hasProductKey: boolean
|
||||
isMigrating: boolean
|
||||
|
||||
polling = false
|
||||
embassyLoaded = false
|
||||
|
||||
recoverySource: CifsRecoverySource | DiskRecoverySource
|
||||
recoveryPassword: string
|
||||
|
||||
dataTransferProgress: { bytesTransferred: number, totalBytes: number, complete: boolean } | null
|
||||
dataProgress = 0
|
||||
dataCompletionSubject = new BehaviorSubject(false)
|
||||
|
||||
torAddress: string
|
||||
lanAddress: string
|
||||
cert: string
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly errorToastService: ErrorToastService,
|
||||
) { }
|
||||
|
||||
async pollDataTransferProgress () {
|
||||
this.polling = true
|
||||
await pauseFor(500)
|
||||
|
||||
if (
|
||||
this.dataTransferProgress?.complete
|
||||
) {
|
||||
this.dataCompletionSubject.next(true)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let progress
|
||||
try {
|
||||
progress = await this.apiService.getRecoveryStatus()
|
||||
} catch (e) {
|
||||
this.errorToastService.present(`${e.message}: ${e.details}.\nRestart Embassy to try again.`)
|
||||
}
|
||||
if (progress) {
|
||||
this.dataTransferProgress = {
|
||||
bytesTransferred: progress['bytes-transferred'],
|
||||
totalBytes: progress['total-bytes'],
|
||||
complete: progress.complete,
|
||||
}
|
||||
if (this.dataTransferProgress.totalBytes) {
|
||||
this.dataProgress = this.dataTransferProgress.bytesTransferred / this.dataTransferProgress.totalBytes
|
||||
}
|
||||
}
|
||||
setTimeout(() => this.pollDataTransferProgress(), 0) // prevent call stack from growing
|
||||
}
|
||||
|
||||
async importDrive (guid: string): Promise<void> {
|
||||
const ret = await this.apiService.importDrive(guid)
|
||||
this.torAddress = ret['tor-address']
|
||||
this.lanAddress = ret['lan-address']
|
||||
this.cert = ret['root-ca']
|
||||
}
|
||||
|
||||
async setupEmbassy (storageLogicalname: string, password: string): Promise<void> {
|
||||
const ret = await this.apiService.setupEmbassy({
|
||||
'embassy-logicalname': storageLogicalname,
|
||||
'embassy-password': password,
|
||||
'recovery-source': this.recoverySource || null,
|
||||
'recovery-password': this.recoveryPassword || null,
|
||||
})
|
||||
this.torAddress = ret['tor-address']
|
||||
this.lanAddress = ret['lan-address']
|
||||
this.cert = ret['root-ca']
|
||||
}
|
||||
}
|
||||
3
frontend/projects/setup-wizard/src/app/util/misc.util.ts
Normal file
3
frontend/projects/setup-wizard/src/app/util/misc.util.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const pauseFor = (ms: number) => {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// This file can be replaced during build by using the `fileReplacements` array.
|
||||
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
|
||||
// The list of file replacements can be found in `angular.json`.
|
||||
|
||||
export const environment = {
|
||||
production: false,
|
||||
}
|
||||
|
||||
/*
|
||||
* For easier debugging in development mode, you can import the following file
|
||||
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
|
||||
*
|
||||
* This import should be commented out in production mode because it will have a negative impact
|
||||
* on performance if an error is thrown.
|
||||
*/
|
||||
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.
|
||||
23
frontend/projects/setup-wizard/src/index.html
Normal file
23
frontend/projects/setup-wizard/src/index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Embassy Setup</title>
|
||||
|
||||
<base href="/" />
|
||||
|
||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
<script>
|
||||
var global = window;
|
||||
</script>
|
||||
<link rel="icon" type="image/x-icon" href="assets/icon/favicon.ico"/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
12
frontend/projects/setup-wizard/src/main.ts
Normal file
12
frontend/projects/setup-wizard/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { enableProdMode } from '@angular/core'
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
|
||||
|
||||
import { AppModule } from './app/app.module'
|
||||
import { environment } from './environments/environment'
|
||||
|
||||
if (environment.production) {
|
||||
enableProdMode()
|
||||
}
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.log(err))
|
||||
65
frontend/projects/setup-wizard/src/polyfills.ts
Normal file
65
frontend/projects/setup-wizard/src/polyfills.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* This file includes polyfills needed by Angular and is loaded before the app.
|
||||
* You can add your own extra polyfills to this file.
|
||||
*
|
||||
* This file is divided into 2 sections:
|
||||
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
|
||||
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
|
||||
* file.
|
||||
*
|
||||
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
|
||||
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
|
||||
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
|
||||
*
|
||||
* Learn more in https://angular.io/guide/browser-support
|
||||
*/
|
||||
|
||||
/***************************************************************************************************
|
||||
* BROWSER POLYFILLS
|
||||
*/
|
||||
|
||||
/** IE11 requires the following for NgClass support on SVG elements */
|
||||
// import 'classlist.js'; // Run `npm install --save classlist.js`.
|
||||
|
||||
/**
|
||||
* Web Animations `@angular/platform-browser/animations`
|
||||
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
|
||||
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
|
||||
*/
|
||||
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
|
||||
|
||||
/**
|
||||
* By default, zone.js will patch all possible macroTask and DomEvents
|
||||
* user can disable parts of macroTask/DomEvents patch by setting following flags
|
||||
* because those flags need to be set before `zone.js` being loaded, and webpack
|
||||
* will put import in the top of bundle, so user need to create a separate file
|
||||
* in this directory (for example: zone-flags.ts), and put the following flags
|
||||
* into that file, and then add the following code before importing zone.js.
|
||||
* import './zone-flags';
|
||||
*
|
||||
* The flags allowed in zone-flags.ts are listed here.
|
||||
*
|
||||
* The following flags will work for all browsers.
|
||||
*
|
||||
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
|
||||
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
|
||||
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
|
||||
*
|
||||
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
|
||||
* with the following flag, it will bypass `zone.js` patch for IE/Edge
|
||||
*
|
||||
* (window as any).__Zone_enable_cross_context_check = true;
|
||||
*
|
||||
*/
|
||||
|
||||
import './zone-flags'
|
||||
|
||||
/***************************************************************************************************
|
||||
* Zone JS is required by default for Angular itself.
|
||||
*/
|
||||
import 'zone.js/dist/zone' // Included with Angular CLI.
|
||||
|
||||
|
||||
/***************************************************************************************************
|
||||
* APPLICATION IMPORTS
|
||||
*/
|
||||
123
frontend/projects/setup-wizard/src/styles.scss
Normal file
123
frontend/projects/setup-wizard/src/styles.scss
Normal file
@@ -0,0 +1,123 @@
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-Regular.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-Bold.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: thin;
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-Light.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Benton Sans';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: url('/assets/fonts/Benton_Sans/BentonSans-Regular.otf');
|
||||
}
|
||||
|
||||
/** Ionic CSS Variables overrides **/
|
||||
:root {
|
||||
--ion-font-family: 'Benton Sans';
|
||||
}
|
||||
|
||||
ion-content {
|
||||
--background: var(--ion-color-medium);
|
||||
}
|
||||
|
||||
ion-grid {
|
||||
padding-top: 32px;
|
||||
height: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
ion-row {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--color: var(--ion-color-light);
|
||||
}
|
||||
|
||||
ion-toolbar {
|
||||
--ion-background-color: var(--ion-color-light);
|
||||
ion-title {
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
}
|
||||
|
||||
ion-avatar {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--highlight-color-valid: transparent;
|
||||
--highlight-color-invalid: transparent;
|
||||
|
||||
--border-radius: 4px;
|
||||
}
|
||||
|
||||
ion-card-title {
|
||||
margin: 16px 0;
|
||||
font-family: 'Montserrat';
|
||||
font-size: x-large;
|
||||
--color: var(--ion-color-light);
|
||||
}
|
||||
|
||||
ion-toast {
|
||||
--background: var(--ion-color-light);
|
||||
--button-color: var(--ion-color-dark);
|
||||
--border-style: solid;
|
||||
--border-width: 1px;
|
||||
--color: white;
|
||||
}
|
||||
|
||||
.center-spinner {
|
||||
height: 20vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inline {
|
||||
* {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.claim-button {
|
||||
margin-inline-start: 0;
|
||||
margin-inline-end: 0;
|
||||
margin-top: 24px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.error-toast {
|
||||
--border-color: var(--ion-color-danger);
|
||||
width: 40%;
|
||||
min-width: 400px;
|
||||
--end: 8px;
|
||||
right: 8px;
|
||||
left: unset;
|
||||
top: 64px;
|
||||
}
|
||||
|
||||
.error-border {
|
||||
border: 2px solid var(--ion-color-danger);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success-border {
|
||||
border: 2px solid var(--ion-color-success);
|
||||
border-radius: 4px;
|
||||
}
|
||||
6
frontend/projects/setup-wizard/src/zone-flags.ts
Normal file
6
frontend/projects/setup-wizard/src/zone-flags.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Prevents Angular change detection from
|
||||
* running with certain Web Component callbacks
|
||||
*/
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
(window as any).__Zone_disable_customElements = true
|
||||
9
frontend/projects/setup-wizard/tsconfig.app.json
Normal file
9
frontend/projects/setup-wizard/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./"
|
||||
},
|
||||
"files": ["src/main.ts", "src/polyfills.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user