diff --git a/.github/workflows/startos-iso.yaml b/.github/workflows/startos-iso.yaml index 72e37eb9f..1efc3f9c1 100644 --- a/.github/workflows/startos-iso.yaml +++ b/.github/workflows/startos-iso.yaml @@ -251,10 +251,8 @@ jobs: mkdir -p patch-db/client/dist mkdir -p web/.angular mkdir -p web/dist/raw/ui - mkdir -p web/dist/raw/install-wizard mkdir -p web/dist/raw/setup-wizard mkdir -p web/dist/static/ui - mkdir -p web/dist/static/install-wizard mkdir -p web/dist/static/setup-wizard PLATFORM=${{ matrix.platform }} make -t compiled-${{ env.ARCH }}.tar diff --git a/web/README.md b/web/README.md index 2636e1e5d..7341d2907 100644 --- a/web/README.md +++ b/web/README.md @@ -4,7 +4,6 @@ StartOS web UIs are written in [Angular/Typescript](https://angular.io/docs) and StartOS conditionally serves one of three Web UIs, depending on the state of the system and user choice. -- **install-wizard** - UI for installing StartOS, served on localhost. - **setup-wizard** - UI for setting up StartOS, served on start.local. - **ui** - primary UI for administering StartOS, served on various hosts unique to the instance. @@ -69,7 +68,6 @@ You can develop using mocks (recommended to start) or against a live server. Eit #### Start the standard development server ```sh -npm run start:install npm run start:setup npm run start:ui ``` @@ -122,7 +120,6 @@ Translate the English dictionary below into ``. Format the result as a #### Adding to StartOS - In the `shared` project: - 1. Create a new file (`language.ts`) in `src/i18n/dictionaries` 2. Update the `I18N_PROVIDERS` array in `src/i18n/i18n.providers.ts` (2 places) 3. Update the `languages` array in `/src/i18n/i18n.service.ts` @@ -131,7 +128,6 @@ Translate the English dictionary below into ``. Format the result as a If you have any doubt about the above steps, check the [French example PR](https://github.com/Start9Labs/start-os/pull/2945/files) for reference. - Here in this README: - 1. Add the language to the list of supported languages below ### Updating the English dictionary diff --git a/web/angular.json b/web/angular.json index 862ea8cf1..4d3c0060c 100644 --- a/web/angular.json +++ b/web/angular.json @@ -121,114 +121,6 @@ } } }, - "install-wizard": { - "projectType": "application", - "schematics": {}, - "root": "projects/install-wizard", - "sourceRoot": "projects/install-wizard/src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular/build:application", - "options": { - "outputPath": { - "base": "dist/raw/install-wizard", - "browser": "" - }, - "index": "projects/install-wizard/src/index.html", - "browser": "projects/install-wizard/src/main.ts", - "polyfills": ["zone.js"], - "tsConfig": "projects/install-wizard/tsconfig.json", - "inlineStyleLanguage": "scss", - "assets": [ - { - "glob": "**/*", - "input": "projects/shared/assets", - "output": "assets" - }, - { - "glob": "**/*.svg", - "input": "node_modules/ionicons/dist/ionicons/svg", - "output": "./svg" - }, - { - "glob": "**/*", - "input": "node_modules/@taiga-ui/icons/src", - "output": "assets/taiga-ui/icons" - } - ], - "styles": [ - "node_modules/@taiga-ui/core/styles/taiga-ui-theme.less" - ], - "scripts": [] - }, - "configurations": { - "production": { - "fileReplacements": [ - { - "replace": "projects/install-wizard/src/environments/environment.ts", - "with": "projects/install-wizard/src/environments/environment.prod.ts" - } - ], - "optimization": true, - "outputHashing": "all", - "sourceMap": false, - "namedChunks": false, - "aot": true, - "extractLicenses": true, - "budgets": [ - { - "type": "initial", - "maximumWarning": "2mb", - "maximumError": "5mb" - } - ] - }, - "ci": { - "progress": false - }, - "development": { - "optimization": false, - "extractLicenses": false, - "sourceMap": true, - "namedChunks": true - } - }, - "defaultConfiguration": "production" - }, - "serve": { - "builder": "@angular/build:dev-server", - "options": { - "buildTarget": "install-wizard:build", - "port": 8100 - }, - "configurations": { - "production": { - "buildTarget": "install-wizard:build:production" - }, - "development": { - "buildTarget": "install-wizard:build:development" - } - }, - "defaultConfiguration": "development" - }, - "extract-i18n": { - "builder": "@angular/build:extract-i18n", - "options": { - "buildTarget": "install-wizard:build" - } - }, - "lint": { - "builder": "@angular-eslint/builder:lint", - "options": { - "lintFilePatterns": [ - "projects/install-wizard/src/**/*.ts", - "projects/install-wizard/src/**/*.html" - ] - } - } - } - }, "setup-wizard": { "projectType": "application", "schematics": {}, diff --git a/web/lint-staged.config.js b/web/lint-staged.config.js index e351c9ce0..d7097bf6d 100644 --- a/web/lint-staged.config.js +++ b/web/lint-staged.config.js @@ -4,7 +4,6 @@ module.exports = { 'projects/ui/**/*.ts': () => 'npm run check:ui', 'projects/shared/**/*.ts': () => 'npm run check:shared', 'projects/marketplace/**/*.ts': () => 'npm run check:marketplace', - 'projects/install-wizard/**/*.ts': () => 'npm run check:install', 'projects/setup-wizard/**/*.ts': () => 'npm run check:setup', 'projects/start-tunnel/**/*.ts': () => 'npm run check:tunnel', } diff --git a/web/package.json b/web/package.json index d6fa9c02f..a72834737 100644 --- a/web/package.json +++ b/web/package.json @@ -6,26 +6,23 @@ "license": "MIT", "scripts": { "ng": "ng", - "check": "npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:install && npm run check:setup", + "check": "npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:setup", "check:shared": "tsc --project projects/shared/tsconfig.json --noEmit --skipLibCheck", "check:marketplace": "tsc --project projects/marketplace/tsconfig.json --noEmit --skipLibCheck", - "check:install": "tsc --project projects/install-wizard/tsconfig.json --noEmit --skipLibCheck", "check:setup": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck", "check:ui": "tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck", "check:tunnel": "tsc --project projects/start-tunnel/tsconfig.json --noEmit --skipLibCheck", "build:deps": "rimraf .angular/cache && (cd ../sdk && make bundle) && (cd ../patch-db/client && npm ci && npm run build)", - "build:install": "ng run install-wizard:build", "build:setup": "ng run setup-wizard:build", "build:ui": "ng run ui:build", "build:ui:dev": "ng run ui:build:development", "build:tunnel": "ng run start-tunnel:build", - "build:all": "npm run build:deps && npm run build:setup && npm run build:ui && npm run build:install", + "build:all": "npm run build:deps && npm run build:setup && npm run build:ui", "build:shared": "ng build shared", "build:marketplace": "npm run build:shared && ng build marketplace", "analyze:ui": "ng build ui --stats-json --named-chunks && npx -y @angular-experts/hawkeye dist/raw/ui/stats.json", "publish:shared": "npm run build:shared && npm publish ./dist/shared --access public", "publish:marketplace": "npm run build:marketplace && npm publish ./dist/marketplace --access public", - "start:install": "npm run-script build-config && ng serve --project install-wizard --host 0.0.0.0", "start:setup": "npm run-script build-config && ng serve --project setup-wizard --host 0.0.0.0", "start:ui": "npm run-script build-config && ng serve --project ui --host 0.0.0.0", "start:tunnel": "ng serve --project start-tunnel --host 0.0.0.0", diff --git a/web/projects/install-wizard/src/app/app.component.html b/web/projects/install-wizard/src/app/app.component.html deleted file mode 100644 index 3b06469f9..000000000 --- a/web/projects/install-wizard/src/app/app.component.html +++ /dev/null @@ -1,63 +0,0 @@ - -
- -
-
- @if (selected) { - - } -

{{ selected ? 'Install Type' : 'StartOS Install' }}

- @if (!selected) { -

Select Disk

- } -
{{ error }}
-
-
-
- @for (drive of disks$ | async; track $index) { - - } -
-
- @if (guid) { - - } - - -
-
-
-
-
diff --git a/web/projects/install-wizard/src/app/app.component.scss b/web/projects/install-wizard/src/app/app.component.scss deleted file mode 100644 index 67be9a2db..000000000 --- a/web/projects/install-wizard/src/app/app.component.scss +++ /dev/null @@ -1,71 +0,0 @@ -@use '@taiga-ui/core/styles/taiga-ui-local' as taiga; - -::ng-deep html, -::ng-deep body, -tui-root { - height: 100%; - margin: 0; - color: var(--tui-text-primary); -} - -main { - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - background: var(--tui-background-accent-opposite-hover); -} - -.logo { - width: 6rem; - margin-bottom: -2rem; - z-index: 1; -} - -.card { - max-width: min(30rem, 90vw); -} - -.header { - position: relative; - display: flex; - flex-direction: column; - text-align: center; - padding-top: 0.25rem; - margin-bottom: -2rem; - h2 { - margin-top: 0; - } -} - -.back { - position: absolute; - top: 1rem; -} - -.pages { - display: flex; - align-items: center; - overflow: hidden; -} - -.options { - @include taiga.transition(margin); - - min-width: 100%; - display: flex; - flex-direction: column; - gap: 1.25rem; - padding: 1rem; - box-sizing: border-box; - - &_selected { - margin-left: -100%; - } -} - -[tuiCell]:not(:last-of-type) { - box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal); -} - diff --git a/web/projects/install-wizard/src/app/app.component.ts b/web/projects/install-wizard/src/app/app.component.ts deleted file mode 100644 index 37f360b09..000000000 --- a/web/projects/install-wizard/src/app/app.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { TUI_CONFIRM } from '@taiga-ui/kit' -import { Component, inject } from '@angular/core' -import { DiskInfo, i18nKey, LoadingService, toGuid } from '@start9labs/shared' -import { TuiDialogService } from '@taiga-ui/core' -import { filter, from } from 'rxjs' -import { SUCCESS, toWarning } from 'src/app/app.utils' -import { ApiService } from 'src/app/services/api.service' - -@Component({ - selector: 'app-root', - templateUrl: 'app.component.html', - styleUrls: ['app.component.scss'], - standalone: false, -}) -export class AppComponent { - private readonly loader = inject(LoadingService) - private readonly api = inject(ApiService) - private readonly dialogs = inject(TuiDialogService) - - readonly disks$ = from(this.api.getDisks()) - selected: DiskInfo | null = null - error = '' - - get guid() { - return toGuid(this.selected) - } - - async install(overwrite = false) { - const loader = this.loader.open('Installing StartOS' as i18nKey).subscribe() - const logicalname = this.selected?.logicalname || '' - - try { - await this.api.install({ logicalname, overwrite }) - this.reboot() - } catch (e: any) { - this.error = e.message - } finally { - loader.unsubscribe() - } - } - - warn() { - this.dialogs - .open(TUI_CONFIRM, toWarning(this.selected)) - .pipe(filter(Boolean)) - .subscribe(() => { - this.install(true) - }) - } - - private async reboot() { - this.dialogs - .open('1. Remove the USB stick
2. Click "Reboot" below', SUCCESS) - .subscribe({ - complete: async () => { - const loader = this.loader.open().subscribe() - - try { - await this.api.reboot() - this.dialogs - .open( - window.location.host === 'localhost' - ? 'Please wait 1-2 minutes for your server to restart' - : 'Please wait 1-2 minutes, then refresh this page to access the StartOS setup wizard.', - { - label: 'Rebooting', - size: 's', - closeable: false, - dismissible: false, - }, - ) - .subscribe() - } catch (e: any) { - this.error = e.message - } finally { - loader.unsubscribe() - } - }, - }) - } -} diff --git a/web/projects/install-wizard/src/app/app.module.ts b/web/projects/install-wizard/src/app/app.module.ts deleted file mode 100644 index 8af5922e4..000000000 --- a/web/projects/install-wizard/src/app/app.module.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - provideHttpClient, - withFetch, - withInterceptorsFromDi, -} from '@angular/common/http' -import { NgModule } from '@angular/core' -import { BrowserAnimationsModule } from '@angular/platform-browser/animations' -import { - DriveComponent, - i18nPipe, - RELATIVE_URL, - WorkspaceConfig, -} from '@start9labs/shared' -import { - TuiButton, - TuiIcon, - TuiRoot, - TuiSurface, - TuiTitle, -} from '@taiga-ui/core' -import { NG_EVENT_PLUGINS } from '@taiga-ui/event-plugins' -import { TuiCardLarge, TuiCell } from '@taiga-ui/layout' -import { ApiService } from 'src/app/services/api.service' -import { LiveApiService } from 'src/app/services/live-api.service' -import { MockApiService } from 'src/app/services/mock-api.service' -import { AppComponent } from './app.component' - -const { - useMocks, - ui: { api }, -} = require('../../../../config.json') as WorkspaceConfig - -@NgModule({ - declarations: [AppComponent], - imports: [ - BrowserAnimationsModule, - TuiRoot, - DriveComponent, - TuiButton, - TuiCardLarge, - TuiCell, - TuiIcon, - TuiSurface, - TuiTitle, - i18nPipe, - ], - providers: [ - NG_EVENT_PLUGINS, - { - provide: ApiService, - useClass: useMocks ? MockApiService : LiveApiService, - }, - { - provide: RELATIVE_URL, - useValue: `/${api.url}/${api.version}`, - }, - provideHttpClient(withInterceptorsFromDi(), withFetch()), - ], - bootstrap: [AppComponent], -}) -export class AppModule {} diff --git a/web/projects/install-wizard/src/app/app.utils.ts b/web/projects/install-wizard/src/app/app.utils.ts deleted file mode 100644 index a8e66e816..000000000 --- a/web/projects/install-wizard/src/app/app.utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DiskInfo } from '@start9labs/shared' -import { TuiDialogOptions } from '@taiga-ui/core' -import { TuiConfirmData } from '@taiga-ui/kit' - -export const SUCCESS: Partial> = { - label: 'Install Success!', - closeable: false, - size: 's', - data: { button: 'Reboot' }, -} - -export function toWarning( - disk: DiskInfo | null, -): Partial> { - return { - label: 'Warning', - size: 's', - data: { - content: `This action will COMPLETELY erase the disk ${ - disk?.vendor || 'Unknown Vendor' - } - ${disk?.model || 'Unknown Model'} and install StartOS in its place`, - yes: 'Continue', - no: 'Cancel', - }, - } -} diff --git a/web/projects/install-wizard/src/app/services/api.service.ts b/web/projects/install-wizard/src/app/services/api.service.ts deleted file mode 100644 index 2114ef2f4..000000000 --- a/web/projects/install-wizard/src/app/services/api.service.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { DiskInfo } from '@start9labs/shared' - -export abstract class ApiService { - abstract getDisks(): Promise // install.disk.list - abstract install(params: InstallReq): Promise // install.execute - abstract reboot(): Promise // install.reboot -} - -export type GetDisksRes = DiskInfo[] - -export type InstallReq = { - logicalname: string - overwrite: boolean -} diff --git a/web/projects/install-wizard/src/app/services/live-api.service.ts b/web/projects/install-wizard/src/app/services/live-api.service.ts deleted file mode 100644 index dc9b92488..000000000 --- a/web/projects/install-wizard/src/app/services/live-api.service.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Injectable } from '@angular/core' -import { - HttpService, - isRpcError, - RpcError, - RPCOptions, -} from '@start9labs/shared' -import { ApiService, GetDisksRes, InstallReq } from './api.service' - -@Injectable() -export class LiveApiService implements ApiService { - constructor(private readonly http: HttpService) {} - - async getDisks(): Promise { - return this.rpcRequest({ - method: 'install.disk.list', - params: {}, - }) - } - - async install(params: InstallReq): Promise { - return this.rpcRequest({ - method: 'install.execute', - params, - }) - } - - async reboot(): Promise { - return this.rpcRequest({ - method: 'install.reboot', - params: {}, - }) - } - - private async rpcRequest(opts: RPCOptions): Promise { - const res = await this.http.rpcRequest(opts) - - const rpcRes = res.body - - if (isRpcError(rpcRes)) { - throw new RpcError(rpcRes.error) - } - - return rpcRes.result - } -} diff --git a/web/projects/install-wizard/src/app/services/mock-api.service.ts b/web/projects/install-wizard/src/app/services/mock-api.service.ts deleted file mode 100644 index 6b94f18db..000000000 --- a/web/projects/install-wizard/src/app/services/mock-api.service.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Injectable } from '@angular/core' -import { pauseFor } from '@start9labs/shared' -import { ApiService, GetDisksRes, InstallReq } from './api.service' - -@Injectable() -export class MockApiService implements ApiService { - async getDisks(): Promise { - await pauseFor(500) - return [ - { - logicalname: 'abcd', - vendor: 'Samsung', - model: 'T5', - partitions: [ - { - logicalname: 'pabcd', - label: null, - capacity: 73264762332, - used: null, - startOs: { - '1234-5678-9876-5432': { - hostname: 'adjective-noun', - timestamp: new Date().toISOString(), - version: '0.2.17', - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, - }, - }, - guid: null, - }, - ], - capacity: 123456789123, - guid: 'uuid-uuid-uuid-uuid', - }, - { - logicalname: 'dcba', - vendor: 'Crucial', - model: 'MX500', - partitions: [ - { - logicalname: 'pbcba', - label: null, - capacity: 73264762332, - used: null, - startOs: { - '1234-5678-9876-5432': { - hostname: 'adjective-noun', - timestamp: new Date().toISOString(), - version: '0.2.17', - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, - }, - }, - guid: null, - }, - ], - capacity: 124456789123, - guid: null, - }, - { - logicalname: 'wxyz', - vendor: 'SanDisk', - model: 'Specialness', - partitions: [ - { - logicalname: 'pbcba', - label: null, - capacity: 73264762332, - used: null, - startOs: { - '1234-5678-9876-5432': { - hostname: 'adjective-noun', - timestamp: new Date().toISOString(), - version: '0.2.17', - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, - }, - }, - guid: 'guid-guid-guid-guid', - }, - ], - capacity: 123459789123, - guid: null, - }, - ] - } - - async install(params: InstallReq): Promise { - await pauseFor(1000) - } - - async reboot(): Promise { - await pauseFor(1000) - } -} diff --git a/web/projects/install-wizard/src/environments/environment.prod.ts b/web/projects/install-wizard/src/environments/environment.prod.ts deleted file mode 100644 index 970e25bd7..000000000 --- a/web/projects/install-wizard/src/environments/environment.prod.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const environment = { - production: true, -} diff --git a/web/projects/install-wizard/src/environments/environment.ts b/web/projects/install-wizard/src/environments/environment.ts deleted file mode 100644 index 5c68c17ab..000000000 --- a/web/projects/install-wizard/src/environments/environment.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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. diff --git a/web/projects/install-wizard/src/index.html b/web/projects/install-wizard/src/index.html deleted file mode 100644 index 8e7a2fd52..000000000 --- a/web/projects/install-wizard/src/index.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - StartOS Install Wizard - - - - - - - - - - - - - - - - - diff --git a/web/projects/install-wizard/src/main.ts b/web/projects/install-wizard/src/main.ts deleted file mode 100644 index 21499c3cd..000000000 --- a/web/projects/install-wizard/src/main.ts +++ /dev/null @@ -1,12 +0,0 @@ -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)) diff --git a/web/projects/install-wizard/tsconfig.json b/web/projects/install-wizard/tsconfig.json deleted file mode 100644 index c1643dea3..000000000 --- a/web/projects/install-wizard/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -/* To learn more about this file see: https://angular.io/config/tsconfig. */ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "baseUrl": "./" - }, - "files": ["src/main.ts"], - "include": ["src/**/*.d.ts"] -} diff --git a/web/projects/setup-wizard/src/app/app.component.ts b/web/projects/setup-wizard/src/app/app.component.ts index bb05f5154..847a6d584 100644 --- a/web/projects/setup-wizard/src/app/app.component.ts +++ b/web/projects/setup-wizard/src/app/app.component.ts @@ -1,7 +1,7 @@ import { Component, inject, DOCUMENT } from '@angular/core' import { Router } from '@angular/router' import { ErrorService } from '@start9labs/shared' -import { ApiService } from 'src/app/services/api.service' +import { ApiService } from './services/api.service' import { StateService } from './services/state.service' @Component({ @@ -18,19 +18,41 @@ export class AppComponent { async ngOnInit() { try { + // Determine if we're in kiosk mode this.stateService.kiosk = ['localhost', '127.0.0.1'].includes( this.document.location.hostname, ) - const inProgress = await this.api.getStatus() + // Get pubkey for encryption + await this.api.getPubKey() - let route = 'home' + // Check setup status to determine initial route + const status = await this.api.getStatus() - if (inProgress) { - route = inProgress.status === 'complete' ? '/success' : '/loading' + switch (status.status) { + case 'needs-install': + // Start the install flow + await this.router.navigate(['/language']) + break + + case 'incomplete': + // Store the data drive info from status + this.stateService.dataDriveGuid = status.guid + this.stateService.attach = status.attach + + await this.router.navigate(['/language']) + break + + case 'running': + // Setup is in progress, show loading page + await this.router.navigate(['/loading']) + break + + case 'complete': + // Setup execution finished, show success page + await this.router.navigate(['/success']) + break } - - await this.router.navigate([route]) } catch (e: any) { this.errorService.handleError(e) } diff --git a/web/projects/setup-wizard/src/app/app.module.ts b/web/projects/setup-wizard/src/app/app.module.ts index c94c21700..effce9f2a 100644 --- a/web/projects/setup-wizard/src/app/app.module.ts +++ b/web/projects/setup-wizard/src/app/app.module.ts @@ -16,9 +16,9 @@ import { } from '@start9labs/shared' import { tuiButtonOptionsProvider, TuiRoot } from '@taiga-ui/core' import { NG_EVENT_PLUGINS } from '@taiga-ui/event-plugins' -import { ApiService } from 'src/app/services/api.service' -import { LiveApiService } from 'src/app/services/live-api.service' -import { MockApiService } from 'src/app/services/mock-api.service' +import { ApiService } from './services/api.service' +import { LiveApiService } from './services/live-api.service' +import { MockApiService } from './services/mock-api.service' import { AppComponent } from './app.component' import { ROUTES } from './app.routes' diff --git a/web/projects/setup-wizard/src/app/app.routes.ts b/web/projects/setup-wizard/src/app/app.routes.ts index 388ebc740..0219eadb3 100644 --- a/web/projects/setup-wizard/src/app/app.routes.ts +++ b/web/projects/setup-wizard/src/app/app.routes.ts @@ -1,33 +1,48 @@ import { Routes } from '@angular/router' export const ROUTES: Routes = [ - { path: '', redirectTo: '/home', pathMatch: 'full' }, + // Entry point - app.component handles initial routing based on setup.status + { path: '', redirectTo: '/language', pathMatch: 'full' }, + + // Install flow + { + path: 'language', + loadComponent: () => import('./pages/language.page'), + }, + { + path: 'keyboard', + loadComponent: () => import('./pages/keyboard.page'), + }, + { + path: 'drives', + loadComponent: () => import('./pages/drives.page'), + }, + + // Setup flow (after install or for pre-installed devices) { path: 'home', - loadComponent: () => import('src/app/pages/home.page'), + loadComponent: () => import('./pages/home.page'), }, { - path: 'attach', - loadComponent: () => import('src/app/pages/attach.page'), - }, - { - path: 'recover', - loadComponent: () => import('src/app/pages/recover.page'), + path: 'restore', + loadComponent: () => import('./pages/restore.page'), }, { path: 'transfer', - loadComponent: () => import('src/app/pages/transfer.page'), + loadComponent: () => import('./pages/transfer.page'), }, { - path: 'storage', - loadComponent: () => import('src/app/pages/storage.page'), + path: 'password', + loadComponent: () => import('./pages/password.page'), }, + + // Shared { path: 'loading', - loadComponent: () => import('src/app/pages/loading.page'), + loadComponent: () => import('./pages/loading.page'), }, { path: 'success', - loadComponent: () => import('src/app/pages/success.page'), + loadComponent: () => import('./pages/success.page'), }, ] diff --git a/web/projects/setup-wizard/src/app/components/cifs.component.ts b/web/projects/setup-wizard/src/app/components/cifs.component.ts index e59d6052a..e1902ca47 100644 --- a/web/projects/setup-wizard/src/app/components/cifs.component.ts +++ b/web/projects/setup-wizard/src/app/components/cifs.component.ts @@ -3,39 +3,38 @@ import { Component, inject } from '@angular/core' import { FormControl, FormGroup, - FormsModule, ReactiveFormsModule, Validators, } from '@angular/forms' -import { i18nKey, LoadingService, StartOSDiskInfo } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' import { TuiButton, TuiDialogContext, TuiDialogService, TuiError, + TuiIcon, TuiTextfield, } from '@taiga-ui/core' import { TUI_VALIDATION_ERRORS, + TuiButtonLoading, TuiFieldErrorPipe, TuiPassword, } from '@taiga-ui/kit' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' -import { SERVERS, ServersResponse } from 'src/app/components/servers.component' -import { ApiService } from 'src/app/services/api.service' +import { ApiService } from '../services/api.service' +import { StartOSDiskInfoWithId } from '../types' -export interface CifsResponse { +export interface CifsResult { cifs: T.Cifs - serverId: string - password: string + servers: StartOSDiskInfoWithId[] } @Component({ template: `
- + - + - + Cancel - + `, @@ -97,18 +99,19 @@ export interface CifsResponse { footer { display: flex; gap: 1rem; - margin-top: 1rem; + margin-top: 1.5rem; } `, imports: [ CommonModule, - FormsModule, ReactiveFormsModule, TuiButton, + TuiButtonLoading, TuiTextfield, TuiPassword, TuiError, TuiFieldErrorPipe, + TuiIcon, ], providers: [ { @@ -122,8 +125,9 @@ export interface CifsResponse { export class CifsComponent { private readonly dialogs = inject(TuiDialogService) private readonly api = inject(ApiService) - private readonly loader = inject(LoadingService) - private readonly context = injectContext>() + private readonly context = injectContext>() + + connecting = false readonly form = new FormGroup({ hostname: new FormControl('', { @@ -149,9 +153,7 @@ export class CifsComponent { } async submit(): Promise { - const loader = this.loader - .open('Connecting to shared folder' as i18nKey) - .subscribe() + this.connecting = true try { const diskInfo = await this.api.verifyCifs({ @@ -161,36 +163,25 @@ export class CifsComponent { : null, }) - loader.unsubscribe() + const servers = Object.keys(diskInfo).map(id => ({ + id, + ...diskInfo[id]!, + })) - this.selectServer(diskInfo) + this.context.completeWith({ + cifs: { ...this.form.getRawValue() }, + servers, + }) } catch (e) { - loader.unsubscribe() + this.connecting = false this.onFail() } } - private selectServer(servers: Record) { - this.dialogs - .open(SERVERS, { - label: 'Select Server to Restore', - data: { - servers: Object.keys(servers).map(id => ({ id, ...servers[id] })), - }, - }) - .subscribe(({ password, serverId }) => { - this.context.completeWith({ - cifs: { ...this.form.getRawValue() }, - serverId, - password, - }) - }) - } - private onFail() { this.dialogs .open( - '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.', + 'Unable to connect to network folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.', { label: 'Connection Failed', size: 's', diff --git a/web/projects/setup-wizard/src/app/components/password.component.ts b/web/projects/setup-wizard/src/app/components/password.component.ts deleted file mode 100644 index f31f8e2d7..000000000 --- a/web/projects/setup-wizard/src/app/components/password.component.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { AsyncPipe } from '@angular/common' -import { Component, inject } from '@angular/core' -import { - AbstractControl, - FormControl, - FormGroup, - ReactiveFormsModule, - Validators, -} from '@angular/forms' -import { verify } from '@start9labs/argon2' -import { ErrorService } from '@start9labs/shared' -import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk' -import { - TuiButton, - TuiDialogContext, - TuiError, - TuiIcon, - TuiTextfield, -} from '@taiga-ui/core' -import { - TuiFieldErrorPipe, - TuiPassword, - tuiValidationErrorsProvider, -} from '@taiga-ui/kit' -import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' - -interface DialogData { - passwordHash?: string - storageDrive?: boolean -} - -@Component({ - template: ` - @if (storageDrive) { - Choose a password for your server. - Make it good. Write it down. - } @else { - Enter the password that was used to encrypt this drive. - } - -
- - - - - - - @if (storageDrive) { - - - - - - - } -
- - -
- - `, - styles: ` - footer { - display: flex; - gap: 1rem; - margin-top: 1rem; - justify-content: flex-end; - } - `, - imports: [ - AsyncPipe, - ReactiveFormsModule, - TuiButton, - TuiError, - TuiAutoFocus, - TuiFieldErrorPipe, - TuiTextfield, - TuiPassword, - TuiValidator, - TuiIcon, - TuiMapperPipe, - ], - providers: [ - tuiValidationErrorsProvider({ - required: 'Required', - minlength: 'Must be 12 characters or greater', - }), - ], -}) -export class PasswordComponent { - private readonly errorService = inject(ErrorService) - private readonly context = - injectContext>() - - readonly storageDrive = this.context.data.storageDrive - readonly form = new FormGroup({ - password: new FormControl('', [ - Validators.required, - Validators.minLength(12), - ]), - confirm: new FormControl('', this.storageDrive ? Validators.required : []), - }) - - readonly validator = (value: any) => (control: AbstractControl) => - value === control.value ? null : { match: 'Passwords do not match' } - - submit() { - const password = this.form.controls.password.value || '' - - if (this.storageDrive) { - this.context.completeWith(password) - - return - } - - try { - verify(this.context.data.passwordHash || '', password) - this.context.completeWith(password) - } catch (e) { - this.errorService.handleError('Incorrect password provided') - } - } - - cancel() { - this.context.$implicit.complete() - } -} - -export const PASSWORD = new PolymorpheusComponent(PasswordComponent) diff --git a/web/projects/setup-wizard/src/app/components/password.directive.ts b/web/projects/setup-wizard/src/app/components/password.directive.ts deleted file mode 100644 index 979a8b99e..000000000 --- a/web/projects/setup-wizard/src/app/components/password.directive.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Directive, ElementRef, inject, input, Output } from '@angular/core' -import { StartOSDiskInfo } from '@start9labs/shared' -import { TuiDialogService } from '@taiga-ui/core' -import { filter, fromEvent, switchMap } from 'rxjs' -import { PASSWORD } from 'src/app/components/password.component' - -@Directive({ - selector: 'button[server][password]', -}) -export class PasswordDirective { - private readonly dialogs = inject(TuiDialogService) - - readonly server = input.required() - - @Output() - readonly password = fromEvent(inject(ElementRef).nativeElement, 'click').pipe( - switchMap(() => - this.dialogs.open(PASSWORD, { - label: 'Unlock Drive', - size: 's', - data: { passwordHash: this.server().passwordHash }, - }), - ), - filter(Boolean), - ) -} diff --git a/web/projects/setup-wizard/src/app/components/preserve-overwrite.dialog.ts b/web/projects/setup-wizard/src/app/components/preserve-overwrite.dialog.ts new file mode 100644 index 000000000..179877dfe --- /dev/null +++ b/web/projects/setup-wizard/src/app/components/preserve-overwrite.dialog.ts @@ -0,0 +1,57 @@ +import { Component, inject } from '@angular/core' +import { TuiButton } from '@taiga-ui/core' +import { TuiDialogContext } from '@taiga-ui/core' +import { injectContext } from '@taiga-ui/polymorpheus' + +@Component({ + standalone: true, + imports: [TuiButton], + template: ` +

This drive contains existing StartOS data.

+
    +
  • + Preserve + to keep your data. +
  • +
  • + Overwrite + to discard +
  • +
+
+ + +
+ `, + styles: ` + p { + margin: 0 0 0.75rem; + } + + footer { + display: flex; + margin-top: 2rem; + gap: 0.5rem; + flex-direction: column-reverse; + } + + .preserve-btn { + background: var(--tui-status-positive) !important; + } + `, +}) +export class PreserveOverwriteDialog { + protected readonly context = injectContext>() +} diff --git a/web/projects/setup-wizard/src/app/components/recover.component.ts b/web/projects/setup-wizard/src/app/components/recover.component.ts deleted file mode 100644 index cf53b9c97..000000000 --- a/web/projects/setup-wizard/src/app/components/recover.component.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { TuiCell } from '@taiga-ui/layout' -import { TuiIcon, TuiTitle } from '@taiga-ui/core' -import { Component, Input } from '@angular/core' -import { RouterModule } from '@angular/router' - -@Component({ - selector: 'app-recover', - template: ` - - - - Use Existing Drive - - Attach an existing StartOS data drive (not a backup) - - - - - - - Transfer - - Transfer data from an existing StartOS data drive (not a backup) to a - new, preferred drive - - - - - - - Restore From Backup (Disaster Recovery) - Restore StartOS data from an encrypted backup - - - `, - imports: [RouterModule, TuiIcon, TuiCell, TuiTitle], -}) -export class RecoverComponent { - @Input() disabled = false -} diff --git a/web/projects/setup-wizard/src/app/components/select-network-backup.dialog.ts b/web/projects/setup-wizard/src/app/components/select-network-backup.dialog.ts new file mode 100644 index 000000000..b8d173e9f --- /dev/null +++ b/web/projects/setup-wizard/src/app/components/select-network-backup.dialog.ts @@ -0,0 +1,71 @@ +import { Component, inject } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { TuiDialogContext, TuiTextfield } from '@taiga-ui/core' +import { TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit' +import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { StartOSDiskInfoWithId } from '../types' + +interface Data { + servers: StartOSDiskInfoWithId[] +} + +@Component({ + standalone: true, + imports: [FormsModule, TuiTextfield, TuiSelect, TuiDataListWrapper], + template: ` +

Multiple backups found. Select which one to restore.

+ + + + + + + +
+ {{ server.id }} + + {{ server['eos-version'] }} +
+
+ `, + styles: ` + .server-item { + display: flex; + flex-direction: column; + + small { + opacity: 0.7; + } + } + `, +}) +export class SelectNetworkBackupDialog { + protected readonly context = + injectContext>() + + private _selectedServer: StartOSDiskInfoWithId | null = null + + get selectedServer(): StartOSDiskInfoWithId | null { + return this._selectedServer + } + + set selectedServer(value: StartOSDiskInfoWithId | null) { + this._selectedServer = value + + if (value) { + this.context.completeWith(value) + } + } + + readonly stringify = (server: StartOSDiskInfoWithId | null) => + server ? server.id : '' +} + +export const SELECT_NETWORK_BACKUP = new PolymorpheusComponent( + SelectNetworkBackupDialog, +) diff --git a/web/projects/setup-wizard/src/app/components/servers.component.ts b/web/projects/setup-wizard/src/app/components/servers.component.ts deleted file mode 100644 index 1dc37f5fa..000000000 --- a/web/projects/setup-wizard/src/app/components/servers.component.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Component } from '@angular/core' -import { ServerComponent } from '@start9labs/shared' -import { TuiDialogContext } from '@taiga-ui/core' -import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' -import { PasswordDirective } from 'src/app/components/password.directive' -import { StartOSDiskInfoWithId } from 'src/app/services/api.service' - -interface Data { - servers: StartOSDiskInfoWithId[] -} - -export interface ServersResponse { - password: string - serverId: string -} - -@Component({ - template: ` - @for (server of context.data.servers; track $index) { - - } - `, - imports: [ServerComponent, PasswordDirective], -}) -export class ServersComponent { - readonly context = injectContext>() - - select(password: string, serverId: string) { - this.context.completeWith({ serverId, password }) - } -} - -export const SERVERS = new PolymorpheusComponent(ServersComponent) diff --git a/web/projects/setup-wizard/src/app/components/unlock-password.dialog.ts b/web/projects/setup-wizard/src/app/components/unlock-password.dialog.ts new file mode 100644 index 000000000..dcf68a30b --- /dev/null +++ b/web/projects/setup-wizard/src/app/components/unlock-password.dialog.ts @@ -0,0 +1,55 @@ +import { Component } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { + TuiButton, + TuiDialogContext, + TuiIcon, + TuiTextfield, +} from '@taiga-ui/core' +import { TuiPassword } from '@taiga-ui/kit' +import { injectContext } from '@taiga-ui/polymorpheus' + +@Component({ + standalone: true, + imports: [FormsModule, TuiButton, TuiTextfield, TuiPassword, TuiIcon], + template: ` +

Enter the password that was used to encrypt this backup.

+ + + + + +
+ + +
+ `, + styles: ` + footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1.5rem; + } + `, +}) +export class UnlockPasswordDialog { + protected readonly context = injectContext>() + + password = '' + + unlock() { + if (this.password) { + this.context.completeWith(this.password) + } + } +} diff --git a/web/projects/setup-wizard/src/app/pages/attach.page.ts b/web/projects/setup-wizard/src/app/pages/attach.page.ts deleted file mode 100644 index c906e8347..000000000 --- a/web/projects/setup-wizard/src/app/pages/attach.page.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Component, inject } from '@angular/core' -import { Router } from '@angular/router' -import { - DiskInfo, - DriveComponent, - ErrorService, - i18nKey, - LoadingService, - toGuid, -} from '@start9labs/shared' -import { TuiButton, TuiDialogService, TuiLoader } from '@taiga-ui/core' -import { TuiCardLarge, TuiCell } from '@taiga-ui/layout' -import { PASSWORD } from 'src/app/components/password.component' -import { ApiService } from 'src/app/services/api.service' -import { StateService } from 'src/app/services/state.service' - -@Component({ - template: ` -
-
Use existing drive
-
Select the physical drive containing your StartOS data
- - @if (loading) { - - } @else { - @for (drive of drives; track drive) { - - } @empty { - 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. - } -
- -
- } -
- `, - imports: [TuiButton, TuiCardLarge, TuiCell, TuiLoader, DriveComponent], -}) -export default class AttachPage { - private readonly apiService = inject(ApiService) - private readonly router = inject(Router) - private readonly errorService = inject(ErrorService) - private readonly stateService = inject(StateService) - private readonly dialogs = inject(TuiDialogService) - private readonly loader = inject(LoadingService) - - loading = true - drives: DiskInfo[] = [] - - 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() - .then(drives => drives.filter(toGuid)) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - this.loading = false - } - } - - select(disk: DiskInfo) { - this.dialogs - .open(PASSWORD, { - label: 'Set Password', - size: 's', - data: { storageDrive: true }, - }) - .subscribe(password => { - this.attachDrive(toGuid(disk) || '', password) - }) - } - - private async attachDrive(guid: string, password: string) { - const loader = this.loader - .open('Connecting to drive' as i18nKey) - .subscribe() - - try { - await this.stateService.importDrive(guid, password) - await this.router.navigate([`loading`]) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - } -} diff --git a/web/projects/setup-wizard/src/app/pages/drives.page.ts b/web/projects/setup-wizard/src/app/pages/drives.page.ts new file mode 100644 index 000000000..7ef9360e6 --- /dev/null +++ b/web/projects/setup-wizard/src/app/pages/drives.page.ts @@ -0,0 +1,355 @@ +import { ChangeDetectorRef, Component, inject } from '@angular/core' +import { Router } from '@angular/router' +import { FormsModule } from '@angular/forms' +import { + DiskInfo, + ErrorService, + LoadingService, + toGuid, +} from '@start9labs/shared' +import { TUI_IS_MOBILE } from '@taiga-ui/cdk' +import { + TuiButton, + TuiDialogService, + TuiIcon, + TuiLoader, + TuiTextfield, + TuiTitle, +} from '@taiga-ui/core' +import { + TUI_CONFIRM, + TuiDataListWrapper, + TuiSelect, + TuiTooltip, +} from '@taiga-ui/kit' +import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' +import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { filter } from 'rxjs' +import { ApiService } from '../services/api.service' +import { StateService } from '../services/state.service' +import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog' + +const OS_DRIVE_TOOLTIP = + 'The drive where the StartOS operating system will be installed.' +const DATA_DRIVE_TOOLTIP = + 'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.' + +@Component({ + template: ` +
+
+

Select Drives

+
+ + @if (loading) { + + } @else if (drives.length === 0) { +

+ No drives found. Please connect a drive and click Refresh. +

+ } @else { + + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + + + + + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + @if (preserveData === true) { + + } + @if (preserveData === false) { + + } + + + + +
+ + {{ drive.vendor || 'Unknown' }} {{ drive.model || 'Drive' }} + + + {{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }} + +
+
+ } + +
+ @if (drives.length === 0) { + + } @else { + + } +
+
+ `, + styles: ` + .no-drives { + text-align: center; + color: var(--tui-text-secondary); + padding: 2rem; + } + + .drive-item { + display: flex; + flex-direction: column; + + small { + opacity: 0.7; + } + } + `, + imports: [ + FormsModule, + TuiCardLarge, + TuiButton, + TuiIcon, + TuiLoader, + TuiTextfield, + TuiSelect, + TuiDataListWrapper, + TuiTooltip, + TuiHeader, + TuiTitle, + ], +}) +export default class DrivesPage { + private readonly api = inject(ApiService) + private readonly router = inject(Router) + private readonly dialogs = inject(TuiDialogService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly stateService = inject(StateService) + private readonly cdr = inject(ChangeDetectorRef) + + protected readonly mobile = inject(TUI_IS_MOBILE) + + readonly osDriveTooltip = OS_DRIVE_TOOLTIP + readonly dataDriveTooltip = DATA_DRIVE_TOOLTIP + + drives: DiskInfo[] = [] + loading = true + selectedOsDrive: DiskInfo | null = null + selectedDataDrive: DiskInfo | null = null + preserveData: boolean | null = null + + readonly stringify = (drive: DiskInfo | null) => + drive ? `${drive.vendor || 'Unknown'} ${drive.model || 'Drive'}` : '' + + formatCapacity(bytes: number): string { + const gb = bytes / 1e9 + if (gb >= 1000) { + return `${(gb / 1000).toFixed(1)} TB` + } + return `${gb.toFixed(0)} GB` + } + + async ngOnInit() { + await this.loadDrives() + } + + async refresh() { + this.loading = true + this.selectedOsDrive = null + this.selectedDataDrive = null + this.preserveData = null + await this.loadDrives() + } + + onDataDriveChange(drive: DiskInfo | null) { + this.preserveData = null + + if (!drive) { + return + } + + const hasStartOSData = !!toGuid(drive) + if (hasStartOSData) { + this.showPreserveOverwriteDialog() + } + } + + continue() { + if (!this.selectedOsDrive || !this.selectedDataDrive) return + + const sameDevice = + this.selectedOsDrive.logicalname === this.selectedDataDrive.logicalname + const dataHasStartOS = !!toGuid(this.selectedDataDrive) + + // Scenario 1: Same drive, has StartOS data, preserving → no warning + if (sameDevice && dataHasStartOS && this.preserveData) { + this.installOs(false) + return + } + + // Scenario 2: Different drives, preserving data → warn OS only + if (!sameDevice && this.preserveData) { + this.showOsDriveWarning() + return + } + + // Scenario 3: All other cases → warn about overwriting + this.showFullWarning(sameDevice) + } + + private showPreserveOverwriteDialog() { + let selectionMade = false + + this.dialogs + .open(new PolymorpheusComponent(PreserveOverwriteDialog), { + label: 'StartOS Data Detected', + size: 's', + dismissible: true, + closeable: true, + }) + .subscribe({ + next: preserve => { + selectionMade = true + this.preserveData = preserve + this.cdr.markForCheck() + }, + complete: () => { + if (!selectionMade) { + // Dialog was dismissed without selection - clear the data drive + this.selectedDataDrive = null + this.preserveData = null + this.cdr.markForCheck() + } + }, + }) + } + + private showOsDriveWarning() { + this.dialogs + .open(TUI_CONFIRM, { + label: 'Warning', + size: 's', + data: { + content: `
    +
  • Data on the OS drive may be overwritten.
  • +
  • your StartOS data on the data drive will be preserved.
  • +
`, + yes: 'Continue', + no: 'Cancel', + }, + }) + .pipe(filter(Boolean)) + .subscribe(() => { + this.installOs(false) + }) + } + + private showFullWarning(sameDevice: boolean) { + const message = sameDevice + ? `

Data on this drive will be overwritten.

` + : `

Data on both drives will be overwritten.

` + + this.dialogs + .open(TUI_CONFIRM, { + label: 'Warning', + size: 's', + data: { + content: message, + yes: 'Continue', + no: 'Cancel', + }, + }) + .pipe(filter(Boolean)) + .subscribe(() => { + this.installOs(true) + }) + } + + private async installOs(wipe: boolean) { + const loader = this.loader.open('Installing StartOS...').subscribe() + + try { + const result = await this.api.installOs({ + osDrive: this.selectedOsDrive!.logicalname, + dataDrive: { + logicalname: this.selectedDataDrive!.logicalname, + wipe, + }, + }) + + this.stateService.dataDriveGuid = result.guid + this.stateService.attach = result.attach + + if (result.attach) { + this.stateService.setupType = 'attach' + await this.router.navigate(['/password']) + } else { + await this.router.navigate(['/home']) + } + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } + + private async loadDrives() { + try { + this.drives = await this.api.getDisks() + } catch (e: any) { + this.errorService.handleError(e) + } finally { + this.loading = false + } + } +} diff --git a/web/projects/setup-wizard/src/app/pages/home.page.ts b/web/projects/setup-wizard/src/app/pages/home.page.ts index 3312402c2..d88845c4a 100644 --- a/web/projects/setup-wizard/src/app/pages/home.page.ts +++ b/web/projects/setup-wizard/src/app/pages/home.page.ts @@ -1,134 +1,70 @@ -import { Component, inject, OnInit } from '@angular/core' -import { RouterModule } from '@angular/router' -import { ErrorService } from '@start9labs/shared' -import { TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core' -import { TuiCardLarge, TuiCell } from '@taiga-ui/layout' -import { RecoverComponent } from 'src/app/components/recover.component' -import { ApiService } from 'src/app/services/api.service' -import { StateService } from 'src/app/services/state.service' +import { Component, inject } from '@angular/core' +import { Router } from '@angular/router' +import { TuiAppearance, TuiTitle } from '@taiga-ui/core' +import { TuiAvatar } from '@taiga-ui/kit' +import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout' +import { StateService } from '../services/state.service' @Component({ template: ` - - @if (!loading) { -
-
- @if (recover) { - - } - {{ recover ? 'Recover Options' : 'StartOS Setup' }} -
-
-
- - -
- +
+
+

Select Setup Flow

+
+ +
- } - `, - styles: ` - @use '@taiga-ui/core/styles/taiga-ui-local' as taiga; + - .logo { - width: 6rem; - margin: auto auto -2rem; - z-index: 1; + - &:only-child { - margin: auto; - } - - + * { - margin-top: 0; - } - } - - .back { - position: absolute; - top: 1rem; - border-radius: 10rem; - } - - .pages { - display: flex; - align-items: center; - overflow: hidden; - } - - .options { - @include taiga.transition(margin); - - min-width: 100%; - display: flex; - flex-direction: column; - gap: 1.25rem; - padding: 1rem; - box-sizing: border-box; - - &_recover { - margin-left: -100%; - } - } + + `, imports: [ - RouterModule, + TuiAppearance, TuiCardLarge, - TuiButton, + TuiHeader, TuiCell, - TuiIcon, TuiTitle, - RecoverComponent, + TuiAvatar, ], }) -export default class HomePage implements OnInit { - private readonly api = inject(ApiService) - private readonly errorService = inject(ErrorService) +export default class HomePage { + private readonly router = inject(Router) private readonly stateService = inject(StateService) - error = false - loading = true - recover = false - - async ngOnInit() { + async startFresh() { this.stateService.setupType = 'fresh' + this.stateService.recoverySource = undefined + await this.router.navigate(['/password']) + } - try { - await this.api.getPubKey() - } catch (e: any) { - this.error = true - this.errorService.handleError(e) - } finally { - this.loading = false - } + async restore() { + this.stateService.setupType = 'restore' + await this.router.navigate(['/restore']) + } + + async transfer() { + this.stateService.setupType = 'transfer' + await this.router.navigate(['/transfer']) } } diff --git a/web/projects/setup-wizard/src/app/pages/keyboard.page.ts b/web/projects/setup-wizard/src/app/pages/keyboard.page.ts new file mode 100644 index 000000000..0bdb9cdfe --- /dev/null +++ b/web/projects/setup-wizard/src/app/pages/keyboard.page.ts @@ -0,0 +1,105 @@ +import { Component, inject } from '@angular/core' +import { Router } from '@angular/router' +import { FormsModule } from '@angular/forms' +import { TUI_IS_MOBILE } from '@taiga-ui/cdk' +import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core' +import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit' +import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' +import { StateService } from '../services/state.service' +import { Keyboard, getKeyboardsForLanguage } from '../utils/languages' + +@Component({ + template: ` +
+
+

Select Keyboard Layout

+
+ + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + + +
+ +
+
+ `, + styles: ` + :host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100%; + } + + footer { + display: flex; + justify-content: flex-end; + margin-top: 1.5rem; + } + `, + imports: [ + FormsModule, + TuiCardLarge, + TuiButton, + TuiTextfield, + TuiChevron, + TuiSelect, + TuiDataListWrapper, + TuiHeader, + TuiTitle, + ], +}) +export default class KeyboardPage { + private readonly router = inject(Router) + private readonly stateService = inject(StateService) + + protected readonly mobile = inject(TUI_IS_MOBILE) + readonly keyboards = getKeyboardsForLanguage(this.stateService.language) + selected = + this.keyboards.find(k => k.code === this.stateService.keyboard) || + this.keyboards[0] + + readonly stringify = (kb: Keyboard) => kb.name + + async back() { + await this.router.navigate(['/language']) + } + + async continue() { + if (this.selected) { + this.stateService.keyboard = this.selected.code + await this.navigateToNextStep() + } + } + + private async navigateToNextStep() { + if (this.stateService.dataDriveGuid) { + if (this.stateService.attach) { + this.stateService.setupType = 'attach' + await this.router.navigate(['/password']) + } else { + await this.router.navigate(['/home']) + } + } else { + await this.router.navigate(['/drives']) + } + } +} diff --git a/web/projects/setup-wizard/src/app/pages/language.page.ts b/web/projects/setup-wizard/src/app/pages/language.page.ts new file mode 100644 index 000000000..af5193d9d --- /dev/null +++ b/web/projects/setup-wizard/src/app/pages/language.page.ts @@ -0,0 +1,139 @@ +import { Component, inject } from '@angular/core' +import { Router } from '@angular/router' +import { FormsModule } from '@angular/forms' +import { TUI_IS_MOBILE } from '@taiga-ui/cdk' +import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core' +import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit' +import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' +import { StateService } from '../services/state.service' +import { + LANGUAGES, + Language, + getDefaultKeyboard, + needsKeyboardSelection, +} from '../utils/languages' + +@Component({ + template: ` +
+
+

+ + Start9 + Welcome to StartOS + + Select your language +

+
+ + + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + + + +
+ {{ item.nativeName }} + @if (item.name !== item.nativeName) { + {{ item.name }} + } +
+
+ +
+ +
+
+ `, + styles: ` + :host { + display: flex; + align-items: center; + justify-content: center; + min-height: 100%; + } + + .language-item { + display: flex; + flex-direction: column; + + small { + opacity: 0.7; + } + } + `, + imports: [ + FormsModule, + TuiCardLarge, + TuiButton, + TuiTextfield, + TuiChevron, + TuiSelect, + TuiDataListWrapper, + TuiHeader, + TuiTitle, + ], +}) +export default class LanguagePage { + private readonly router = inject(Router) + private readonly stateService = inject(StateService) + + protected readonly mobile = inject(TUI_IS_MOBILE) + readonly languages = LANGUAGES + selected = + LANGUAGES.find(l => l.code === this.stateService.language) || LANGUAGES[0] + + readonly stringify = (lang: Language) => lang.nativeName + + async continue() { + if (this.selected) { + this.stateService.language = this.selected.code + + if (this.stateService.kiosk) { + // Check if we need keyboard selection + if (needsKeyboardSelection(this.selected.code)) { + await this.router.navigate(['/keyboard']) + } else { + // Auto-select the only keyboard option + this.stateService.keyboard = getDefaultKeyboard( + this.selected.code, + ).code + await this.navigateToNextStep() + } + } else { + await this.navigateToNextStep() + } + } + } + + private async navigateToNextStep() { + if (this.stateService.dataDriveGuid) { + if (this.stateService.attach) { + this.stateService.setupType = 'attach' + await this.router.navigate(['/password']) + } else { + await this.router.navigate(['/home']) + } + } else { + await this.router.navigate(['/drives']) + } + } +} diff --git a/web/projects/setup-wizard/src/app/pages/loading.page.ts b/web/projects/setup-wizard/src/app/pages/loading.page.ts index 20d532b96..a11d152b9 100644 --- a/web/projects/setup-wizard/src/app/pages/loading.page.ts +++ b/web/projects/setup-wizard/src/app/pages/loading.page.ts @@ -26,18 +26,16 @@ import { tap, timer, } from 'rxjs' -import { ApiService } from 'src/app/services/api.service' -import { StateService } from 'src/app/services/state.service' +import { ApiService } from '../services/api.service' +import { StateService } from '../services/state.service' @Component({ template: ` @if (error(); as err) {
-

{{ 'Error initializing server' }}

+

Error initializing server

{{ err }}

- +
} @else { @@ -54,7 +52,6 @@ import { StateService } from 'src/app/services/state.service' padding: 1rem; margin: 1.5rem; text-align: center; - // @TODO Theme background: #e0e0e0; color: #333; --tui-background-neutral-1: rgba(0, 0, 0, 0.1); @@ -67,9 +64,9 @@ export default class LoadingPage { private readonly api = inject(ApiService) private readonly loader = inject(LoadingService) private readonly dialog = inject(DialogService) + private readonly router = inject(Router) readonly type = inject(StateService).setupType - readonly router = inject(Router) readonly progress = toSignal( from(this.getStatus()).pipe( filter(Boolean), @@ -99,12 +96,13 @@ export default class LoadingPage { try { const res = await this.api.getStatus() - if (!res) { - this.router.navigate(['home']) - } else if (res.status === 'complete') { - this.router.navigate(['success']) - } else { + if (res.status === 'running') { return res + } else if (res.status === 'complete') { + this.router.navigate(['/success']) + } else { + // incomplete or needs-install - shouldn't happen on loading page + this.router.navigate(['/language']) } } catch (e: any) { this.error.set(getErrorMessage(e)) diff --git a/web/projects/setup-wizard/src/app/pages/password.page.ts b/web/projects/setup-wizard/src/app/pages/password.page.ts new file mode 100644 index 000000000..4a721ae4c --- /dev/null +++ b/web/projects/setup-wizard/src/app/pages/password.page.ts @@ -0,0 +1,188 @@ +import { AsyncPipe } from '@angular/common' +import { Component, inject } from '@angular/core' +import { Router } from '@angular/router' +import { + AbstractControl, + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms' +import { ErrorService, i18nKey, LoadingService } from '@start9labs/shared' +import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk' +import { + TuiButton, + TuiError, + TuiIcon, + TuiTextfield, + TuiTitle, +} from '@taiga-ui/core' +import { + TuiFieldErrorPipe, + TuiPassword, + tuiValidationErrorsProvider, +} from '@taiga-ui/kit' +import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' +import { StateService } from '../services/state.service' + +@Component({ + template: ` +
+
+

+ {{ + isRequired ? 'Set Master Password' : 'Set New Password (Optional)' + }} + + {{ + isRequired + ? 'Make it good. Write it down.' + : 'Skip to keep your existing password.' + }} + +

+
+ +
+ + + + + + + + + + + + + + +
+ + @if (!isRequired) { + + } +
+ +
+ `, + styles: ` + footer { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1.5rem; + } + `, + imports: [ + AsyncPipe, + ReactiveFormsModule, + TuiCardLarge, + TuiButton, + TuiError, + TuiAutoFocus, + TuiFieldErrorPipe, + TuiTextfield, + TuiPassword, + TuiValidator, + TuiIcon, + TuiMapperPipe, + TuiHeader, + TuiTitle, + ], + providers: [ + tuiValidationErrorsProvider({ + required: 'Required', + minlength: 'Must be 12 characters or greater', + maxlength: 'Must be 64 character or less', + match: 'Passwords do not match', + }), + ], +}) +export default class PasswordPage { + private readonly router = inject(Router) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly stateService = inject(StateService) + + // Password is required only for fresh install + readonly isRequired = this.stateService.setupType === 'fresh' + + readonly form = new FormGroup({ + password: new FormControl('', [ + ...(this.isRequired ? [Validators.required] : []), + Validators.minLength(12), + Validators.maxLength(64), + ]), + confirm: new FormControl(''), + }) + + readonly validator = (value: string) => (control: AbstractControl) => + value === control.value ? null : { match: 'Passwords do not match' } + + async skip() { + // Skip means no new password - pass null + await this.executeSetup(null) + } + + async submit() { + await this.executeSetup(this.form.controls.password.value) + } + + private async executeSetup(password: string | null) { + const loader = this.loader.open('Starting setup...' as i18nKey).subscribe() + + try { + if (this.stateService.setupType === 'attach') { + await this.stateService.attachDrive(password) + } else { + // fresh, restore, or transfer - all use execute + await this.stateService.executeSetup(password) + } + + await this.router.navigate(['/loading']) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } +} diff --git a/web/projects/setup-wizard/src/app/pages/recover.page.ts b/web/projects/setup-wizard/src/app/pages/recover.page.ts deleted file mode 100644 index b5316f118..000000000 --- a/web/projects/setup-wizard/src/app/pages/recover.page.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Component, inject } from '@angular/core' -import { Router } from '@angular/router' -import { ErrorService, ServerComponent } from '@start9labs/shared' -import { - TuiButton, - TuiDialogService, - TuiIcon, - TuiLoader, - TuiTitle, -} from '@taiga-ui/core' -import { TuiCardLarge, TuiCell } from '@taiga-ui/layout' -import { CIFS, CifsResponse } from 'src/app/components/cifs.component' -import { PasswordDirective } from 'src/app/components/password.directive' -import { ApiService, StartOSDiskInfoFull } from 'src/app/services/api.service' -import { StateService } from 'src/app/services/state.service' - -@Component({ - template: ` -
-
Restore from Backup
- @if (loading) { - - } @else { -

Network Folder

- Restore StartOS data from a folder on another computer that is connected - to the same network as your server. - - - -

Physical Drive

-
- Restore StartOS data from a physical drive that is plugged directly - into your server. -
- - 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. - - - @for (server of servers; track $index) { - - } -
- -
- } -
- `, - imports: [ - TuiCardLarge, - TuiLoader, - TuiButton, - TuiCell, - TuiIcon, - TuiTitle, - ServerComponent, - PasswordDirective, - ], -}) -export default class RecoverPage { - private readonly api = inject(ApiService) - private readonly router = inject(Router) - private readonly dialogs = inject(TuiDialogService) - private readonly errorService = inject(ErrorService) - private readonly stateService = inject(StateService) - - loading = true - servers: StartOSDiskInfoFull[] = [] - - async ngOnInit() { - this.stateService.setupType = 'restore' - await this.getDrives() - } - - async refresh() { - this.loading = true - await this.getDrives() - } - - async getDrives() { - this.servers = [] - - try { - const drives = await this.api.getDrives() - - this.servers = drives.flatMap(drive => - drive.partitions.flatMap(partition => - Object.entries(partition.startOs).map(([id, val]) => ({ - id, - ...val, - partition, - drive, - })), - ), - ) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - this.loading = false - } - } - - select(password: string, server: StartOSDiskInfoFull) { - this.stateService.recoverySource = { - type: 'backup', - target: { - type: 'disk', - logicalname: server.partition.logicalname, - }, - serverId: server.id, - password, - } - this.router.navigate(['storage']) - } - - onCifs() { - this.dialogs - .open(CIFS, { - label: 'Connect Network Folder', - }) - .subscribe(({ cifs, serverId, password }) => { - this.stateService.recoverySource = { - type: 'backup', - target: { - type: 'cifs', - ...cifs, - }, - serverId, - password, - } - this.router.navigate(['storage']) - }) - } -} diff --git a/web/projects/setup-wizard/src/app/pages/restore.page.ts b/web/projects/setup-wizard/src/app/pages/restore.page.ts new file mode 100644 index 000000000..37ef58dfa --- /dev/null +++ b/web/projects/setup-wizard/src/app/pages/restore.page.ts @@ -0,0 +1,236 @@ +import { Component, inject } from '@angular/core' +import { Router } from '@angular/router' +import { ErrorService } from '@start9labs/shared' +import { T } from '@start9labs/start-sdk' +import { + TuiButton, + TuiDataList, + TuiDialogService, + TuiDropdown, + TuiIcon, + TuiLoader, + TuiOptGroup, + TuiTitle, +} from '@taiga-ui/core' +import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' +import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { ApiService } from '../services/api.service' +import { StateService } from '../services/state.service' +import { StartOSDiskInfoFull, StartOSDiskInfoWithId } from '../types' +import { CIFS, CifsResult } from '../components/cifs.component' +import { SELECT_NETWORK_BACKUP } from '../components/select-network-backup.dialog' +import { UnlockPasswordDialog } from '../components/unlock-password.dialog' + +@Component({ + template: ` +
+
+

+ Select Backup + + Select the StartOS backup you want to restore + + + Refresh + + +

+
+ + @if (loading) { + + } @else { + + + + + + + + + @for (server of physicalServers; track server.id) { + + } @empty { +
No physical backups
+ } +
+
+
+ } +
+ `, + styles: ` + .refresh { + display: inline-flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + color: var(--tui-text-action); + + tui-icon { + font-size: 0.875rem; + } + } + + .server-item { + display: flex; + flex-direction: column; + + small { + opacity: 0.7; + } + } + + .no-items { + padding: 0.5rem 0.75rem; + color: var(--tui-text-secondary); + font-style: italic; + } + `, + imports: [ + TuiButton, + TuiCardLarge, + TuiDataList, + TuiDropdown, + TuiLoader, + TuiIcon, + TuiOptGroup, + TuiTitle, + TuiHeader, + ], +}) +export default class RestorePage { + private readonly api = inject(ApiService) + private readonly router = inject(Router) + private readonly dialogs = inject(TuiDialogService) + private readonly errorService = inject(ErrorService) + private readonly stateService = inject(StateService) + + loading = true + open = false + physicalServers: StartOSDiskInfoFull[] = [] + + async ngOnInit() { + await this.loadDrives() + } + + async refresh() { + this.loading = true + await this.loadDrives() + } + + openCifs() { + this.open = false + this.dialogs + .open(CIFS, { + label: 'Connect Network Folder', + size: 's', + }) + .subscribe(result => { + if (result) { + this.handleCifsResult(result) + } + }) + } + + selectPhysicalBackup(server: StartOSDiskInfoFull) { + this.open = false + this.showUnlockDialog(server.id, { + type: 'disk', + logicalname: server.partition.logicalname, + }) + } + + private handleCifsResult(result: CifsResult) { + if (result.servers.length === 1) { + this.showUnlockDialog(result.servers[0]!.id, { + type: 'cifs', + ...result.cifs, + }) + } else if (result.servers.length > 1) { + this.showSelectNetworkBackupDialog(result.cifs, result.servers) + } + } + + private showSelectNetworkBackupDialog( + cifs: T.Cifs, + servers: StartOSDiskInfoWithId[], + ) { + this.dialogs + .open(SELECT_NETWORK_BACKUP, { + label: 'Select Network Backup', + size: 's', + data: { servers }, + }) + .subscribe(server => { + if (server) { + this.showUnlockDialog(server.id, { type: 'cifs', ...cifs }) + } + }) + } + + private showUnlockDialog( + serverId: string, + target: { type: 'disk'; logicalname: string } | ({ type: 'cifs' } & T.Cifs), + ) { + this.dialogs + .open(new PolymorpheusComponent(UnlockPasswordDialog), { + label: 'Unlock Backup', + size: 's', + }) + .subscribe(password => { + if (password) { + this.stateService.recoverySource = { + type: 'backup', + target, + serverId, + password, + } + this.router.navigate(['/password']) + } + }) + } + + private async loadDrives() { + this.physicalServers = [] + + try { + const drives = await this.api.getDisks() + + this.physicalServers = drives.flatMap(drive => + drive.partitions.flatMap(partition => + Object.entries(partition.startOs).map(([id, val]) => ({ + id, + ...val, + partition, + drive, + })), + ), + ) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + this.loading = false + } + } +} diff --git a/web/projects/setup-wizard/src/app/pages/storage.page.ts b/web/projects/setup-wizard/src/app/pages/storage.page.ts deleted file mode 100644 index e8d7f857b..000000000 --- a/web/projects/setup-wizard/src/app/pages/storage.page.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Component, inject } from '@angular/core' -import { Router } from '@angular/router' -import { - DiskInfo, - DriveComponent, - ErrorService, - i18nKey, - LoadingService, - toGuid, -} from '@start9labs/shared' -import { TuiButton, TuiDialogService, TuiLoader } from '@taiga-ui/core' -import { TUI_CONFIRM } from '@taiga-ui/kit' -import { TuiCardLarge, TuiCell } from '@taiga-ui/layout' -import { filter, of, switchMap } from 'rxjs' -import { PASSWORD } from 'src/app/components/password.component' -import { ApiService } from 'src/app/services/api.service' -import { StateService } from 'src/app/services/state.service' - -@Component({ - template: ` -
- @if (loading || drives.length) { -
Select storage drive
- This is the drive where your StartOS data will be stored. - } @else { -
No drives found
- Please connect a storage drive to your server. Then click "Refresh". - } - - @if (loading) { - - } - - @for (d of drives; track d) { - - } -
- -
-
- `, - imports: [TuiCardLarge, TuiLoader, TuiCell, TuiButton, DriveComponent], -}) -export default class StoragePage { - private readonly api = inject(ApiService) - private readonly router = inject(Router) - private readonly dialogs = inject(TuiDialogService) - private readonly stateService = inject(StateService) - private readonly loader = inject(LoadingService) - private readonly errorService = inject(ErrorService) - - drives: DiskInfo[] = [] - loading = true - - async ngOnInit() { - await this.getDrives() - } - - isSmall({ capacity }: DiskInfo) { - return capacity < 34359738368 - } - - async refresh() { - this.loading = true - await this.getDrives() - } - - async getDrives() { - this.loading = true - try { - const disks = await this.api.getDrives() - if (this.stateService.setupType === 'fresh') { - this.drives = disks - } else if ( - this.stateService.setupType === 'restore' && - this.stateService.recoverySource?.type === 'backup' - ) { - if (this.stateService.recoverySource.target.type === 'disk') { - const logicalname = - this.stateService.recoverySource.target.logicalname - this.drives = disks.filter( - d => !d.partitions.map(p => p.logicalname).includes(logicalname), - ) - } else { - this.drives = disks - } - } else if ( - this.stateService.setupType === 'transfer' && - this.stateService.recoverySource?.type === 'migrate' - ) { - const guid = this.stateService.recoverySource.guid - this.drives = disks.filter(d => { - return ( - d.guid !== guid && !d.partitions.map(p => p.guid).includes(guid) - ) - }) - } - } catch (e: any) { - this.errorService.handleError(e) - } finally { - this.loading = false - } - } - - select(drive: DiskInfo) { - of(!toGuid(drive) && !drive.partitions.some(p => p.used)) - .pipe( - switchMap(unused => - unused - ? of(true) - : this.dialogs.open(TUI_CONFIRM, { - label: 'Warning', - size: 's', - data: { - content: - 'Drive contains data!

All data stored on this drive will be permanently deleted.

', - yes: 'Continue', - no: 'Cancel', - }, - }), - ), - ) - .pipe(filter(Boolean)) - .subscribe(() => { - // for backup recoveries - if (this.stateService.recoverySource?.type === 'backup') { - this.setupEmbassy( - drive.logicalname, - this.stateService.recoverySource.password, - ) - } else { - // for migrations and fresh setups - this.promptPassword(drive.logicalname) - } - }) - } - - private promptPassword(logicalname: string) { - this.dialogs - .open(PASSWORD, { - label: 'Set Password', - size: 's', - data: { storageDrive: true }, - }) - .subscribe(password => { - this.setupEmbassy(logicalname, password) - }) - } - - private async setupEmbassy( - logicalname: string, - password: string, - ): Promise { - const loader = this.loader - .open('Connecting to drive' as i18nKey) - .subscribe() - - try { - await this.stateService.setupEmbassy(logicalname, password) - await this.router.navigate(['loading']) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - } -} diff --git a/web/projects/setup-wizard/src/app/pages/success.page.ts b/web/projects/setup-wizard/src/app/pages/success.page.ts index fb68f0a0d..43c638911 100644 --- a/web/projects/setup-wizard/src/app/pages/success.page.ts +++ b/web/projects/setup-wizard/src/app/pages/success.page.ts @@ -7,121 +7,170 @@ import { DOCUMENT, } from '@angular/core' import { DownloadHTMLService, ErrorService } from '@start9labs/shared' -import { TuiButton, TuiIcon, TuiLoader, TuiSurface } from '@taiga-ui/core' -import { TuiCardLarge } from '@taiga-ui/layout' -import { DocumentationComponent } from 'src/app/components/documentation.component' -import { MatrixComponent } from 'src/app/components/matrix.component' -import { ApiService } from 'src/app/services/api.service' -import { StateService } from 'src/app/services/state.service' +import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core' +import { TuiAvatar } from '@taiga-ui/kit' +import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout' +import { ApiService } from '../services/api.service' +import { StateService } from '../services/state.service' +import { DocumentationComponent } from '../components/documentation.component' +import { MatrixComponent } from '../components/matrix.component' +import { SetupCompleteRes } from '../types' @Component({ template: `
-

- - Setup Complete! -

- @if (stateService.kiosk) { - - } @else if (lanAddress) { - @if (stateService.setupType === 'restore') { -

You can now safely unplug your backup drive

- } @else if (stateService.setupType === 'transfer') { -

You can now safely unplug your old StartOS data drive

+
+

+ + + Setup Complete! + + @if (!stateService.kiosk) { + + {{ + stateService.setupType === 'restore' + ? 'You can unplug your backup drive' + : stateService.setupType === 'transfer' + ? 'You can unplug your transfer drive' + : 'http://start.local was for setup only. It will no longer work.' + }} + + } +

+
+ + @if (!result) { + + } @else { + + @if (!stateService.kiosk) { + } -

- http://start.local was for setup purposes only. It will no longer - work. -

+ + @if (result.needsRestart) { + - + + + } - - - In the new tab, follow instructions to trust your server's Root CA - and log in. - - - Open Local Address - - - -
`, styles: ` - .heading { - display: flex; - gap: 1rem; + .inline-title { + display: inline-flex; align-items: center; - margin: 0; - font: var(--tui-font-heading-4); - } - - .caps { - display: flex; - align-items: center; - justify-content: center; gap: 0.5rem; - text-transform: uppercase; } - [tuiCardLarge] { - color: var(--tui-text-primary); - text-decoration: none; - text-align: center; - - &[data-appearance='floating'] { - background: var(--tui-background-neutral-1); - - &:hover { - background: var(--tui-background-neutral-1-hover) !important; - } - } - } - - a[tuiCardLarge]:not([href]) { + [tuiCell].disabled { opacity: var(--tui-disabled-opacity); pointer-events: none; } - - h3 { - text-align: left; - } `, imports: [ TuiCardLarge, + TuiCell, TuiIcon, - TuiButton, - TuiSurface, + TuiLoader, + TuiAvatar, MatrixComponent, DocumentationComponent, - TuiLoader, + TuiHeader, + TuiTitle, ], }) export default class SuccessPage implements AfterViewInit { @ViewChild(DocumentationComponent, { read: ElementRef }) private readonly documentation?: ElementRef + private readonly document = inject(DOCUMENT) private readonly errorService = inject(ErrorService) private readonly api = inject(ApiService) @@ -129,32 +178,42 @@ export default class SuccessPage implements AfterViewInit { readonly stateService = inject(StateService) - lanAddress?: string - cert?: string - disableLogin = this.stateService.setupType === 'fresh' + result?: SetupCompleteRes + lanAddress = '' + downloaded = false + usbRemoved = false + rebooting = false + rebooted = false + + get canOpenAddress(): boolean { + if (!this.downloaded) return false + if (this.result?.needsRestart && !this.rebooted) return false + return true + } ngAfterViewInit() { - setTimeout(() => this.complete(), 1000) + setTimeout(() => this.complete(), 500) } download() { - const lanElem = this.document.getElementById('lan-addr') + if (this.downloaded) return - if (lanElem) lanElem.innerHTML = this.lanAddress || '' + const lanElem = this.document.getElementById('lan-addr') + if (lanElem) lanElem.innerHTML = this.lanAddress this.document .getElementById('cert') ?.setAttribute( 'href', URL.createObjectURL( - new Blob([this.cert!], { type: 'application/octet-stream' }), + new Blob([this.result!.rootCa], { type: 'application/octet-stream' }), ), ) const html = this.documentation?.nativeElement.innerHTML || '' - this.downloadHtml.download('StartOS-info.html', html).then(_ => { - this.disableLogin = false + this.downloadHtml.download('StartOS-info.html', html).then(() => { + this.downloaded = true }) } @@ -162,17 +221,56 @@ export default class SuccessPage implements AfterViewInit { this.api.exit() } + openLocalAddress() { + window.open(this.lanAddress, '_blank') + } + + async reboot() { + this.rebooting = true + + try { + await this.api.exit() + await this.pollForServer() + this.rebooted = true + this.rebooting = false + } catch (e: any) { + this.errorService.handleError(e) + this.rebooting = false + } + } + private async complete() { try { - const ret = await this.api.complete() - if (!this.stateService.kiosk) { - this.lanAddress = ret.lanAddress.replace(/^https:/, 'http:') - this.cert = ret.rootCa + this.result = await this.api.complete() - await this.api.exit() + if (!this.stateService.kiosk) { + this.lanAddress = `https://${this.result.hostname}.local` + + if (!this.result.needsRestart) { + await this.api.exit() + } } } catch (e: any) { this.errorService.handleError(e) } } + + private async pollForServer(): Promise { + const maxAttempts = 60 + let attempts = 0 + + while (attempts < maxAttempts) { + try { + await this.api.echo({ message: 'ping' }, this.lanAddress) + return + } catch { + await new Promise(resolve => setTimeout(resolve, 5000)) + attempts++ + } + } + + throw new Error( + 'Server did not come back online. Please check your server and try accessing it manually.', + ) + } } diff --git a/web/projects/setup-wizard/src/app/pages/transfer.page.ts b/web/projects/setup-wizard/src/app/pages/transfer.page.ts index 3082b0662..72b6900c8 100644 --- a/web/projects/setup-wizard/src/app/pages/transfer.page.ts +++ b/web/projects/setup-wizard/src/app/pages/transfer.page.ts @@ -1,97 +1,167 @@ import { Component, inject } from '@angular/core' import { Router } from '@angular/router' -import { - DiskInfo, - DriveComponent, - ErrorService, - toGuid, -} from '@start9labs/shared' +import { DiskInfo, ErrorService, toGuid } from '@start9labs/shared' import { TuiButton, + TuiDataList, TuiDialogOptions, TuiDialogService, + TuiDropdown, + TuiIcon, TuiLoader, + TuiTitle, } from '@taiga-ui/core' import { TUI_CONFIRM, TuiConfirmData } from '@taiga-ui/kit' -import { TuiCardLarge, TuiCell } from '@taiga-ui/layout' +import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' import { filter } from 'rxjs' -import { ApiService } from 'src/app/services/api.service' -import { StateService } from 'src/app/services/state.service' +import { ApiService } from '../services/api.service' +import { StateService } from '../services/state.service' @Component({ template: `
-
Transfer
- Select the physical drive containing your StartOS data +
+

+ Transfer Data + + Select the drive containing your existing StartOS data + + + Refresh + + +

+
+ @if (loading) { - } - @for (drive of drives; track drive) { - - } -
- -
+ + + + @for (drive of drives; track drive.logicalname) { + + } @empty { +
No StartOS data drives found
+ } +
+
+ }
`, - imports: [TuiCardLarge, TuiCell, TuiButton, TuiLoader, DriveComponent], + styles: ` + .refresh { + display: inline-flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + color: var(--tui-text-action); + + tui-icon { + font-size: 0.875rem; + } + } + + .drive-item { + display: flex; + flex-direction: column; + + small { + opacity: 0.7; + } + } + + .no-items { + padding: 0.5rem 0.75rem; + color: var(--tui-text-secondary); + font-style: italic; + } + `, + imports: [ + TuiButton, + TuiCardLarge, + TuiDataList, + TuiDropdown, + TuiIcon, + TuiLoader, + TuiTitle, + TuiHeader, + ], }) export default class TransferPage { - private readonly apiService = inject(ApiService) + private readonly api = inject(ApiService) private readonly router = inject(Router) private readonly dialogs = inject(TuiDialogService) private readonly errorService = inject(ErrorService) private readonly stateService = inject(StateService) loading = true + open = false drives: DiskInfo[] = [] async ngOnInit() { - this.stateService.setupType = 'transfer' - await this.getDrives() + await this.loadDrives() } async refresh() { - await this.getDrives() + this.loading = true + await this.loadDrives() } - async getDrives() { - this.loading = true + select(drive: DiskInfo) { + this.open = false + const WARNING_OPTIONS: Partial> = { + label: 'Warning', + size: 's', + data: { + content: `After transferring data from this drive, do not + attempt to boot into it again as a Start9 Server. This may result in + services malfunctioning, data corruption, or loss of funds.`, + yes: 'Continue', + no: 'Cancel', + }, + } + + this.dialogs + .open(TUI_CONFIRM, WARNING_OPTIONS) + .pipe(filter(Boolean)) + .subscribe(() => { + const guid = toGuid(drive) + if (guid) { + this.stateService.recoverySource = { + type: 'migrate', + guid, + } + this.router.navigate(['/password']) + } + }) + } + + private async loadDrives() { try { - this.drives = await this.apiService - .getDrives() - .then(drives => drives.filter(toGuid)) + const allDrives = await this.api.getDisks() + // Filter to only drives with StartOS data (guid) + this.drives = allDrives.filter(toGuid) } catch (e: any) { this.errorService.handleError(e) } finally { this.loading = false } } - - select(drive: DiskInfo) { - this.dialogs - .open(TUI_CONFIRM, OPTIONS) - .pipe(filter(Boolean)) - .subscribe(() => { - this.stateService.recoverySource = { - type: 'migrate', - guid: toGuid(drive) || '', - } - this.router.navigate([`storage`]) - }) - } -} - -const OPTIONS: Partial> = { - label: 'Warning', - size: 's', - data: { - content: - 'After transferring data from this drive, do not attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.', - yes: 'Continue', - no: 'Cancel', - }, } diff --git a/web/projects/setup-wizard/src/app/services/api.service.ts b/web/projects/setup-wizard/src/app/services/api.service.ts index 03604b7aa..d92ff67ed 100644 --- a/web/projects/setup-wizard/src/app/services/api.service.ts +++ b/web/projects/setup-wizard/src/app/services/api.service.ts @@ -1,50 +1,56 @@ import * as jose from 'node-jose' -import { - DiskInfo, - DiskListResponse, - FollowLogsRes, - PartitionInfo, - StartOSDiskInfo, -} from '@start9labs/shared' +import { DiskInfo, FollowLogsRes, StartOSDiskInfo } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' -import { WebSocketSubjectConfig } from 'rxjs/webSocket' import { Observable } from 'rxjs' +import { + SetupStatusRes, + InstallOsParams, + InstallOsRes, + AttachParams, + SetupExecuteParams, + SetupCompleteRes, + EchoReq, +} from '../types' export abstract class ApiService { pubkey?: jose.JWK.Key - abstract getStatus(): Promise // setup.status + // echo + abstract echo(params: EchoReq, url: string): Promise + + // Status & Setup + abstract getStatus(): Promise // setup.status abstract getPubKey(): Promise // setup.get-pubkey - abstract getDrives(): Promise // setup.disk.list + + // Install + abstract getDisks(): Promise // setup.disk.list + abstract installOs(params: InstallOsParams): Promise // setup.install-os + + // Setup execution + abstract attach(params: AttachParams): Promise // setup.attach + abstract execute(params: SetupExecuteParams): Promise // setup.execute + + // Recovery helpers abstract verifyCifs( cifs: T.VerifyCifsParams, ): Promise> // setup.cifs.verify - abstract attach(importInfo: T.AttachParams): Promise // setup.attach - abstract execute(setupInfo: T.SetupExecuteParams): Promise // setup.execute - abstract complete(): Promise // setup.complete + + // Completion + abstract complete(): Promise // setup.complete abstract exit(): Promise // setup.exit + + // Logs & Progress abstract initFollowLogs(): Promise // setup.logs.follow - abstract restart(): Promise // setup.restart abstract openWebsocket$(guid: string): Observable + // Restart (for error recovery) + abstract restart(): Promise // setup.restart + async encrypt(toEncrypt: string): Promise { if (!this.pubkey) throw new Error('No pubkey found!') const encrypted = await jose.JWE.createEncrypt(this.pubkey!) .update(toEncrypt) .final() - return { - encrypted, - } + return { encrypted } } } - -export type WebsocketConfig = Omit, 'url'> - -export type StartOSDiskInfoWithId = StartOSDiskInfo & { - id: string -} - -export type StartOSDiskInfoFull = StartOSDiskInfoWithId & { - partition: PartitionInfo - drive: DiskInfo -} diff --git a/web/projects/setup-wizard/src/app/services/live-api.service.ts b/web/projects/setup-wizard/src/app/services/live-api.service.ts index 43fc7016a..149d16dde 100644 --- a/web/projects/setup-wizard/src/app/services/live-api.service.ts +++ b/web/projects/setup-wizard/src/app/services/live-api.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, DOCUMENT } from '@angular/core' import { - DiskListResponse, + DiskInfo, encodeBase64, FollowLogsRes, HttpService, @@ -14,6 +14,15 @@ import * as jose from 'node-jose' import { Observable } from 'rxjs' import { webSocket } from 'rxjs/webSocket' import { ApiService } from './api.service' +import { + SetupStatusRes, + InstallOsParams, + InstallOsRes, + AttachParams, + SetupExecuteParams, + SetupCompleteRes, + EchoReq, +} from '../types' @Injectable({ providedIn: 'root', @@ -36,39 +45,40 @@ export class LiveApiService extends ApiService { }) } - async getStatus(): Promise { - return this.rpcRequest({ + async echo(params: EchoReq, url: string): Promise { + return this.rpcRequest({ method: 'echo', params }, url) + } + + async getStatus() { + return this.rpcRequest({ 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(): Promise { + async getPubKey() { const response: jose.JWK.Key = await this.rpcRequest({ method: 'setup.get-pubkey', params: {}, }) - this.pubkey = response } - async getDrives(): Promise { - return this.rpcRequest({ + async getDisks() { + return this.rpcRequest({ method: 'setup.disk.list', params: {}, }) } - async verifyCifs( - source: T.VerifyCifsParams, - ): Promise> { + async installOs(params: InstallOsParams) { + return this.rpcRequest({ + method: 'setup.install-os', + params, + }) + } + + async verifyCifs(source: T.VerifyCifsParams) { source.path = source.path.replace('/\\/g', '/') return this.rpcRequest>({ method: 'setup.cifs.verify', @@ -76,33 +86,36 @@ export class LiveApiService extends ApiService { }) } - async attach(params: T.AttachParams): Promise { + async attach(params: AttachParams) { return this.rpcRequest({ method: 'setup.attach', params, }) } - async execute(setupInfo: T.SetupExecuteParams): Promise { - if (setupInfo.recoverySource?.type === 'backup') { - if (isCifsSource(setupInfo.recoverySource.target)) { - setupInfo.recoverySource.target.path = - setupInfo.recoverySource.target.path.replace('/\\/g', '/') + async execute(params: SetupExecuteParams) { + if (params.recoverySource?.type === 'backup') { + const target = params.recoverySource.target + if (target.type === 'cifs') { + target.path = target.path.replace('/\\/g', '/') } } return this.rpcRequest({ method: 'setup.execute', - params: setupInfo, + params, }) } - async initFollowLogs(): Promise { - return this.rpcRequest({ method: 'setup.logs.follow', params: {} }) + async initFollowLogs() { + return this.rpcRequest({ + method: 'setup.logs.follow', + params: {}, + }) } - async complete(): Promise { - const res = await this.rpcRequest({ + async complete() { + const res = await this.rpcRequest({ method: 'setup.complete', params: {}, }) @@ -113,23 +126,22 @@ export class LiveApiService extends ApiService { } } - async exit(): Promise { + async exit() { await this.rpcRequest({ method: 'setup.exit', params: {}, }) } - async restart(): Promise { + async restart() { await this.rpcRequest({ method: 'setup.restart', params: {}, }) } - private async rpcRequest(opts: RPCOptions): Promise { - const res = await this.http.rpcRequest(opts) - + private async rpcRequest(opts: RPCOptions, url?: string): Promise { + const res = await this.http.rpcRequest(opts, url) const rpcRes = res.body if (isRpcError(rpcRes)) { @@ -139,9 +151,3 @@ export class LiveApiService extends ApiService { return rpcRes.result } } - -function isCifsSource( - source: T.BackupTargetFS | null, -): source is T.Cifs & { type: 'cifs' } { - return !!(source as T.Cifs)?.hostname -} diff --git a/web/projects/setup-wizard/src/app/services/mock-api.service.ts b/web/projects/setup-wizard/src/app/services/mock-api.service.ts index e62597bfe..3cc9b9216 100644 --- a/web/projects/setup-wizard/src/app/services/mock-api.service.ts +++ b/web/projects/setup-wizard/src/app/services/mock-api.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core' import { - DiskListResponse, + DiskInfo, encodeBase64, FollowLogsRes, pauseFor, @@ -8,104 +8,24 @@ import { } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' import * as jose from 'node-jose' -import { first, interval, map, Observable } from 'rxjs' +import { interval, map, Observable } from 'rxjs' import { ApiService } from './api.service' +import { + SetupStatusRes, + InstallOsParams, + InstallOsRes, + AttachParams, + SetupExecuteParams, + SetupCompleteRes, + EchoReq, +} from '../types' @Injectable({ providedIn: 'root', }) export class MockApiService extends ApiService { - // fullProgress$(): Observable { - // const phases = [ - // { - // name: 'Preparing Data', - // progress: null, - // }, - // { - // name: 'Transferring Data', - // progress: null, - // }, - // { - // name: 'Finalizing Setup', - // progress: null, - // }, - // ] - - // return from(phases).pipe( - // switchScan((acc, val, i) => {}, { overall: null, phases }), - // ) - // } - - // namedProgress$(namedProgress: T.NamedProgress): Observable { - // return of(namedProgress).pipe(startWith(namedProgress)) - // } - - // progress$(progress: T.Progress): Observable {} - - // websocket - - // oldMockProgress$(): Promise { - // const numPhases = PROGRESS.phases.length - - // return of(PROGRESS).pipe( - // switchMap(full => - // from(PROGRESS.phases).pipe( - // mergeScan((full, phase, i) => { - // if ( - // !phase.progress || - // typeof phase.progress !== 'object' || - // !phase.progress.total - // ) { - // full.phases[i].progress = true - - // if ( - // full.overall && - // typeof full.overall === 'object' && - // full.overall.total - // ) { - // const step = full.overall.total / numPhases - // full.overall.done += step - // } - - // return of(full).pipe(delay(2000)) - // } else { - // const total = phase.progress.total - // const step = total / 4 - // let done = phase.progress.done - - // return interval(1000).pipe( - // takeWhile(() => done < total), - // map(() => { - // done += step - - // console.error(done) - - // if ( - // full.overall && - // typeof full.overall === 'object' && - // full.overall.total - // ) { - // const step = full.overall.total / numPhases / 4 - - // full.overall.done += step - // } - - // if (done === total) { - // full.phases[i].progress = true - - // if (i === numPhases - 1) { - // full.overall = true - // } - // } - // return full - // }), - // ) - // } - // }, full), - // ), - // ), - // ) - // } + private statusIndex = 0 + private installCompleted = false openWebsocket$(guid: string): Observable { if (guid === 'logs-guid') { @@ -117,24 +37,13 @@ export class MockApiService extends ApiService { })), ) as Observable } else if (guid === 'progress-guid') { - // @TODO Matt mock progress return interval(1000).pipe( - first(), map(() => ({ overall: true, phases: [ - { - name: 'Preparing Data', - progress: true, - }, - { - name: 'Transferring Data', - progress: true, - }, - { - name: 'Finalizing Setup', - progress: true, - }, + { name: 'Preparing Data', progress: true }, + { name: 'Transferring Data', progress: true }, + { name: 'Finalizing Setup', progress: true }, ], })), ) as Observable @@ -143,40 +52,39 @@ export class MockApiService extends ApiService { } } - private statusIndex = 0 - async getStatus(): Promise { - await pauseFor(1000) + async echo(params: EchoReq, url: string): Promise { + if (url) { + const num = Math.floor(Math.random() * 10) + 1 + if (num > 8) return params.message + throw new Error() + } + await pauseFor(500) + return params.message + } + + async getStatus(): Promise { + await pauseFor(500) this.statusIndex++ - switch (this.statusIndex) { - case 2: - return { - status: 'running', - progress: PROGRESS, - guid: 'progress-guid', - } - case 3: - return { - status: 'complete', - torAddresses: ['https://asdafsadasdasasdasdfasdfasdf.onion'], - hostname: 'adjective-noun', - lanAddress: 'https://adjective-noun.local', - rootCa: encodeBase64(rootCA), - } - default: - return null + if (this.statusIndex === 1) { + // return { status: 'needs-install' } + return { status: 'incomplete', attach: false, guid: 'mock-data-guid' } + } + + if (this.statusIndex > 3) { + return { status: 'complete' } + } + + return { + status: 'running', + progress: PROGRESS, + guid: 'progress-guid', } } async getPubKey(): Promise { - await pauseFor(1000) - - // randomly generated - // const keystore = jose.JWK.createKeyStore() - // this.pubkey = await keystore.generate('EC', 'P-256') - - // generated from backend + await pauseFor(300) this.pubkey = await jose.JWK.asKey({ kty: 'EC', crv: 'P-256', @@ -185,88 +93,18 @@ export class MockApiService extends ApiService { }) } - async getDrives(): Promise { - 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, - startOs: { - '1234-5678-9876-5432': { - hostname: 'adjective-noun', - version: '0.2.17', - timestamp: new Date().toISOString(), - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: 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, - startOs: { - '1234-5678-9876-5432': { - hostname: 'adjective-noun', - version: '0.2.17', - timestamp: new Date().toISOString(), - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, - }, - }, - guid: null, - }, - ], - capacity: 1000190509056, - guid: null, - }, - { - logicalname: '/dev/sda', - vendor: 'ASMT', - model: '2115', - partitions: [ - { - logicalname: 'pbcba', - label: null, - capacity: 73264762332, - used: null, - startOs: { - '1234-5678-9876-5432': { - hostname: 'adjective-noun', - version: '0.2.17', - timestamp: new Date().toISOString(), - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, - }, - }, - guid: 'guid-guid-guid-guid', - }, - ], - capacity: 1000190509, - guid: null, - }, - ] + async getDisks(): Promise { + await pauseFor(500) + return MOCK_DISKS + } + + async installOs(params: InstallOsParams): Promise { + await pauseFor(2000) + this.installCompleted = true + return { + guid: 'mock-data-guid', + attach: !params.dataDrive.wipe, + } } async verifyCifs( @@ -282,21 +120,29 @@ export class MockApiService extends ApiService { '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', wrappedKey: '', }, + '9876-5432-1234-5671': { + hostname: 'adjective-noun', + version: '0.3.6', + timestamp: new Date().toISOString(), + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: '', + }, } } - async attach(params: T.AttachParams): Promise { + async attach(params: AttachParams): Promise { await pauseFor(1000) - + this.statusIndex = 1 // Jump to running state return { progress: PROGRESS, guid: 'progress-guid', } } - async execute(setupInfo: T.SetupExecuteParams): Promise { + async execute(params: SetupExecuteParams): Promise { await pauseFor(1000) - + this.statusIndex = 1 // Jump to running state return { progress: PROGRESS, guid: 'progress-guid', @@ -304,33 +150,109 @@ export class MockApiService extends ApiService { } async initFollowLogs(): Promise { - await pauseFor(1000) + await pauseFor(500) return { startCursor: 'fakestartcursor', guid: 'logs-guid', } } - async complete(): Promise { - await pauseFor(1000) + async complete(): Promise { + await pauseFor(500) return { - torAddresses: ['https://asdafsadasdasasdasdfasdfasdf.onion'], hostname: 'adjective-noun', - lanAddress: 'https://adjective-noun.local', - rootCa: encodeBase64(rootCA), + rootCa: encodeBase64(ROOT_CA), + needsRestart: this.installCompleted, } } async exit(): Promise { - await pauseFor(1000) + await pauseFor(500) } async restart(): Promise { - await pauseFor(1000) + await pauseFor(500) } } -const rootCA = `-----BEGIN CERTIFICATE----- +const MOCK_DISKS: DiskInfo[] = [ + { + logicalname: '/dev/sda', + vendor: 'Samsung', + model: 'SSD 970 EVO Plus', + partitions: [ + { + logicalname: '/dev/sda1', + label: null, + capacity: 500000000000, + used: null, + startOs: {}, + guid: null, + }, + ], + capacity: 500000000000, + guid: null, + }, + { + logicalname: '/dev/sdb', + vendor: 'Crucial', + model: 'MX500', + partitions: [ + { + logicalname: '/dev/sdb1', + label: null, + capacity: 1000000000000, + used: null, + startOs: { + '1234-5678-9876-5432': { + hostname: 'existing-server', + version: '0.3.6', + timestamp: new Date().toISOString(), + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: null, + }, + }, + guid: 'existing-guid', + }, + ], + capacity: 1000000000000, + guid: 'existing-guid', + }, + { + logicalname: '/dev/sdc', + vendor: 'WD', + model: 'Blue SN570', + partitions: [ + { + logicalname: '/dev/sdc1', + label: 'Backup', + capacity: 2000000000000, + used: 500000000000, + startOs: { + 'backup-server-id': { + hostname: 'backup-server', + version: '0.3.5', + timestamp: new Date(Date.now() - 86400000).toISOString(), + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: '', + }, + }, + guid: null, + }, + ], + capacity: 2000000000000, + guid: null, + }, +] + +const PROGRESS: T.FullProgress = { + overall: null, + phases: [], +} + +const ROOT_CA = `-----BEGIN CERTIFICATE----- MIIDpzCCAo+gAwIBAgIRAIIuOarlQETlUQEOZJGZYdIwDQYJKoZIhvcNAQELBQAw bTELMAkGA1UEBhMCVVMxFTATBgNVBAoMDEV4YW1wbGUgQ29ycDEOMAwGA1UECwwF U2FsZXMxCzAJBgNVBAgMAldBMRgwFgYDVQQDDA93d3cuZXhhbXBsZS5jb20xEDAO @@ -352,8 +274,3 @@ Rf3ZOPm9QP92YpWyYDkfAU04xdDo1vR0MYjKPkl4LjRqSU/tcCJnPMbJiwq+bWpX 2WJoEBXB/p15Kn6JxjI0ze2SnSI48JZ8it4fvxrhOo0VoLNIuCuNXJOwU17Rdl1W YJidaq7je6k18AdgPA0Kh8y1XtfUH3fTaVw4 -----END CERTIFICATE-----` - -const PROGRESS = { - overall: null, - phases: [], -} diff --git a/web/projects/setup-wizard/src/app/services/state.service.ts b/web/projects/setup-wizard/src/app/services/state.service.ts index fc8e3e66e..39e821ada 100644 --- a/web/projects/setup-wizard/src/app/services/state.service.ts +++ b/web/projects/setup-wizard/src/app/services/state.service.ts @@ -1,6 +1,28 @@ import { inject, Injectable } from '@angular/core' -import { ApiService } from './api.service' import { T } from '@start9labs/start-sdk' +import { ApiService } from './api.service' + +export type SetupType = 'fresh' | 'restore' | 'attach' | 'transfer' + +export type RecoverySource = + | { + type: 'migrate' + guid: string + } + | { + type: 'backup' + target: + | { type: 'disk'; logicalname: string } + | { + type: 'cifs' + hostname: string + path: string + username: string + password: string | null + } + serverId: string + password: string // plaintext, will be encrypted before sending + } @Injectable({ providedIn: 'root', @@ -8,34 +30,72 @@ import { T } from '@start9labs/start-sdk' export class StateService { private readonly api = inject(ApiService) - kiosk?: boolean - setupType?: 'fresh' | 'restore' | 'attach' | 'transfer' - recoverySource?: T.RecoverySource + // Determined at app init + kiosk = false - async importDrive(guid: string, password: string): Promise { + // Set during install flow, or loaded from status response + language = '' + keyboard = '' // only used if kiosk + + // From install response or status response (incomplete) + dataDriveGuid = '' + attach = false + + // Set during setup flow + setupType?: SetupType + recoverySource?: RecoverySource + + /** + * Called for attach flow (existing data drive) + */ + async attachDrive(password: string | null): Promise { await this.api.attach({ - guid, - startOsPassword: await this.api.encrypt(password), - kiosk: this.kiosk, + guid: this.dataDriveGuid, + startOsPassword: password ? await this.api.encrypt(password) : null, + language: this.language, + kiosk: this.kiosk ? { keyboard: this.keyboard } : null, }) } - async setupEmbassy( - storageLogicalname: string, - password: string, - ): Promise { + /** + * Called for fresh, restore, and transfer flows + * password is required for fresh, optional for restore/transfer + */ + async executeSetup(password: string | null): Promise { + let recoverySource: T.RecoverySource | null = null + + if (this.recoverySource) { + if (this.recoverySource.type === 'migrate') { + recoverySource = this.recoverySource + } else { + // backup type - need to encrypt the backup password + recoverySource = { + type: 'backup', + target: this.recoverySource.target, + serverId: this.recoverySource.serverId, + password: await this.api.encrypt(this.recoverySource.password), + } + } + } + await this.api.execute({ - startOsLogicalname: storageLogicalname, - startOsPassword: await this.api.encrypt(password), - recoverySource: this.recoverySource - ? this.recoverySource.type === 'migrate' - ? this.recoverySource - : { - ...this.recoverySource, - password: await this.api.encrypt(this.recoverySource.password), - } - : null, - kiosk: this.kiosk, + startOsLogicalname: this.dataDriveGuid, + startOsPassword: password ? await this.api.encrypt(password) : null, + language: this.language, + kiosk: this.kiosk ? { keyboard: this.keyboard } : null, + recoverySource, }) } + + /** + * Reset state for a fresh start + */ + reset(): void { + this.language = '' + this.keyboard = '' + this.dataDriveGuid = '' + this.attach = false + this.setupType = undefined + this.recoverySource = undefined + } } diff --git a/web/projects/setup-wizard/src/app/types.ts b/web/projects/setup-wizard/src/app/types.ts new file mode 100644 index 000000000..ea1d0facc --- /dev/null +++ b/web/projects/setup-wizard/src/app/types.ts @@ -0,0 +1,88 @@ +import { DiskInfo, PartitionInfo, StartOSDiskInfo } from '@start9labs/shared' +import { T } from '@start9labs/start-sdk' + +// === Echo === + +export type EchoReq = { + message: string +} + +// === Setup Status === + +export type SetupStatusRes = + | { status: 'needs-install' } + | { status: 'incomplete'; guid: string; attach: boolean } + | { status: 'running'; progress: T.FullProgress; guid: string } + | { status: 'complete' } + +// === Install OS === + +export interface InstallOsParams { + osDrive: string // e.g. /dev/sda + dataDrive: { + logicalname: string // e.g. /dev/sda, /dev/sdb3 + wipe: boolean + } +} + +export interface InstallOsRes { + guid: string // data drive guid + attach: boolean +} + +// === Attach === + +export interface AttachParams { + startOsPassword: T.EncryptedWire | null + guid: string // data drive + language: string + kiosk: { keyboard: string } | null +} + +// === Execute === + +export interface SetupExecuteParams { + startOsLogicalname: string + startOsPassword: T.EncryptedWire | null // null = keep existing password (for restore/transfer) + language: string + kiosk: { keyboard: string } | null + recoverySource: + | { + type: 'migrate' + guid: string + } + | { + type: 'backup' + target: + | { type: 'disk'; logicalname: string } + | { + type: 'cifs' + hostname: string + path: string + username: string + password: string | null + } + password: T.EncryptedWire + serverId: string + } + | null +} + +// === Complete === + +export interface SetupCompleteRes { + hostname: string // unique.local + rootCa: string + needsRestart: boolean +} + +// === Disk Info Helpers === + +export type StartOSDiskInfoWithId = StartOSDiskInfo & { + id: string +} + +export type StartOSDiskInfoFull = StartOSDiskInfoWithId & { + partition: PartitionInfo + drive: DiskInfo +} diff --git a/web/projects/setup-wizard/src/app/utils/languages.ts b/web/projects/setup-wizard/src/app/utils/languages.ts new file mode 100644 index 000000000..a32ef2123 --- /dev/null +++ b/web/projects/setup-wizard/src/app/utils/languages.ts @@ -0,0 +1,59 @@ +export interface Language { + code: string + name: string + nativeName: string +} + +export interface Keyboard { + code: string + name: string +} + +export const LANGUAGES: Language[] = [ + { code: 'en', name: 'English', nativeName: 'English' }, + { code: 'es', name: 'Spanish', nativeName: 'Español' }, + { code: 'de', name: 'German', nativeName: 'Deutsch' }, + { code: 'fr', name: 'French', nativeName: 'Français' }, + { code: 'pl', name: 'Polish', nativeName: 'Polski' }, +] + +export const KEYBOARDS_BY_LANGUAGE: Record = { + en: [ + { code: 'us', name: 'US English' }, + { code: 'gb', name: 'UK English' }, + ], + es: [ + { code: 'es', name: 'Spanish' }, + { code: 'latam', name: 'Latin American' }, + ], + de: [{ code: 'de', name: 'German' }], + fr: [{ code: 'fr', name: 'French' }], + pl: [{ code: 'pl', name: 'Polish' }], +} + +/** + * Get available keyboards for a language. + * Returns array of keyboards (may be 1 or more). + */ +export function getKeyboardsForLanguage(languageCode: string): Keyboard[] { + return ( + KEYBOARDS_BY_LANGUAGE[languageCode] || [{ code: 'us', name: 'US English' }] + ) +} + +/** + * Check if keyboard selection is needed for a language. + * Returns true if there are multiple keyboard options. + */ +export function needsKeyboardSelection(languageCode: string): boolean { + const keyboards = getKeyboardsForLanguage(languageCode) + return keyboards.length > 1 +} + +/** + * Get the default keyboard for a language. + * Returns the first keyboard option. + */ +export function getDefaultKeyboard(languageCode: string): Keyboard { + return getKeyboardsForLanguage(languageCode)[0]! +} diff --git a/web/projects/setup-wizard/src/styles.scss b/web/projects/setup-wizard/src/styles.scss index 818b4ea92..1e1d12cb2 100644 --- a/web/projects/setup-wizard/src/styles.scss +++ b/web/projects/setup-wizard/src/styles.scss @@ -19,16 +19,29 @@ router-outlet + * { flex-direction: column; align-items: center; box-sizing: border-box; + justify-content: center; padding: 2rem; margin: 0 auto; [tuiCardLarge] { width: 100%; background: var(--tui-background-elevation-2); - margin: auto; + margin-top: 2rem; } } +.inline-title { + display: inline-flex; + align-items: center; + gap: 0.5rem; + + :first-child { + width: 2rem; + height: 2rem; + } + +} + button:disabled { opacity: var(--tui-disabled-opacity); pointer-events: none; @@ -74,4 +87,4 @@ h2 { [tuiCell]:not(:last-of-type) { box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal); -} +} \ No newline at end of file diff --git a/web/projects/shared/src/components/drive.component.ts b/web/projects/shared/src/components/drive.component.ts deleted file mode 100644 index e2f8e14ff..000000000 --- a/web/projects/shared/src/components/drive.component.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { TuiIcon, TuiTitle } from '@taiga-ui/core' -import { Component, Input } from '@angular/core' -import { UnitConversionPipesModule } from '../pipes/unit-conversion/unit-conversion.module' - -@Component({ - selector: 'button[drive]', - template: ` - - - {{ drive.logicalname }} - - {{ drive.vendor || 'Unknown Vendor' }} - - {{ drive.model || 'Unknown Model' }} - - Capacity: {{ drive.capacity | convertBytes }} - - - `, - imports: [TuiIcon, TuiTitle, UnitConversionPipesModule], -}) -export class DriveComponent { - @Input() drive!: { - logicalname: string | null - vendor: string | null - model: string | null - capacity: number - } -} diff --git a/web/projects/shared/src/public-api.ts b/web/projects/shared/src/public-api.ts index cab15d62c..3cd6a4dc3 100644 --- a/web/projects/shared/src/public-api.ts +++ b/web/projects/shared/src/public-api.ts @@ -8,7 +8,6 @@ export * from './classes/rpc-error' export * from './components/initializing/logs-window.component' export * from './components/initializing/initializing.component' export * from './components/ticker.component' -export * from './components/drive.component' export * from './components/markdown.component' export * from './components/prompt.component' export * from './components/server.component' diff --git a/web/projects/shared/src/types/workspace-config.ts b/web/projects/shared/src/types/workspace-config.ts index 5f5f6601d..ebc678ccd 100644 --- a/web/projects/shared/src/types/workspace-config.ts +++ b/web/projects/shared/src/types/workspace-config.ts @@ -10,7 +10,7 @@ export type AccessType = export type WorkspaceConfig = { gitHash: string useMocks: boolean - // each key corresponds to a project and values adjust settings for that project, eg: ui, install-wizard, setup-wizard + // each key corresponds to a project and values adjust settings for that project, eg: ui, setup-wizard ui: { api: { url: string diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 5d243463c..1ec9d6411 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -22,7 +22,6 @@ import { from, interval, map, shareReplay, startWith, Subject, tap } from 'rxjs' import { mockPatchData } from './mock-patch' import { AuthService } from '../auth.service' import { T } from '@start9labs/start-sdk' -import { MarketplacePkg } from '@start9labs/marketplace' import { WebSocketSubject } from 'rxjs/webSocket' import { toAuthorityUrl } from 'src/app/utils/acme'