frontend support for setting and changing hostname

This commit is contained in:
Matt Hill
2026-02-24 10:27:22 -07:00
parent d1162272f0
commit 86ecc4cc99
26 changed files with 9381 additions and 188 deletions

View File

@@ -31,23 +31,15 @@ export class AppComponent {
switch (status.status) {
case 'needs-install':
// Restore keyboard from status if it was previously set
if (status.keyboard) {
this.stateService.keyboard = status.keyboard.layout
}
// 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
// Restore keyboard from status if it was previously set
if (status.keyboard) {
this.stateService.keyboard = status.keyboard.layout
if (status.guid) {
this.stateService.dataDriveGuid = status.guid
}
this.stateService.attach = status.attach
await this.router.navigate(['/language'])
break

View File

@@ -8,11 +8,17 @@ import {
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import {
ErrorService,
generateHostname,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk'
import {
TuiButton,
TuiError,
TuiHint,
TuiIcon,
TuiTextfield,
TuiTitle,
@@ -20,6 +26,7 @@ import {
import {
TuiFieldErrorPipe,
TuiPassword,
TuiTooltip,
tuiValidationErrorsProvider,
} from '@taiga-ui/kit'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
@@ -31,31 +38,49 @@ import { StateService } from '../services/state.service'
<header tuiHeader>
<h2 tuiTitle>
{{
isRequired
? ('Set Master Password' | i18n)
isFresh
? ('Set Up Your Server' | i18n)
: ('Set New Password (Optional)' | i18n)
}}
<span tuiSubtitle>
{{
isRequired
? ('Make it good. Write it down.' | i18n)
: ('Skip to keep your existing password.' | i18n)
}}
</span>
</h2>
</header>
<form [formGroup]="form" (ngSubmit)="submit()">
<tui-textfield>
@if (isFresh) {
<tui-textfield>
<label tuiLabel>{{ 'Server Hostname' | i18n }}</label>
<input tuiTextfield tuiAutoFocus formControlName="hostname" />
<span class="local-suffix">.local</span>
<button
tuiIconButton
type="button"
appearance="icon"
iconStart="@tui.refresh-cw"
size="xs"
[tuiHint]="'Randomize' | i18n"
(click)="randomizeHostname()"
></button>
<tui-icon
[tuiTooltip]="
'This value will be used as your server hostname and mDNS address on the LAN. Only lowercase letters, numbers, and hyphens are allowed.'
| i18n
"
/>
</tui-textfield>
<tui-error
formControlName="hostname"
[error]="[] | tuiFieldError | async"
/>
}
<tui-textfield [style.margin-top.rem]="isFresh ? 1 : 0">
<label tuiLabel>
{{
isRequired ? ('Enter Password' | i18n) : ('New Password' | i18n)
}}
{{ isFresh ? ('Password' | i18n) : ('New Password' | i18n) }}
</label>
<input
tuiTextfield
type="password"
tuiAutoFocus
[tuiAutoFocus]="!isFresh"
maxlength="64"
formControlName="password"
/>
@@ -87,14 +112,14 @@ import { StateService } from '../services/state.service'
<button
tuiButton
[disabled]="
isRequired
isFresh
? form.invalid
: form.controls.password.value && form.invalid
"
>
{{ 'Finish' | i18n }}
</button>
@if (!isRequired) {
@if (!isFresh) {
<button
tuiButton
appearance="secondary"
@@ -109,6 +134,10 @@ import { StateService } from '../services/state.service'
</section>
`,
styles: `
.local-suffix {
color: var(--tui-text-secondary);
}
footer {
display: flex;
flex-direction: column;
@@ -131,6 +160,8 @@ import { StateService } from '../services/state.service'
TuiMapperPipe,
TuiHeader,
TuiTitle,
TuiHint,
TuiTooltip,
i18nPipe,
],
providers: [
@@ -139,6 +170,7 @@ import { StateService } from '../services/state.service'
minlength: 'Must be 12 characters or greater',
maxlength: 'Must be 64 character or less',
match: 'Passwords do not match',
pattern: 'Only lowercase letters, numbers, and hyphens allowed',
}),
],
})
@@ -149,16 +181,20 @@ export default class PasswordPage {
private readonly stateService = inject(StateService)
private readonly i18n = inject(i18nPipe)
// Password is required only for fresh install
readonly isRequired = this.stateService.setupType === 'fresh'
// Fresh install requires password and hostname
readonly isFresh = this.stateService.setupType === 'fresh'
readonly form = new FormGroup({
password: new FormControl('', [
...(this.isRequired ? [Validators.required] : []),
...(this.isFresh ? [Validators.required] : []),
Validators.minLength(12),
Validators.maxLength(64),
]),
confirm: new FormControl(''),
hostname: new FormControl(generateHostname(), [
Validators.required,
Validators.pattern(/^[a-z0-9][a-z0-9-]*$/),
]),
})
readonly validator = (value: string) => (control: AbstractControl) =>
@@ -166,6 +202,10 @@ export default class PasswordPage {
? null
: { match: this.i18n.transform('Passwords do not match') }
randomizeHostname() {
this.form.controls.hostname.setValue(generateHostname())
}
async skip() {
// Skip means no new password - pass null
await this.executeSetup(null)
@@ -177,13 +217,14 @@ export default class PasswordPage {
private async executeSetup(password: string | null) {
const loader = this.loader.open('Starting setup').subscribe()
const hostname = this.form.controls.hostname.value || generateHostname()
try {
if (this.stateService.setupType === 'attach') {
await this.stateService.attachDrive(password)
await this.stateService.attachDrive(password, hostname)
} else {
// fresh, restore, or transfer - all use execute
await this.stateService.executeSetup(password)
await this.stateService.executeSetup(password, hostname)
}
await this.router.navigate(['/loading'])

View File

@@ -20,7 +20,7 @@ import { StateService } from '../services/state.service'
import { DocumentationComponent } from '../components/documentation.component'
import { MatrixComponent } from '../components/matrix.component'
import { RemoveMediaDialog } from '../components/remove-media.dialog'
import { SetupCompleteRes } from '../types'
import { T } from '@start9labs/start-sdk'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
@Component({
@@ -193,7 +193,7 @@ export default class SuccessPage implements AfterViewInit {
readonly stateService = inject(StateService)
result?: SetupCompleteRes
result?: T.SetupResult
lanAddress = ''
downloaded = false
usbRemoved = false
@@ -232,14 +232,11 @@ export default class SuccessPage implements AfterViewInit {
removeMedia() {
this.dialogs
.openComponent<boolean>(
new PolymorpheusComponent(RemoveMediaDialog),
{
size: 's',
dismissible: false,
closeable: false,
},
)
.openComponent<boolean>(new PolymorpheusComponent(RemoveMediaDialog), {
size: 's',
dismissible: false,
closeable: false,
})
.subscribe(() => {
this.usbRemoved = true
})

View File

@@ -7,24 +7,16 @@ import {
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { Observable } from 'rxjs'
import {
SetupStatusRes,
InstallOsParams,
InstallOsRes,
AttachParams,
SetupExecuteParams,
SetupCompleteRes,
EchoReq,
} from '../types'
import { InstallOsParams, InstallOsRes } from '../types'
export abstract class ApiService {
pubkey?: jose.JWK.Key
// echo
abstract echo(params: EchoReq, url: string): Promise<string>
abstract echo(params: T.EchoParams, url: string): Promise<string>
// Status & Setup
abstract getStatus(): Promise<SetupStatusRes> // setup.status
abstract getStatus(): Promise<T.SetupStatusRes> // setup.status
abstract getPubKey(): Promise<void> // setup.get-pubkey
abstract setKeyboard(params: FullKeyboard): Promise<null> // setup.set-keyboard
abstract setLanguage(params: SetLanguageParams): Promise<null> // setup.set-language
@@ -34,8 +26,8 @@ export abstract class ApiService {
abstract installOs(params: InstallOsParams): Promise<InstallOsRes> // setup.install-os
// Setup execution
abstract attach(params: AttachParams): Promise<T.SetupProgress> // setup.attach
abstract execute(params: SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
abstract attach(params: T.AttachParams): Promise<T.SetupProgress> // setup.attach
abstract execute(params: T.SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
// Recovery helpers
abstract verifyCifs(
@@ -43,7 +35,7 @@ export abstract class ApiService {
): Promise<Record<string, StartOSDiskInfo>> // setup.cifs.verify
// Completion
abstract complete(): Promise<SetupCompleteRes> // setup.complete
abstract complete(): Promise<T.SetupResult> // setup.complete
abstract exit(): Promise<void> // setup.exit
abstract shutdown(): Promise<void> // setup.shutdown

View File

@@ -15,15 +15,7 @@ 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'
import { InstallOsParams, InstallOsRes } from '../types'
@Injectable({
providedIn: 'root',
@@ -46,12 +38,12 @@ export class LiveApiService extends ApiService {
})
}
async echo(params: EchoReq, url: string): Promise<string> {
async echo(params: T.EchoParams, url: string): Promise<string> {
return this.rpcRequest({ method: 'echo', params }, url)
}
async getStatus() {
return this.rpcRequest<SetupStatusRes>({
return this.rpcRequest<T.SetupStatusRes>({
method: 'setup.status',
params: {},
})
@@ -101,14 +93,14 @@ export class LiveApiService extends ApiService {
})
}
async attach(params: AttachParams) {
async attach(params: T.AttachParams) {
return this.rpcRequest<T.SetupProgress>({
method: 'setup.attach',
params,
})
}
async execute(params: SetupExecuteParams) {
async execute(params: T.SetupExecuteParams) {
if (params.recoverySource?.type === 'backup') {
const target = params.recoverySource.target
if (target.type === 'cifs') {
@@ -130,7 +122,7 @@ export class LiveApiService extends ApiService {
}
async complete() {
const res = await this.rpcRequest<SetupCompleteRes>({
const res = await this.rpcRequest<T.SetupResult>({
method: 'setup.complete',
params: {},
})

View File

@@ -11,15 +11,7 @@ import { T } from '@start9labs/start-sdk'
import * as jose from 'node-jose'
import { interval, map, Observable } from 'rxjs'
import { ApiService } from './api.service'
import {
SetupStatusRes,
InstallOsParams,
InstallOsRes,
AttachParams,
SetupExecuteParams,
SetupCompleteRes,
EchoReq,
} from '../types'
import { InstallOsParams, InstallOsRes } from '../types'
@Injectable({
providedIn: 'root',
@@ -53,7 +45,7 @@ export class MockApiService extends ApiService {
}
}
async echo(params: EchoReq, url: string): Promise<string> {
async echo(params: T.EchoParams, url: string): Promise<string> {
if (url) {
const num = Math.floor(Math.random() * 10) + 1
if (num > 8) return params.message
@@ -63,23 +55,27 @@ export class MockApiService extends ApiService {
return params.message
}
async getStatus(): Promise<SetupStatusRes> {
async getStatus(): Promise<T.SetupStatusRes> {
await pauseFor(500)
this.statusIndex++
if (this.statusIndex === 1) {
return { status: 'needs-install', keyboard: null }
return { status: 'needs-install' }
// return {
// status: 'incomplete',
// attach: false,
// guid: 'mock-data-guid',
// keyboard: null,
// }
}
if (this.statusIndex > 3) {
return { status: 'complete' }
return {
status: 'complete',
hostname: 'adjective-noun',
rootCa: encodeBase64(ROOT_CA),
needsRestart: this.installCompleted,
}
}
return {
@@ -147,7 +143,7 @@ export class MockApiService extends ApiService {
}
}
async attach(params: AttachParams): Promise<T.SetupProgress> {
async attach(params: T.AttachParams): Promise<T.SetupProgress> {
await pauseFor(1000)
this.statusIndex = 1 // Jump to running state
return {
@@ -156,7 +152,7 @@ export class MockApiService extends ApiService {
}
}
async execute(params: SetupExecuteParams): Promise<T.SetupProgress> {
async execute(params: T.SetupExecuteParams): Promise<T.SetupProgress> {
await pauseFor(1000)
this.statusIndex = 1 // Jump to running state
return {
@@ -173,7 +169,7 @@ export class MockApiService extends ApiService {
}
}
async complete(): Promise<SetupCompleteRes> {
async complete(): Promise<T.SetupResult> {
await pauseFor(500)
return {
hostname: 'adjective-noun',

View File

@@ -48,18 +48,19 @@ export class StateService {
/**
* Called for attach flow (existing data drive)
*/
async attachDrive(password: string | null): Promise<void> {
async attachDrive(password: string | null, hostname: string): Promise<void> {
await this.api.attach({
guid: this.dataDriveGuid,
password: password ? await this.api.encrypt(password) : null,
hostname,
})
}
/**
* Called for fresh, restore, and transfer flows
* password is required for fresh, optional for restore/transfer
* Password is required for fresh, optional for restore/transfer
*/
async executeSetup(password: string | null): Promise<void> {
async executeSetup(password: string | null, hostname: string): Promise<void> {
let recoverySource: T.RecoverySource<T.EncryptedWire> | null = null
if (this.recoverySource) {
@@ -78,7 +79,9 @@ export class StateService {
await this.api.execute({
guid: this.dataDriveGuid,
// @ts-expect-error TODO: backend should make password optional for restore/transfer
password: password ? await this.api.encrypt(password) : null,
hostname,
recoverySource,
})
}

View File

@@ -1,31 +1,6 @@
import {
DiskInfo,
FullKeyboard,
PartitionInfo,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { DiskInfo, PartitionInfo, StartOSDiskInfo } from '@start9labs/shared'
// === Echo ===
export type EchoReq = {
message: string
}
// === Setup Status ===
export type SetupStatusRes =
| { status: 'needs-install'; keyboard: FullKeyboard | null }
| {
status: 'incomplete'
guid: string
attach: boolean
keyboard: FullKeyboard | null
}
| { status: 'running'; progress: T.FullProgress; guid: string }
| { status: 'complete' }
// === Install OS ===
// === Install OS === (no binding available)
export interface InstallOsParams {
osDrive: string // e.g. /dev/sda
@@ -40,48 +15,6 @@ export interface InstallOsRes {
attach: boolean
}
// === Attach ===
export interface AttachParams {
password: T.EncryptedWire | null
guid: string // data drive
}
// === Execute ===
export interface SetupExecuteParams {
guid: string
password: T.EncryptedWire | null // null = keep existing password (for restore/transfer)
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 & {