mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 20:43:41 +00:00
Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/major
This commit is contained in:
56
web/projects/setup-wizard/src/app/app-routing.module.ts
Normal file
56
web/projects/setup-wizard/src/app/app-routing.module.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () =>
|
||||
import('./pages/home/home.module').then(m => m.HomePageModule),
|
||||
},
|
||||
{
|
||||
path: 'attach',
|
||||
loadChildren: () =>
|
||||
import('./pages/attach/attach.module').then(m => m.AttachPageModule),
|
||||
},
|
||||
{
|
||||
path: 'recover',
|
||||
loadChildren: () =>
|
||||
import('./pages/recover/recover.module').then(m => m.RecoverPageModule),
|
||||
},
|
||||
{
|
||||
path: 'transfer',
|
||||
loadChildren: () =>
|
||||
import('./pages/transfer/transfer.module').then(
|
||||
m => m.TransferPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'storage',
|
||||
loadChildren: () =>
|
||||
import('./pages/embassy/embassy.module').then(m => m.EmbassyPageModule),
|
||||
},
|
||||
{
|
||||
path: 'loading',
|
||||
loadChildren: () =>
|
||||
import('./pages/loading/loading.module').then(m => m.LoadingPageModule),
|
||||
},
|
||||
{
|
||||
path: 'success',
|
||||
loadChildren: () =>
|
||||
import('./pages/success/success.module').then(m => m.SuccessPageModule),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, {
|
||||
scrollPositionRestoration: 'enabled',
|
||||
preloadingStrategy: PreloadAllModules,
|
||||
useHash: true,
|
||||
initialNavigation: 'disabled',
|
||||
}),
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AppRoutingModule {}
|
||||
5
web/projects/setup-wizard/src/app/app.component.html
Normal file
5
web/projects/setup-wizard/src/app/app.component.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<tui-root>
|
||||
<ion-app>
|
||||
<ion-router-outlet></ion-router-outlet>
|
||||
</ion-app>
|
||||
</tui-root>
|
||||
8
web/projects/setup-wizard/src/app/app.component.scss
Normal file
8
web/projects/setup-wizard/src/app/app.component.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
tui-root {
|
||||
height: 100%;
|
||||
}
|
||||
32
web/projects/setup-wizard/src/app/app.component.ts
Normal file
32
web/projects/setup-wizard/src/app/app.component.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
|
||||
@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,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const inProgress = await this.apiService.getSetupStatus()
|
||||
|
||||
let route = '/home'
|
||||
if (inProgress) {
|
||||
route = inProgress.complete ? '/success' : '/loading'
|
||||
}
|
||||
|
||||
await this.navCtrl.navigateForward(route)
|
||||
} catch (e: any) {
|
||||
this.errorToastService.present(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
65
web/projects/setup-wizard/src/app/app.module.ts
Normal file
65
web/projects/setup-wizard/src/app/app.module.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
|
||||
import { RouteReuseStrategy } from '@angular/router'
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { TuiRootModule } from '@taiga-ui/core'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { MockApiService } from './services/api/mock-api.service'
|
||||
import { LiveApiService } from './services/api/live-api.service'
|
||||
import {
|
||||
IonicModule,
|
||||
IonicRouteStrategy,
|
||||
iosTransitionAnimation,
|
||||
} from '@ionic/angular'
|
||||
import { AppComponent } from './app.component'
|
||||
import { AppRoutingModule } from './app-routing.module'
|
||||
import { SuccessPageModule } from './pages/success/success.module'
|
||||
import { HomePageModule } from './pages/home/home.module'
|
||||
import { LoadingPageModule } from './pages/loading/loading.module'
|
||||
import { RecoverPageModule } from './pages/recover/recover.module'
|
||||
import { TransferPageModule } from './pages/transfer/transfer.module'
|
||||
import {
|
||||
provideSetupLogsService,
|
||||
provideSetupService,
|
||||
RELATIVE_URL,
|
||||
WorkspaceConfig,
|
||||
} from '@start9labs/shared'
|
||||
|
||||
const {
|
||||
useMocks,
|
||||
ui: { api },
|
||||
} = require('../../../../config.json') as WorkspaceConfig
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
imports: [
|
||||
BrowserAnimationsModule,
|
||||
IonicModule.forRoot({
|
||||
mode: 'md',
|
||||
navAnimation: iosTransitionAnimation,
|
||||
}),
|
||||
AppRoutingModule,
|
||||
HttpClientModule,
|
||||
SuccessPageModule,
|
||||
HomePageModule,
|
||||
LoadingPageModule,
|
||||
RecoverPageModule,
|
||||
TransferPageModule,
|
||||
TuiRootModule,
|
||||
],
|
||||
providers: [
|
||||
provideSetupService(ApiService),
|
||||
provideSetupLogsService(ApiService),
|
||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||
{
|
||||
provide: ApiService,
|
||||
useClass: useMocks ? MockApiService : LiveApiService,
|
||||
},
|
||||
{
|
||||
provide: RELATIVE_URL,
|
||||
useValue: `/${api.url}/${api.version}`,
|
||||
},
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,12 @@
|
||||
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,93 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Connect Network 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="warning"
|
||||
(click)="cancel()"
|
||||
>
|
||||
Cancel
|
||||
</ion-button>
|
||||
<ion-button
|
||||
class="ion-padding-end"
|
||||
slot="end"
|
||||
color="primary"
|
||||
strong="true"
|
||||
[disabled]="!cifsForm.form.valid"
|
||||
(click)="submit()"
|
||||
>
|
||||
Verify
|
||||
</ion-button>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,16 @@
|
||||
.item-interactive {
|
||||
--highlight-background: var(--ion-color-dark) !important;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
|
||||
&:hover {
|
||||
transition-property: transform;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.item-has-focus {
|
||||
--background: var(--ion-color-dark-tint) !important;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Component } from '@angular/core'
|
||||
import {
|
||||
AlertController,
|
||||
LoadingController,
|
||||
ModalController,
|
||||
} from '@ionic/angular'
|
||||
import { ApiService, CifsBackupTarget } from 'src/app/services/api/api.service'
|
||||
import { StartOSDiskInfo } from '@start9labs/shared'
|
||||
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 api: ApiService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
) {}
|
||||
|
||||
cancel() {
|
||||
this.modalController.dismiss()
|
||||
}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Connecting to shared folder...',
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
const diskInfo = await this.api.verifyCifs({
|
||||
...this.cifs,
|
||||
password: this.cifs.password
|
||||
? await this.api.encrypt(this.cifs.password)
|
||||
: null,
|
||||
})
|
||||
|
||||
await loader.dismiss()
|
||||
|
||||
this.presentModalPassword(diskInfo)
|
||||
} catch (e) {
|
||||
await loader.dismiss()
|
||||
this.presentAlertFailed()
|
||||
}
|
||||
}
|
||||
|
||||
private async presentModalPassword(diskInfo: StartOSDiskInfo): Promise<void> {
|
||||
const target: CifsBackupTarget = {
|
||||
...this.cifs,
|
||||
mountable: true,
|
||||
'embassy-os': diskInfo,
|
||||
}
|
||||
|
||||
const modal = await this.modalController.create({
|
||||
component: PasswordPage,
|
||||
componentProps: { target },
|
||||
})
|
||||
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,12 @@
|
||||
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,89 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>{{ storageDrive ? 'Set Password' : 'Unlock Drive' }}</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<div style="padding: 8px 24px">
|
||||
<p *ngIf="!storageDrive else choose">
|
||||
Enter the password that was used to encrypt this drive.
|
||||
</p>
|
||||
<ng-template #choose>
|
||||
<p>
|
||||
Choose a password for your server.
|
||||
<i>Make it good. Write it down.</i>
|
||||
</p>
|
||||
</ng-template>
|
||||
|
||||
<form (ngSubmit)="storageDrive ? submitPw() : verifyPw()">
|
||||
<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()"
|
||||
></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>
|
||||
<p *ngIf="pwError" class="error-message">{{ pwError }}</p>
|
||||
<ng-container *ngIf="storageDrive">
|
||||
<ion-item
|
||||
[class]="verError ? 'error-border' : passwordVer ? 'success-border' : ''"
|
||||
>
|
||||
<ion-input
|
||||
[(ngModel)]="passwordVer"
|
||||
[ngModelOptions]="{'standalone': true}"
|
||||
[type]="!unmasked2 ? 'password' : 'text'"
|
||||
placeholder="Retype Password"
|
||||
(ionChange)="checkVer()"
|
||||
></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>
|
||||
<p *ngIf="verError" class="error-message">{{ verError }}</p>
|
||||
</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="warning"
|
||||
(click)="cancel()"
|
||||
>
|
||||
Cancel
|
||||
</ion-button>
|
||||
<ion-button
|
||||
class="ion-padding-end"
|
||||
slot="end"
|
||||
strong="true"
|
||||
(click)="storageDrive ? submitPw() : verifyPw()"
|
||||
>
|
||||
{{ storageDrive ? 'Finish' : 'Unlock' }}
|
||||
</ion-button>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,21 @@
|
||||
.item-interactive {
|
||||
--highlight-background: var(--ion-color-dark) !important;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
&:hover {
|
||||
transition-property: transform;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.item-has-focus {
|
||||
--background: var(--ion-color-dark-tint) !important;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--ion-color-danger) !important;
|
||||
font-size: .9rem !important;
|
||||
margin-left: 36px;
|
||||
margin-top: -16px;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Component, Input, ViewChild } from '@angular/core'
|
||||
import { IonInput, ModalController } from '@ionic/angular'
|
||||
import {
|
||||
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 = false
|
||||
|
||||
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 {
|
||||
const passwordHash = this.target!['embassy-os']?.['password-hash'] || ''
|
||||
|
||||
argon2.verify(passwordHash, 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 if (this.password.length > 64) {
|
||||
this.pwError = 'Must be less than 65 characters'
|
||||
} else {
|
||||
this.pwError = ''
|
||||
}
|
||||
}
|
||||
|
||||
checkVer() {
|
||||
this.verError =
|
||||
this.password !== this.passwordVer ? 'Passwords do not match' : ''
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.modalController.dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { AttachPage } from './attach.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AttachPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AttachPageRoutingModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
GuidPipePipesModule,
|
||||
UnitConversionPipesModule,
|
||||
} from '@start9labs/shared'
|
||||
import { AttachPage } from './attach.page'
|
||||
import { AttachPageRoutingModule } from './attach-routing.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AttachPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
AttachPageRoutingModule,
|
||||
UnitConversionPipesModule,
|
||||
GuidPipePipesModule,
|
||||
],
|
||||
})
|
||||
export class AttachPageModule {}
|
||||
@@ -0,0 +1,67 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col>
|
||||
<ion-card color="dark">
|
||||
<ion-card-header class="ion-text-center">
|
||||
<ion-card-title>Use existing drive</ion-card-title>
|
||||
<div class="center-wrapper">
|
||||
<ion-card-subtitle>
|
||||
Select the physical drive containing your StartOS data
|
||||
</ion-card-subtitle>
|
||||
</div>
|
||||
</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" class="ion-text-center">
|
||||
<!-- drives -->
|
||||
<p *ngIf="!drives.length">
|
||||
No valid StartOS data drives found. Please make sure the drive
|
||||
is a valid StartOS data drive (not a backup) and is firmly
|
||||
connected, then refresh the page.
|
||||
</p>
|
||||
|
||||
<ng-container *ngFor="let drive of drives">
|
||||
<ion-item
|
||||
*ngIf="drive | guid as guid"
|
||||
button
|
||||
(click)="select(guid)"
|
||||
lines="none"
|
||||
>
|
||||
<ion-icon
|
||||
slot="start"
|
||||
name="save-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<h1>{{ drive.logicalname }}</h1>
|
||||
<p>
|
||||
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model ||
|
||||
'Unknown Model' }}
|
||||
</p>
|
||||
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
<ion-button
|
||||
class="ion-margin-top"
|
||||
color="primary"
|
||||
(click)="refresh()"
|
||||
>
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Refresh
|
||||
</ion-button>
|
||||
</ion-item-group>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Component } from '@angular/core'
|
||||
import {
|
||||
LoadingController,
|
||||
ModalController,
|
||||
NavController,
|
||||
} from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { DiskInfo, ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PasswordPage } from 'src/app/modals/password/password.page'
|
||||
|
||||
@Component({
|
||||
selector: 'app-attach',
|
||||
templateUrl: 'attach.page.html',
|
||||
styleUrls: ['attach.page.scss'],
|
||||
})
|
||||
export class AttachPage {
|
||||
loading = true
|
||||
drives: DiskInfo[] = []
|
||||
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly errToastService: ErrorToastService,
|
||||
private readonly stateService: StateService,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.setupType = 'attach'
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
try {
|
||||
this.drives = await this.apiService.getDrives()
|
||||
} catch (e: any) {
|
||||
this.errToastService.present(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async select(guid: string) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: PasswordPage,
|
||||
componentProps: { storageDrive: true },
|
||||
})
|
||||
modal.onDidDismiss().then(res => {
|
||||
if (res.data && res.data.password) {
|
||||
this.attachDrive(guid, res.data.password)
|
||||
}
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async attachDrive(guid: string, password: string) {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Connecting to drive...',
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
try {
|
||||
await this.stateService.importDrive(guid, password)
|
||||
await this.navCtrl.navigateForward(`/loading`)
|
||||
} catch (e: any) {
|
||||
this.errToastService.present(e)
|
||||
} finally {
|
||||
loader.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,25 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
GuidPipePipesModule,
|
||||
UnitConversionPipesModule,
|
||||
} from '@start9labs/shared'
|
||||
import { EmbassyPage } from './embassy.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
import { EmbassyPageRoutingModule } from './embassy-routing.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
EmbassyPageRoutingModule,
|
||||
PasswordPageModule,
|
||||
UnitConversionPipesModule,
|
||||
GuidPipePipesModule,
|
||||
],
|
||||
declarations: [EmbassyPage],
|
||||
})
|
||||
export class EmbassyPageModule {}
|
||||
@@ -0,0 +1,87 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col class="ion-text-center">
|
||||
<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>
|
||||
<div class="center-wrapper">
|
||||
<ion-card-subtitle>
|
||||
This is the drive where your StartOS data will be stored.
|
||||
</ion-card-subtitle>
|
||||
</div>
|
||||
</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>
|
||||
<div class="center-wrapper">
|
||||
<ion-card-subtitle>
|
||||
Please connect a storage drive to your server. Then click
|
||||
"Refresh".
|
||||
</ion-card-subtitle>
|
||||
</div>
|
||||
</ion-card-header>
|
||||
</ng-template>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<!-- loading -->
|
||||
<ion-spinner
|
||||
*ngIf="loading; else loaded"
|
||||
class="center-spinner"
|
||||
name="lines-sharp"
|
||||
></ion-spinner>
|
||||
|
||||
<!-- not loading -->
|
||||
<ng-template #loaded>
|
||||
<ion-item-group
|
||||
*ngIf="storageDrives.length"
|
||||
class="ion-padding-bottom"
|
||||
>
|
||||
<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"
|
||||
></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>
|
||||
<ion-button fill="solid" color="primary" (click)="getDrives()">
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Refresh
|
||||
</ion-button>
|
||||
</ng-template>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
162
web/projects/setup-wizard/src/app/pages/embassy/embassy.page.ts
Normal file
162
web/projects/setup-wizard/src/app/pages/embassy/embassy.page.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Component } from '@angular/core'
|
||||
import {
|
||||
AlertController,
|
||||
LoadingController,
|
||||
ModalController,
|
||||
NavController,
|
||||
} from '@ionic/angular'
|
||||
import {
|
||||
ApiService,
|
||||
BackupRecoverySource,
|
||||
DiskRecoverySource,
|
||||
DiskMigrateSource,
|
||||
} from 'src/app/services/api/api.service'
|
||||
import { DiskInfo, ErrorToastService, GuidPipe } from '@start9labs/shared'
|
||||
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'],
|
||||
providers: [GuidPipe],
|
||||
})
|
||||
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,
|
||||
private readonly guidPipe: GuidPipe,
|
||||
) {}
|
||||
|
||||
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 = await this.apiService.getDrives()
|
||||
if (this.stateService.setupType === 'fresh') {
|
||||
this.storageDrives = disks
|
||||
} else if (this.stateService.setupType === 'restore') {
|
||||
this.storageDrives = disks.filter(
|
||||
d =>
|
||||
!d.partitions
|
||||
.map(p => p.logicalname)
|
||||
.includes(
|
||||
(
|
||||
(this.stateService.recoverySource as BackupRecoverySource)
|
||||
?.target as DiskRecoverySource
|
||||
)?.logicalname,
|
||||
),
|
||||
)
|
||||
} else if (this.stateService.setupType === 'transfer') {
|
||||
const guid = (this.stateService.recoverySource as DiskMigrateSource)
|
||||
.guid
|
||||
this.storageDrives = disks.filter(d => {
|
||||
return (
|
||||
d.guid !== guid && !d.partitions.map(p => p.guid).includes(guid)
|
||||
)
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorToastService.present(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async chooseDrive(drive: DiskInfo) {
|
||||
if (
|
||||
this.guidPipe.transform(drive) ||
|
||||
!!drive.partitions.find(p => p.used)
|
||||
) {
|
||||
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: () => {
|
||||
// for backup recoveries
|
||||
if (this.stateService.recoveryPassword) {
|
||||
this.setupEmbassy(
|
||||
drive.logicalname,
|
||||
this.stateService.recoveryPassword,
|
||||
)
|
||||
} else {
|
||||
// for migrations and fresh setups
|
||||
this.presentModalPassword(drive.logicalname)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
} else {
|
||||
// for backup recoveries
|
||||
if (this.stateService.recoveryPassword) {
|
||||
this.setupEmbassy(drive.logicalname, this.stateService.recoveryPassword)
|
||||
} else {
|
||||
// for migrations and fresh setups
|
||||
this.presentModalPassword(drive.logicalname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async presentModalPassword(logicalname: string): Promise<void> {
|
||||
const modal = await this.modalController.create({
|
||||
component: PasswordPage,
|
||||
componentProps: {
|
||||
storageDrive: true,
|
||||
},
|
||||
})
|
||||
modal.onDidDismiss().then(async ret => {
|
||||
if (!ret.data || !ret.data.password) return
|
||||
this.setupEmbassy(logicalname, ret.data.password)
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async setupEmbassy(
|
||||
logicalname: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Connecting to drive...',
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.stateService.setupEmbassy(logicalname, password)
|
||||
await this.navCtrl.navigateForward(`/loading`)
|
||||
} catch (e: any) {
|
||||
this.errorToastService.present(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 {}
|
||||
21
web/projects/setup-wizard/src/app/pages/home/home.module.ts
Normal file
21
web/projects/setup-wizard/src/app/pages/home/home.module.ts
Normal file
@@ -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'
|
||||
import { SwiperModule } from 'swiper/angular'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
HomePageRoutingModule,
|
||||
PasswordPageModule,
|
||||
SwiperModule,
|
||||
],
|
||||
declarations: [HomePage],
|
||||
})
|
||||
export class HomePageModule {}
|
||||
129
web/projects/setup-wizard/src/app/pages/home/home.page.html
Normal file
129
web/projects/setup-wizard/src/app/pages/home/home.page.html
Normal file
@@ -0,0 +1,129 @@
|
||||
<ion-content>
|
||||
<ion-grid *ngIf="!loading">
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col class="ion-text-center">
|
||||
<div style="padding-bottom: 32px">
|
||||
<img
|
||||
src="assets/img/icon.png"
|
||||
class="pb-1"
|
||||
style="max-width: 100px"
|
||||
/>
|
||||
</div>
|
||||
<ion-card color="dark">
|
||||
<ion-card-header style="padding-bottom: 0">
|
||||
<ion-button
|
||||
*ngIf="swiper?.activeIndex === 1"
|
||||
class="back-button"
|
||||
fill="clear"
|
||||
color="light"
|
||||
(click)="previous()"
|
||||
>
|
||||
<ion-icon slot="icon-only" name="arrow-back"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-card-title>
|
||||
{{ swiper?.activeIndex === 0 ? 'StartOS Setup' : 'Recover Options'
|
||||
}}
|
||||
</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content class="ion-margin-bottom">
|
||||
<swiper
|
||||
[autoHeight]="true"
|
||||
[observeParents]="true"
|
||||
(swiper)="setSwiperInstance($event)"
|
||||
>
|
||||
<!-- SLIDE 1 -->
|
||||
<ng-template swiperSlide>
|
||||
<!-- fresh -->
|
||||
<ion-item
|
||||
button
|
||||
[disabled]="error"
|
||||
detail="false"
|
||||
lines="none"
|
||||
routerLink="/storage"
|
||||
>
|
||||
<ion-icon slot="start" name="add"></ion-icon>
|
||||
<ion-label>
|
||||
<h2><ion-text color="success">Start Fresh</ion-text></h2>
|
||||
<p>Get started with a brand new Start9 server</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- recover -->
|
||||
<ion-item
|
||||
button
|
||||
[disabled]="error"
|
||||
detail="false"
|
||||
lines="none"
|
||||
(click)="next()"
|
||||
>
|
||||
<ion-icon slot="start" name="reload"></ion-icon>
|
||||
<ion-label>
|
||||
<h2><ion-text color="danger">Recover</ion-text></h2>
|
||||
<p>Recover, restore, or transfer StartOS data</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-template>
|
||||
|
||||
<!-- SLIDE 2 -->
|
||||
<ng-template swiperSlide>
|
||||
<!-- attach -->
|
||||
<ion-item
|
||||
button
|
||||
detail="false"
|
||||
lines="none"
|
||||
routerLink="/attach"
|
||||
>
|
||||
<ion-icon slot="start" name="cube-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>
|
||||
<ion-text color="success">Use Existing Drive</ion-text>
|
||||
</h2>
|
||||
<p>Attach an existing StartOS data drive (not a backup)</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- transfer -->
|
||||
<ion-item
|
||||
button
|
||||
detail="false"
|
||||
lines="none"
|
||||
routerLink="/transfer"
|
||||
>
|
||||
<ion-icon slot="start" name="share-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>
|
||||
<ion-text color="primary">Transfer</ion-text>
|
||||
</h2>
|
||||
<p>
|
||||
Transfer data from an existing StartOS data drive (not a
|
||||
backup) to a new, preferred drive
|
||||
<br />
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- restore from backup -->
|
||||
<ion-item
|
||||
button
|
||||
lines="none"
|
||||
detail="false"
|
||||
routerLink="/recover"
|
||||
>
|
||||
<ion-icon slot="start" name="save-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>
|
||||
<ion-text color="warning">
|
||||
Restore From Backup (Disaster Recovery)
|
||||
</ion-text>
|
||||
</h2>
|
||||
<p>Restore StartOS data from an encrypted backup</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-template>
|
||||
</swiper>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
13
web/projects/setup-wizard/src/app/pages/home/home.page.scss
Normal file
13
web/projects/setup-wizard/src/app/pages/home/home.page.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
.back-button {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 24px;
|
||||
z-index: 1000000;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
53
web/projects/setup-wizard/src/app/pages/home/home.page.ts
Normal file
53
web/projects/setup-wizard/src/app/pages/home/home.page.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { IonicSlides } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import SwiperCore, { Swiper } from 'swiper'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
SwiperCore.use([IonicSlides])
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: 'home.page.html',
|
||||
styleUrls: ['home.page.scss'],
|
||||
})
|
||||
export class HomePage {
|
||||
swiper?: Swiper
|
||||
error = false
|
||||
loading = true
|
||||
|
||||
constructor(
|
||||
private readonly api: ApiService,
|
||||
private readonly errToastService: ErrorToastService,
|
||||
private readonly stateService: StateService,
|
||||
) {}
|
||||
|
||||
async ionViewDidEnter() {
|
||||
this.stateService.setupType = 'fresh'
|
||||
if (this.swiper) {
|
||||
this.swiper.allowTouchMove = false
|
||||
}
|
||||
|
||||
try {
|
||||
await this.api.getPubKey()
|
||||
} catch (e: any) {
|
||||
this.error = true
|
||||
this.errToastService.present(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
setSwiperInstance(swiper: any) {
|
||||
this.swiper = swiper
|
||||
}
|
||||
|
||||
next() {
|
||||
this.swiper?.slideNext(500)
|
||||
}
|
||||
|
||||
previous() {
|
||||
this.swiper?.slidePrev(500)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { LoadingModule } from '@start9labs/shared'
|
||||
import { LoadingPage } from './loading.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LoadingPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [LoadingModule, RouterModule.forChild(routes)],
|
||||
declarations: [LoadingPage],
|
||||
})
|
||||
export class LoadingPageModule {}
|
||||
@@ -0,0 +1,5 @@
|
||||
<app-loading
|
||||
class="ion-page"
|
||||
[setupType]="stateService.setupType"
|
||||
(finished)="navCtrl.navigateForward('/success')"
|
||||
></app-loading>
|
||||
@@ -0,0 +1,3 @@
|
||||
ion-card-title {
|
||||
font-size: 42px;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
templateUrl: 'loading.page.html',
|
||||
})
|
||||
export class LoadingPage {
|
||||
constructor(
|
||||
readonly stateService: StateService,
|
||||
readonly navCtrl: NavController,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="inline">
|
||||
<!-- has backup -->
|
||||
<h2 *ngIf="hasValidBackup; else noBackup">
|
||||
<ion-icon name="cloud-done" color="success"></ion-icon>
|
||||
StartOS backup detected
|
||||
</h2>
|
||||
<!-- no backup -->
|
||||
<ng-template #noBackup>
|
||||
<h2>
|
||||
<ion-icon name="cloud-offline" color="danger"></ion-icon>
|
||||
No StartOS 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,23 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { UnitConversionPipesModule } from '@start9labs/shared'
|
||||
import { DriveStatusComponent, RecoverPage } from './recover.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
import { RecoverPageRoutingModule } from './recover-routing.module'
|
||||
import { CifsModalModule } from 'src/app/modals/cifs-modal/cifs-modal.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [RecoverPage, DriveStatusComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
RecoverPageRoutingModule,
|
||||
PasswordPageModule,
|
||||
UnitConversionPipesModule,
|
||||
CifsModalModule,
|
||||
],
|
||||
})
|
||||
export class RecoverPageModule {}
|
||||
@@ -0,0 +1,97 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col>
|
||||
<ion-card color="dark">
|
||||
<ion-card-header class="ion-text-center">
|
||||
<ion-card-title>Restore from Backup</ion-card-title>
|
||||
</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" class="ion-text-center">
|
||||
<!-- cifs -->
|
||||
<h2 class="target-label">Network Folder</h2>
|
||||
<p class="ion-padding-bottom ion-text-left">
|
||||
Restore StartOS data from a folder on another computer that is
|
||||
connected to the same network as your server.
|
||||
</p>
|
||||
|
||||
<!-- connect -->
|
||||
<ion-item button lines="none" (click)="presentModalCifs()">
|
||||
<ion-icon
|
||||
slot="start"
|
||||
name="folder-open-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<b>Open</b>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<!-- drives -->
|
||||
<h2 class="target-label">Physical Drive</h2>
|
||||
<div class="ion-text-left ion-padding-bottom">
|
||||
<p>
|
||||
Restore StartOS data from a physical drive that is plugged
|
||||
directly into your server.
|
||||
</p>
|
||||
<br />
|
||||
<b>
|
||||
Warning. Do not use this option if you are using a Raspberry
|
||||
Pi with an external SSD as your main data drive. The Raspberry
|
||||
Pi cannot not support more than one external drive without
|
||||
additional power and can cause data corruption.
|
||||
</b>
|
||||
</div>
|
||||
|
||||
<ng-container *ngFor="let mapped of mappedDrives">
|
||||
<ion-item
|
||||
button
|
||||
*ngIf="mapped.drive as drive"
|
||||
[disabled]="!driveClickable(mapped)"
|
||||
(click)="select(drive)"
|
||||
lines="none"
|
||||
>
|
||||
<ion-icon
|
||||
slot="start"
|
||||
name="save-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<h1>{{ drive.label || drive.logicalname }}</h1>
|
||||
<drive-status
|
||||
[hasValidBackup]="mapped.hasValidBackup"
|
||||
></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-button
|
||||
class="ion-margin-top"
|
||||
color="primary"
|
||||
(click)="refresh()"
|
||||
>
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Refresh
|
||||
</ion-button>
|
||||
</ion-item-group>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,5 @@
|
||||
.target-label {
|
||||
font-weight: 500;
|
||||
padding-bottom: 6px;
|
||||
font-variant-caps: all-small-caps;
|
||||
}
|
||||
138
web/projects/setup-wizard/src/app/pages/recover/recover.page.ts
Normal file
138
web/projects/setup-wizard/src/app/pages/recover/recover.page.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { 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 '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PasswordPage } from '../../modals/password/password.page'
|
||||
|
||||
@Component({
|
||||
selector: 'app-recover',
|
||||
templateUrl: 'recover.page.html',
|
||||
styleUrls: ['recover.page.scss'],
|
||||
})
|
||||
export class RecoverPage {
|
||||
loading = true
|
||||
mappedDrives: MappedDisk[] = []
|
||||
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly modalController: ModalController,
|
||||
private readonly errToastService: ErrorToastService,
|
||||
private readonly stateService: StateService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.setupType = 'restore'
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
driveClickable(mapped: MappedDisk) {
|
||||
return mapped.drive['embassy-os']?.full
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
this.mappedDrives = []
|
||||
try {
|
||||
const disks = 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,
|
||||
drive,
|
||||
})
|
||||
})
|
||||
})
|
||||
} catch (e: any) {
|
||||
this.errToastService.present(e)
|
||||
} 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: 'backup',
|
||||
target: {
|
||||
type: 'cifs',
|
||||
hostname,
|
||||
path,
|
||||
username,
|
||||
password,
|
||||
},
|
||||
}
|
||||
this.stateService.recoveryPassword = res.data.recoveryPassword
|
||||
this.navCtrl.navigateForward('/storage')
|
||||
}
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async select(target: DiskBackupTarget) {
|
||||
const { logicalname } = target
|
||||
|
||||
if (!logicalname) return
|
||||
|
||||
const modal = await this.modalController.create({
|
||||
component: PasswordPage,
|
||||
componentProps: { target },
|
||||
cssClass: 'alertlike-modal',
|
||||
})
|
||||
modal.onDidDismiss().then(res => {
|
||||
if (res.data?.password) {
|
||||
this.selectRecoverySource(logicalname, res.data.password)
|
||||
}
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async selectRecoverySource(logicalname: string, password?: string) {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'backup',
|
||||
target: {
|
||||
type: 'disk',
|
||||
logicalname,
|
||||
},
|
||||
}
|
||||
this.stateService.recoveryPassword = password
|
||||
this.navCtrl.navigateForward(`/storage`)
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'drive-status',
|
||||
templateUrl: './drive-status.component.html',
|
||||
styleUrls: ['./recover.page.scss'],
|
||||
})
|
||||
export class DriveStatusComponent {
|
||||
@Input() hasValidBackup!: boolean
|
||||
}
|
||||
|
||||
interface MappedDisk {
|
||||
hasValidBackup: boolean
|
||||
drive: DiskBackupTarget
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>StartOS Address Info</title>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
style="
|
||||
font-family: Montserrat, sans-serif;
|
||||
color: #333333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: auto;
|
||||
width: clamp(900px, 35vw, 600px);
|
||||
"
|
||||
>
|
||||
<h1
|
||||
style="
|
||||
font-variant-caps: all-small-caps;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
"
|
||||
>
|
||||
StartOS Address Info
|
||||
</h1>
|
||||
|
||||
<section
|
||||
style="
|
||||
padding: 1rem 3rem 2rem 3rem;
|
||||
margin-bottom: 24px;
|
||||
border: solid #c4c4c5 3px;
|
||||
border-radius: 20px;
|
||||
"
|
||||
>
|
||||
<div>
|
||||
<h3 style="color: #f8546a; font-weight: bold">Important!</h3>
|
||||
<p>
|
||||
Download your server's Root CA and
|
||||
<a
|
||||
href="https://docs.start9.com/0.3.5.x/user-manual/connecting-lan"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style="color: #6866cc; font-weight: bold; text-decoration: none"
|
||||
>
|
||||
follow the instructions
|
||||
</a>
|
||||
to establish a secure connection with your server.
|
||||
</p>
|
||||
</div>
|
||||
<div style="text-align: center">
|
||||
<a
|
||||
id="cert"
|
||||
[download]="crtName"
|
||||
style="
|
||||
display: inline-block;
|
||||
padding: 1em 1.2em;
|
||||
box-sizing: border-box;
|
||||
font-size: 1rem;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
border-radius: clamp(2rem, 3rem, 4rem);
|
||||
cursor: pointer;
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px,
|
||||
rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
|
||||
background: #6866cc;
|
||||
color: #f4f4f5;
|
||||
"
|
||||
>
|
||||
Download certificate
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
style="
|
||||
padding: 1rem 3rem 2rem 3rem;
|
||||
border: solid #c4c4c5 3px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 24px;
|
||||
"
|
||||
>
|
||||
<h2 style="font-variant-caps: all-small-caps">
|
||||
Access from home (LAN)
|
||||
</h2>
|
||||
<p>
|
||||
Visit the address below when you are connected to the same WiFi or
|
||||
Local Area Network (LAN) as your server.
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
padding: 16px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
overflow: auto;
|
||||
"
|
||||
>
|
||||
<code id="lan-addr"></code>
|
||||
</p>
|
||||
|
||||
<h2 style="font-variant-caps: all-small-caps">
|
||||
Access on the go (Tor)
|
||||
</h2>
|
||||
<p>Visit the address below when you are away from home.</p>
|
||||
<p>
|
||||
<span style="font-weight: bold">Note:</span>
|
||||
This address will only work from a Tor-enabled browser.
|
||||
<a
|
||||
href="https://docs.start9.com/0.3.5.x/user-manual/connecting-tor"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style="color: #6866cc; font-weight: bold; text-decoration: none"
|
||||
>
|
||||
Follow the instructions
|
||||
</a>
|
||||
to get setup.
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
padding: 16px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
overflow: auto;
|
||||
"
|
||||
>
|
||||
<code id="tor-addr"></code>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'download-doc',
|
||||
templateUrl: 'download-doc.component.html',
|
||||
})
|
||||
export class DownloadDocComponent {
|
||||
@Input() lanAddress!: string
|
||||
|
||||
get crtName(): string {
|
||||
const hostname = new URL(this.lanAddress).hostname
|
||||
return `${hostname}.crt`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { SuccessPage } from './success.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: SuccessPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class SuccessPageRoutingModule {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ResponsiveColModule } from '@start9labs/shared'
|
||||
|
||||
import { SuccessPage } from './success.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
import { SuccessPageRoutingModule } from './success-routing.module'
|
||||
import { DownloadDocComponent } from './download-doc/download-doc.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
PasswordPageModule,
|
||||
SuccessPageRoutingModule,
|
||||
ResponsiveColModule,
|
||||
],
|
||||
declarations: [SuccessPage, DownloadDocComponent],
|
||||
exports: [SuccessPage],
|
||||
})
|
||||
export class SuccessPageModule {}
|
||||
@@ -0,0 +1,102 @@
|
||||
<canvas #canvas>Your browser does not support the canvas element.</canvas>
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col>
|
||||
<!-- kiosk mode -->
|
||||
<ng-container *ngIf="isKiosk; else notKiosk">
|
||||
<ion-card>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col responsiveCol sizeXs="12" class="ion-text-center">
|
||||
<div class="inline mb-12">
|
||||
<ion-icon
|
||||
name="checkmark-circle-outline"
|
||||
color="success"
|
||||
></ion-icon>
|
||||
<h1>Setup Complete!</h1>
|
||||
</div>
|
||||
<ion-button
|
||||
shape="round"
|
||||
class="login-button mb-12"
|
||||
(click)="exitKiosk()"
|
||||
>
|
||||
Continue to Login
|
||||
<ion-icon name="log-in-outline" slot="end"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-card>
|
||||
</ng-container>
|
||||
<!-- not kiosk -->
|
||||
<ng-template #notKiosk>
|
||||
<ion-card *ngIf="lanAddress">
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col responsiveCol sizeXs="12" class="ion-text-center">
|
||||
<div class="mb-12">
|
||||
<div class="inline-container setup">
|
||||
<ion-icon
|
||||
name="checkmark-circle-outline"
|
||||
color="success"
|
||||
></ion-icon>
|
||||
<h1>Setup Complete!</h1>
|
||||
</div>
|
||||
<h3 *ngIf="setupType === 'restore'">
|
||||
You can now safely unplug your backup drive
|
||||
</h3>
|
||||
<h3 *ngIf="setupType === 'transfer'">
|
||||
You can now safely unplug your old StartOS data drive
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-container">
|
||||
<ion-card id="information" (click)="download()">
|
||||
<ion-card-content>
|
||||
<ion-card-title>Download address info</ion-card-title>
|
||||
<p>
|
||||
start.local was for setup purposes only. It will no
|
||||
longer work.
|
||||
</p>
|
||||
</ion-card-content>
|
||||
<ion-footer>
|
||||
<div class="inline-container">
|
||||
<p class="action-text">Download</p>
|
||||
<ion-icon slot="end" name="download-outline"></ion-icon>
|
||||
</div>
|
||||
</ion-footer>
|
||||
</ion-card>
|
||||
<ion-card
|
||||
id="launch"
|
||||
[disabled]="disableLogin"
|
||||
href="{{ lanAddress }}"
|
||||
target="_blank"
|
||||
>
|
||||
<ion-card-content>
|
||||
<ion-card-title>Trust your Root CA</ion-card-title>
|
||||
<p>
|
||||
In the new tab, follow instructions to trust your
|
||||
server's Root CA and log in.
|
||||
</p>
|
||||
</ion-card-content>
|
||||
<ion-footer>
|
||||
<div class="container">
|
||||
<div class="inline-container">
|
||||
<p class="action-text">Open</p>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
</ion-footer>
|
||||
</ion-card>
|
||||
</div>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
<!-- download elem -->
|
||||
<download-doc
|
||||
hidden
|
||||
id="downloadable"
|
||||
[lanAddress]="lanAddress"
|
||||
></download-doc>
|
||||
</ion-card>
|
||||
</ng-template>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,183 @@
|
||||
canvas {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-variant: all-small-caps;
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
ion-content {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
--background: transparent;
|
||||
}
|
||||
|
||||
ion-grid {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.inline-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
ion-card {
|
||||
padding: 2.4rem;
|
||||
|
||||
h1 {
|
||||
color: var(--ion-color-success);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
ion-icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
// download info card
|
||||
ion-card {
|
||||
min-height: 260px;
|
||||
width: 80%;
|
||||
background: #615F5F;
|
||||
color: var(--ion-text-color);
|
||||
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
|
||||
border-radius: 44px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding: 1rem 2rem;
|
||||
transition: all 350ms ease;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
transition-property: transform;
|
||||
transform: scale(1.05);
|
||||
transition-delay: 40ms;
|
||||
}
|
||||
|
||||
ion-card-title {
|
||||
color: var(--ion-text-color);
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
ion-footer {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 0;
|
||||
color: var(--ion-text-color);
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-md::before {
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-button {
|
||||
--background: var(--color-accent);
|
||||
--padding-bottom: 2.5rem;
|
||||
--padding-top: 2.5rem;
|
||||
--padding-start: 2.5rem;
|
||||
--padding-end: 2.5rem;
|
||||
--border-radius: 44px;
|
||||
font-size: 1.4rem !important;
|
||||
font-weight: bold;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
transition: all 350ms ease;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
transition-property: transform;
|
||||
transform: scale(1.05);
|
||||
transition-delay: 40ms;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.launch-button {
|
||||
--background: var(--alt-blue);
|
||||
}
|
||||
|
||||
#information:after, #launch:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 79%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
#launch:after {
|
||||
background: var(--alt-blue);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.mb-12 {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.pb-2 {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.pt-1 {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
.action-text {
|
||||
font-variant-caps: all-small-caps;
|
||||
padding-right: 0.5rem;
|
||||
font-size: 1.5rem !important;
|
||||
letter-spacing: 0.03rem;
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.setup {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
ion-card {
|
||||
ion-card {
|
||||
width: 100%;
|
||||
padding-bottom: unset;
|
||||
}
|
||||
#information:after {
|
||||
top: 84%;
|
||||
}
|
||||
#launch:after {
|
||||
top: 85%;
|
||||
}
|
||||
}
|
||||
}
|
||||
143
web/projects/setup-wizard/src/app/pages/success/success.page.ts
Normal file
143
web/projects/setup-wizard/src/app/pages/success/success.page.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { Component, ElementRef, Inject, NgZone, ViewChild } from '@angular/core'
|
||||
import { DownloadHTMLService, ErrorToastService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'success',
|
||||
templateUrl: 'success.page.html',
|
||||
styleUrls: ['success.page.scss'],
|
||||
providers: [DownloadHTMLService],
|
||||
})
|
||||
export class SuccessPage {
|
||||
@ViewChild('canvas', { static: true })
|
||||
private canvas: ElementRef<HTMLCanvasElement> = {} as ElementRef<HTMLCanvasElement>
|
||||
private ctx: CanvasRenderingContext2D = {} as CanvasRenderingContext2D
|
||||
|
||||
torAddress?: string
|
||||
lanAddress?: string
|
||||
cert?: string
|
||||
|
||||
tileSize = 16
|
||||
// a higher fade factor will make the characters fade quicker
|
||||
fadeFactor = 0.07
|
||||
columns: any[] = []
|
||||
maxStackHeight: any
|
||||
disableLogin = this.stateService.setupType === 'fresh'
|
||||
|
||||
constructor(
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
private readonly errCtrl: ErrorToastService,
|
||||
private readonly stateService: StateService,
|
||||
private readonly api: ApiService,
|
||||
private readonly downloadHtml: DownloadHTMLService,
|
||||
private readonly ngZone: NgZone,
|
||||
) {}
|
||||
|
||||
get setupType() {
|
||||
return this.stateService.setupType
|
||||
}
|
||||
|
||||
get isKiosk() {
|
||||
return ['localhost', '127.0.0.1'].includes(this.document.location.hostname)
|
||||
}
|
||||
|
||||
async ngAfterViewInit() {
|
||||
this.ngZone.runOutsideAngular(() => this.initMatrix())
|
||||
setTimeout(() => this.complete(), 1000)
|
||||
}
|
||||
|
||||
download() {
|
||||
const torAddress = this.document.getElementById('tor-addr')
|
||||
const lanAddress = this.document.getElementById('lan-addr')
|
||||
|
||||
if (torAddress) torAddress.innerHTML = this.torAddress!
|
||||
if (lanAddress) lanAddress.innerHTML = this.lanAddress!
|
||||
|
||||
this.document
|
||||
.getElementById('cert')
|
||||
?.setAttribute(
|
||||
'href',
|
||||
'data:application/x-x509-ca-cert;base64,' +
|
||||
encodeURIComponent(this.cert!),
|
||||
)
|
||||
let html = this.document.getElementById('downloadable')?.innerHTML || ''
|
||||
this.downloadHtml.download('StartOS-info.html', html).then(_ => {
|
||||
this.disableLogin = false
|
||||
})
|
||||
}
|
||||
|
||||
exitKiosk() {
|
||||
this.api.exit()
|
||||
}
|
||||
|
||||
private async complete() {
|
||||
try {
|
||||
const ret = await this.api.complete()
|
||||
if (!this.isKiosk) {
|
||||
this.torAddress = ret['tor-address'].replace(/^https:/, 'http:')
|
||||
this.lanAddress = ret['lan-address'].replace(/^https:/, 'http:')
|
||||
this.cert = ret['root-ca']
|
||||
|
||||
await this.api.exit()
|
||||
}
|
||||
} catch (e: any) {
|
||||
await this.errCtrl.present(e)
|
||||
}
|
||||
}
|
||||
|
||||
private initMatrix() {
|
||||
this.ctx = this.canvas.nativeElement.getContext('2d')!
|
||||
this.canvas.nativeElement.width = window.innerWidth
|
||||
this.canvas.nativeElement.height = window.innerHeight
|
||||
this.setupMatrixGrid()
|
||||
this.tick()
|
||||
}
|
||||
|
||||
private setupMatrixGrid() {
|
||||
this.maxStackHeight = Math.ceil(this.ctx.canvas.height / this.tileSize)
|
||||
// divide the canvas into columns
|
||||
for (let i = 0; i < this.ctx.canvas.width / this.tileSize; ++i) {
|
||||
const column = {} as any
|
||||
// save the x position of the column
|
||||
column.x = i * this.tileSize
|
||||
// create a random stack height for the column
|
||||
column.stackHeight = 10 + Math.random() * this.maxStackHeight
|
||||
// add a counter to count the stack height
|
||||
column.stackCounter = 0
|
||||
// add the column to the list
|
||||
this.columns.push(column)
|
||||
}
|
||||
}
|
||||
|
||||
private draw() {
|
||||
// draw a semi transparent black rectangle on top of the scene to slowly fade older characters
|
||||
this.ctx.fillStyle = 'rgba( 0 , 0 , 0 , ' + this.fadeFactor + ' )'
|
||||
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
|
||||
// pick a font slightly smaller than the tile size
|
||||
this.ctx.font = this.tileSize - 2 + 'px monospace'
|
||||
this.ctx.fillStyle = '#ff4961'
|
||||
for (let i = 0; i < this.columns.length; ++i) {
|
||||
// pick a random ascii character (change the 94 to a higher number to include more characters)
|
||||
const randomCharacter = String.fromCharCode(
|
||||
33 + Math.floor(Math.random() * 94),
|
||||
)
|
||||
this.ctx.fillText(
|
||||
randomCharacter,
|
||||
this.columns[i].x,
|
||||
this.columns[i].stackCounter * this.tileSize + this.tileSize,
|
||||
)
|
||||
// if the stack is at its height limit, pick a new random height and reset the counter
|
||||
if (++this.columns[i].stackCounter >= this.columns[i].stackHeight) {
|
||||
this.columns[i].stackHeight = 10 + Math.random() * this.maxStackHeight
|
||||
this.columns[i].stackCounter = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private tick() {
|
||||
this.draw()
|
||||
setTimeout(this.tick.bind(this), 50)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { TransferPage } from './transfer.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: TransferPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class TransferPageRoutingModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
GuidPipePipesModule,
|
||||
UnitConversionPipesModule,
|
||||
} from '@start9labs/shared'
|
||||
import { TransferPage } from './transfer.page'
|
||||
import { TransferPageRoutingModule } from './transfer-routing.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [TransferPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TransferPageRoutingModule,
|
||||
UnitConversionPipesModule,
|
||||
GuidPipePipesModule,
|
||||
],
|
||||
})
|
||||
export class TransferPageModule {}
|
||||
@@ -0,0 +1,61 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col>
|
||||
<ion-card color="dark">
|
||||
<ion-card-header class="ion-text-center">
|
||||
<ion-card-title>Transfer</ion-card-title>
|
||||
<div class="center-wrapper">
|
||||
<ion-card-subtitle>
|
||||
Select the physical drive containing your StartOS data
|
||||
</ion-card-subtitle>
|
||||
</div>
|
||||
</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" class="ion-text-center">
|
||||
<!-- drives -->
|
||||
<ng-container *ngFor="let drive of drives">
|
||||
<ion-item
|
||||
*ngIf="drive | guid as guid"
|
||||
button
|
||||
(click)="select(guid)"
|
||||
lines="none"
|
||||
>
|
||||
<ion-icon
|
||||
slot="start"
|
||||
name="save-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<h1>{{ drive.logicalname }}</h1>
|
||||
<p>
|
||||
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model ||
|
||||
'Unknown Model' }}
|
||||
</p>
|
||||
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
<ion-button
|
||||
class="ion-margin-top"
|
||||
color="primary"
|
||||
(click)="refresh()"
|
||||
>
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Refresh
|
||||
</ion-button>
|
||||
</ion-item-group>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { AlertController, NavController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { DiskInfo, ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-transfer',
|
||||
templateUrl: 'transfer.page.html',
|
||||
styleUrls: ['transfer.page.scss'],
|
||||
})
|
||||
export class TransferPage {
|
||||
loading = true
|
||||
drives: DiskInfo[] = []
|
||||
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly errToastService: ErrorToastService,
|
||||
private readonly stateService: StateService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.setupType = 'transfer'
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
try {
|
||||
this.drives = await this.apiService.getDrives()
|
||||
} catch (e: any) {
|
||||
this.errToastService.present(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async select(guid: string) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message:
|
||||
'After transferring data from this drive, <b>do not</b> attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.',
|
||||
buttons: [
|
||||
{
|
||||
role: 'cancel',
|
||||
text: 'Cancel',
|
||||
},
|
||||
{
|
||||
text: 'Continue',
|
||||
handler: () => {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'migrate',
|
||||
guid,
|
||||
}
|
||||
this.navCtrl.navigateForward(`/storage`)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
}
|
||||
100
web/projects/setup-wizard/src/app/services/api/api.service.ts
Normal file
100
web/projects/setup-wizard/src/app/services/api/api.service.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import * as jose from 'node-jose'
|
||||
import {
|
||||
DiskListResponse,
|
||||
StartOSDiskInfo,
|
||||
Log,
|
||||
SetupStatus,
|
||||
} from '@start9labs/shared'
|
||||
import { Observable } from 'rxjs'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
|
||||
export abstract class ApiService {
|
||||
pubkey?: jose.JWK.Key
|
||||
|
||||
abstract getSetupStatus(): Promise<SetupStatus | null> // setup.status
|
||||
abstract getPubKey(): Promise<void> // setup.get-pubkey
|
||||
abstract getDrives(): Promise<DiskListResponse> // setup.disk.list
|
||||
abstract verifyCifs(cifs: CifsRecoverySource): Promise<StartOSDiskInfo> // setup.cifs.verify
|
||||
abstract attach(importInfo: AttachReq): Promise<void> // setup.attach
|
||||
abstract execute(setupInfo: ExecuteReq): Promise<void> // setup.execute
|
||||
abstract complete(): Promise<CompleteRes> // setup.complete
|
||||
abstract exit(): Promise<void> // setup.exit
|
||||
abstract followLogs(): Promise<string> // setup.logs.follow
|
||||
abstract openLogsWebsocket$(
|
||||
config: WebSocketSubjectConfig<Log>,
|
||||
): Observable<Log>
|
||||
|
||||
async encrypt(toEncrypt: string): Promise<Encrypted> {
|
||||
if (!this.pubkey) throw new Error('No pubkey found!')
|
||||
const encrypted = await jose.JWE.createEncrypt(this.pubkey!)
|
||||
.update(toEncrypt)
|
||||
.final()
|
||||
return {
|
||||
encrypted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Encrypted = {
|
||||
encrypted: string
|
||||
}
|
||||
|
||||
export type AttachReq = {
|
||||
guid: string
|
||||
'embassy-password': Encrypted
|
||||
}
|
||||
|
||||
export type ExecuteReq = {
|
||||
'embassy-logicalname': string
|
||||
'embassy-password': Encrypted
|
||||
'recovery-source': RecoverySource | null
|
||||
'recovery-password': Encrypted | null
|
||||
}
|
||||
|
||||
export type CompleteRes = {
|
||||
'tor-address': string
|
||||
'lan-address': string
|
||||
'root-ca': string
|
||||
}
|
||||
|
||||
export type DiskBackupTarget = {
|
||||
vendor: string | null
|
||||
model: string | null
|
||||
logicalname: string | null
|
||||
label: string | null
|
||||
capacity: number
|
||||
used: number | null
|
||||
'embassy-os': StartOSDiskInfo | null
|
||||
}
|
||||
|
||||
export type CifsBackupTarget = {
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
mountable: boolean
|
||||
'embassy-os': StartOSDiskInfo | null
|
||||
}
|
||||
|
||||
export type DiskRecoverySource = {
|
||||
type: 'disk'
|
||||
logicalname: string // partition logicalname
|
||||
}
|
||||
|
||||
export type BackupRecoverySource = {
|
||||
type: 'backup'
|
||||
target: CifsRecoverySource | DiskRecoverySource
|
||||
}
|
||||
export type RecoverySource = BackupRecoverySource | DiskMigrateSource
|
||||
|
||||
export type DiskMigrateSource = {
|
||||
type: 'migrate'
|
||||
guid: string
|
||||
}
|
||||
|
||||
export type CifsRecoverySource = {
|
||||
type: 'cifs'
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
password: Encrypted | null
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import {
|
||||
DiskListResponse,
|
||||
StartOSDiskInfo,
|
||||
encodeBase64,
|
||||
HttpService,
|
||||
isRpcError,
|
||||
Log,
|
||||
RpcError,
|
||||
RPCOptions,
|
||||
SetupStatus,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
ApiService,
|
||||
CifsRecoverySource,
|
||||
DiskRecoverySource,
|
||||
AttachReq,
|
||||
ExecuteReq,
|
||||
CompleteRes,
|
||||
} from './api.service'
|
||||
import * as jose from 'node-jose'
|
||||
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import { Observable } from 'rxjs'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LiveApiService extends ApiService {
|
||||
constructor(private readonly http: HttpService) {
|
||||
super()
|
||||
}
|
||||
|
||||
async getSetupStatus() {
|
||||
return this.rpcRequest<SetupStatus | null>({
|
||||
method: 'setup.status',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* We want to update the pubkey, which means that we will call in clearnet the
|
||||
* getPubKey, and all the information is never in the clear, and only public
|
||||
* information is sent across the network. We don't want to expose that we do
|
||||
* this wil all public/private key, which means that there is no information loss
|
||||
* through the network.
|
||||
*/
|
||||
async getPubKey() {
|
||||
const response: jose.JWK.Key = await this.rpcRequest({
|
||||
method: 'setup.get-pubkey',
|
||||
params: {},
|
||||
})
|
||||
|
||||
this.pubkey = response
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
return this.rpcRequest<DiskListResponse>({
|
||||
method: 'setup.disk.list',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async verifyCifs(source: CifsRecoverySource) {
|
||||
source.path = source.path.replace('/\\/g', '/')
|
||||
return this.rpcRequest<StartOSDiskInfo>({
|
||||
method: 'setup.cifs.verify',
|
||||
params: source,
|
||||
})
|
||||
}
|
||||
|
||||
async attach(params: AttachReq) {
|
||||
await this.rpcRequest<void>({
|
||||
method: 'setup.attach',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async execute(setupInfo: ExecuteReq) {
|
||||
if (setupInfo['recovery-source']?.type === 'backup') {
|
||||
if (isCifsSource(setupInfo['recovery-source'].target)) {
|
||||
setupInfo['recovery-source'].target.path = setupInfo[
|
||||
'recovery-source'
|
||||
].target.path.replace('/\\/g', '/')
|
||||
}
|
||||
}
|
||||
|
||||
await this.rpcRequest<void>({
|
||||
method: 'setup.execute',
|
||||
params: setupInfo,
|
||||
})
|
||||
}
|
||||
|
||||
async followLogs(): Promise<string> {
|
||||
return this.rpcRequest({ method: 'setup.logs.follow', params: {} })
|
||||
}
|
||||
|
||||
openLogsWebsocket$({ url }: WebSocketSubjectConfig<Log>): Observable<Log> {
|
||||
return webSocket(`http://start.local/ws/${url}`)
|
||||
}
|
||||
|
||||
async complete() {
|
||||
const res = await this.rpcRequest<CompleteRes>({
|
||||
method: 'setup.complete',
|
||||
params: {},
|
||||
})
|
||||
|
||||
return {
|
||||
...res,
|
||||
'root-ca': encodeBase64(res['root-ca']),
|
||||
}
|
||||
}
|
||||
|
||||
async exit() {
|
||||
await this.rpcRequest<void>({
|
||||
method: 'setup.exit',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
|
||||
const res = await this.http.rpcRequest<T>(opts)
|
||||
|
||||
const rpcRes = res.body
|
||||
|
||||
if (isRpcError(rpcRes)) {
|
||||
throw new RpcError(rpcRes.error)
|
||||
}
|
||||
|
||||
return rpcRes.result
|
||||
}
|
||||
}
|
||||
|
||||
function isCifsSource(
|
||||
source: CifsRecoverySource | DiskRecoverySource | null,
|
||||
): source is CifsRecoverySource {
|
||||
return !!(source as CifsRecoverySource)?.hostname
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import {
|
||||
encodeBase64,
|
||||
getSetupStatusMock,
|
||||
Log,
|
||||
pauseFor,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
ApiService,
|
||||
AttachReq,
|
||||
CifsRecoverySource,
|
||||
CompleteRes,
|
||||
ExecuteReq,
|
||||
} from './api.service'
|
||||
import * as jose from 'node-jose'
|
||||
import { interval, map, Observable } from 'rxjs'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MockApiService extends ApiService {
|
||||
async getSetupStatus() {
|
||||
return getSetupStatusMock()
|
||||
}
|
||||
|
||||
async getPubKey() {
|
||||
await pauseFor(1000)
|
||||
|
||||
// randomly generated
|
||||
// const keystore = jose.JWK.createKeyStore()
|
||||
// this.pubkey = await keystore.generate('EC', 'P-256')
|
||||
|
||||
// generated from backend
|
||||
this.pubkey = await jose.JWK.asKey({
|
||||
kty: 'EC',
|
||||
crv: 'P-256',
|
||||
x: 'yHTDYSfjU809fkSv9MmN4wuojf5c3cnD7ZDN13n-jz4',
|
||||
y: '8Mpkn744A5KDag0DmX2YivB63srjbugYZzWc3JOpQXI',
|
||||
})
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
await pauseFor(1000)
|
||||
return [
|
||||
{
|
||||
logicalname: '/dev/nvme0n1p3',
|
||||
vendor: 'Unknown Vendor',
|
||||
model: 'Samsung SSD - 970 EVO Plus 2TB',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pabcd',
|
||||
label: null,
|
||||
capacity: 1979120929996,
|
||||
used: null,
|
||||
'embassy-os': {
|
||||
version: '0.2.17',
|
||||
full: true,
|
||||
'password-hash':
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
'wrapped-key': null,
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 1979120929996,
|
||||
guid: 'uuid-uuid-uuid-uuid',
|
||||
},
|
||||
{
|
||||
logicalname: 'dcba',
|
||||
vendor: 'CT1000MX',
|
||||
model: '500SSD1',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pbcba',
|
||||
label: null,
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
'embassy-os': {
|
||||
version: '0.3.3',
|
||||
full: true,
|
||||
'password-hash':
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
'wrapped-key': null,
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 1000190509056,
|
||||
guid: null,
|
||||
},
|
||||
{
|
||||
logicalname: '/dev/sda',
|
||||
vendor: 'ASMT',
|
||||
model: '2115',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pbcba',
|
||||
label: null,
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
'embassy-os': {
|
||||
version: '0.3.2',
|
||||
full: true,
|
||||
'password-hash':
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
'wrapped-key': null,
|
||||
},
|
||||
guid: 'guid-guid-guid-guid',
|
||||
},
|
||||
],
|
||||
capacity: 1000190509056,
|
||||
guid: null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
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 attach(params: AttachReq) {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async execute(setupInfo: ExecuteReq) {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async followLogs(): Promise<string> {
|
||||
await pauseFor(1000)
|
||||
return 'fake-guid'
|
||||
}
|
||||
|
||||
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
|
||||
return interval(500).pipe(
|
||||
map(() => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'fake log entry',
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
async complete(): Promise<CompleteRes> {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
'tor-address': 'https://asdafsadasdasasdasdfasdfasdf.onion',
|
||||
'lan-address': 'https://adjective-noun.local',
|
||||
'root-ca': encodeBase64(rootCA),
|
||||
}
|
||||
}
|
||||
|
||||
async exit() {
|
||||
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-----`
|
||||
35
web/projects/setup-wizard/src/app/services/state.service.ts
Normal file
35
web/projects/setup-wizard/src/app/services/state.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ApiService, RecoverySource } from './api/api.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StateService {
|
||||
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
|
||||
|
||||
recoverySource?: RecoverySource
|
||||
recoveryPassword?: string
|
||||
|
||||
constructor(private readonly api: ApiService) {}
|
||||
|
||||
async importDrive(guid: string, password: string): Promise<void> {
|
||||
await this.api.attach({
|
||||
guid,
|
||||
'embassy-password': await this.api.encrypt(password),
|
||||
})
|
||||
}
|
||||
|
||||
async setupEmbassy(
|
||||
storageLogicalname: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
await this.api.execute({
|
||||
'embassy-logicalname': storageLogicalname,
|
||||
'embassy-password': await this.api.encrypt(password),
|
||||
'recovery-source': this.recoverySource || null,
|
||||
'recovery-password': this.recoveryPassword
|
||||
? await this.api.encrypt(this.recoveryPassword)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
}
|
||||
16
web/projects/setup-wizard/src/environments/environment.ts
Normal file
16
web/projects/setup-wizard/src/environments/environment.ts
Normal file
@@ -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.
|
||||
24
web/projects/setup-wizard/src/index.html
Normal file
24
web/projects/setup-wizard/src/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>StartOS 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>
|
||||
13
web/projects/setup-wizard/src/main.ts
Normal file
13
web/projects/setup-wizard/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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.error(err))
|
||||
64
web/projects/setup-wizard/src/polyfills.ts
Normal file
64
web/projects/setup-wizard/src/polyfills.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
345
web/projects/setup-wizard/src/styles.scss
Normal file
345
web/projects/setup-wizard/src/styles.scss
Normal file
@@ -0,0 +1,345 @@
|
||||
@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: 600;
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-SemiBold.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: 500;
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-Medium.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');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: url('/assets/fonts/Open_Sans/OpenSans-Regular.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
src: url('/assets/fonts/Open_Sans/OpenSans-Bold.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('/assets/fonts/Open_Sans/OpenSans-Bold.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url('/assets/fonts/Open_Sans/OpenSans-SemiBold.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: thin;
|
||||
src: url('/assets/fonts/Open_Sans/OpenSans-Light.ttf');
|
||||
}
|
||||
|
||||
/** Ionic CSS Variables overrides **/
|
||||
:root {
|
||||
--ion-font-family: 'Montserrat', sans-serif;
|
||||
|
||||
--ion-background-color: #333333;
|
||||
--ion-background-color-rgb: 51, 51, 51;
|
||||
|
||||
--ion-text-color: #F4F4F5;
|
||||
--ion-text-color-rgb: 244, 244, 245;
|
||||
|
||||
--ion-color-step-50: #3d3d3d;
|
||||
--ion-color-step-100: #464646;
|
||||
--ion-color-step-150: #505050;
|
||||
--ion-color-step-200: #5a5a5a;
|
||||
--ion-color-step-250: #636364;
|
||||
--ion-color-step-300: #6d6d6d;
|
||||
--ion-color-step-350: #777777;
|
||||
--ion-color-step-400: #808081;
|
||||
--ion-color-step-450: #8a8a8a;
|
||||
--ion-color-step-500: #949494;
|
||||
--ion-color-step-550: #9d9d9e;
|
||||
--ion-color-step-600: #a7a7a7;
|
||||
--ion-color-step-650: #b0b0b1;
|
||||
--ion-color-step-700: #bababb;
|
||||
--ion-color-step-750: #c4c4c5;
|
||||
--ion-color-step-800: #cdcdce;
|
||||
--ion-color-step-850: #d7d7d8;
|
||||
--ion-color-step-900: #e1e1e2;
|
||||
--ion-color-step-950: #eaeaeb;
|
||||
|
||||
|
||||
--ion-color-dark: var(--ion-color-step-50) !important;
|
||||
// --ion-color-base-rgb:
|
||||
--ion-color-dark-contrast: var(--ion-color-step-950) !important;
|
||||
// --ion-color-dark-contrast-rgb:
|
||||
--ion-color-dark-shade: var(--ion-color-step-100) !important;
|
||||
--ion-color-dark-tint: var(--ion-color-step-250) !important;
|
||||
|
||||
--color-accent: #6866cc;
|
||||
--color-dark-black: #121212;
|
||||
|
||||
--alt-red: #FF4961;
|
||||
--alt-orange: #F89248;
|
||||
--alt-yellow: #E5D53E;
|
||||
--alt-green: #3DCF6F;
|
||||
--alt-blue: #00A8A8;
|
||||
--alt-purple: #9747FF;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
ion-card-title {
|
||||
margin: 16px 0;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
font-size: x-large;
|
||||
--color: var(--ion-color-light);
|
||||
}
|
||||
|
||||
ion-card-subtitle {
|
||||
font-size: 20px;
|
||||
font-weight: 200;
|
||||
max-width: 400px;
|
||||
padding: 0.7rem;
|
||||
color: var(--ion-color-step-900) !important;
|
||||
}
|
||||
|
||||
ion-label ion-text {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--ion-color-dark-contrast) !important;
|
||||
font-size: 1.12rem !important;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
color: var(--ion-color-dark-contrast) !important;
|
||||
}
|
||||
|
||||
.small-caps {
|
||||
font-variant-caps: all-small-caps;
|
||||
}
|
||||
|
||||
ion-grid {
|
||||
padding-top: 32px;
|
||||
height: 100%;
|
||||
max-width: 695px;
|
||||
}
|
||||
|
||||
ion-row {
|
||||
height: 90%;
|
||||
}
|
||||
|
||||
ion-card {
|
||||
border-radius: 31px;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--highlight-color-valid: transparent;
|
||||
--highlight-color-invalid: transparent;
|
||||
}
|
||||
|
||||
ion-avatar {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
}
|
||||
|
||||
ion-toast {
|
||||
--background: var(--ion-color-light);
|
||||
--button-color: var(--ion-color-dark);
|
||||
--border-style: solid;
|
||||
--border-width: 1px;
|
||||
--color: white;
|
||||
}
|
||||
|
||||
.center-spinner {
|
||||
height: 6vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inline {
|
||||
* {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
padding-left: 0px 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.sc-ion-label-md-s p {
|
||||
line-height: 23px;
|
||||
}
|
||||
|
||||
ion-button {
|
||||
--padding-top: 1.3rem;
|
||||
--padding-bottom: 1.3rem;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
border: var(--ion-color-step-750) 1px solid;
|
||||
margin: 2rem;
|
||||
--background: transparent;
|
||||
--border-color: var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, 0.13))));
|
||||
transition: all 350ms ease;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
transition-property: transform;
|
||||
transform: scale(1.05);
|
||||
transition-delay: 40ms;
|
||||
}
|
||||
|
||||
ion-button {
|
||||
--padding-top: unset;
|
||||
--padding-bottom: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.item.sc-ion-label-md-h,
|
||||
.item .sc-ion-label-md-h {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.center-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
.loader {
|
||||
--spinner-color: var(--ion-color-tertiary) !important;
|
||||
}
|
||||
|
||||
.toolbar-background {
|
||||
background: #2a2a2a !important;
|
||||
}
|
||||
|
||||
.toolbar-container {
|
||||
padding-right: 2rem !important;
|
||||
}
|
||||
|
||||
ion-header {
|
||||
ion-toolbar {
|
||||
--border-color: var(--ion-color-step-950);
|
||||
--border-width: 0 0 1px 0;
|
||||
|
||||
--min-height: 80px;
|
||||
--padding-top: 20px;
|
||||
--padding-bottom: 20px;
|
||||
--padding-end: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
ion-footer {
|
||||
ion-toolbar {
|
||||
--border-width: 0;
|
||||
--padding-end: 2.3rem;
|
||||
--padding-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-md::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
margin: 0 0.5rem 2rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
p a {
|
||||
color: var(--ion-text-color);
|
||||
// text-decoration: none;
|
||||
font-weight: 600;
|
||||
text-underline-offset: 0.4rem;
|
||||
}
|
||||
6
web/projects/setup-wizard/src/zone-flags.ts
Normal file
6
web/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
|
||||
Reference in New Issue
Block a user