mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-01 04:53:40 +00:00
frontend support for setting and changing hostname
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
Reference in New Issue
Block a user