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

@@ -283,7 +283,7 @@ core/bindings/index.ts: $(call ls-files, core) $(ENVIRONMENT_FILE)
rm -rf core/bindings
./core/build/build-ts.sh
ls core/bindings/*.ts | sed 's/core\/bindings\/\([^.]*\)\.ts/export { \1 } from ".\/\1";/g' | grep -v '"./index"' | tee core/bindings/index.ts
npm --prefix sdk/base exec -- prettier --config=./sdk/base/package.json -w ./core/bindings/*.ts
npm --prefix sdk/base exec -- prettier --config=./sdk/base/package.json -w './core/bindings/**/*.ts'
touch core/bindings/index.ts
sdk/dist/package.json sdk/baseDist/package.json: $(call ls-files, sdk) sdk/base/lib/osBindings/index.ts

View File

@@ -23,19 +23,14 @@ Pending tasks for AI agents. Remove items when completed.
other crate types. Extracting them requires either moving the type definitions into the sub-crate
(and importing them back into `start-os`) or restructuring to share a common types crate.
- [ ] Use auto-generated RPC types in the frontend instead of manual duplicates
- [ ] Make `SetupExecuteParams.password` optional in the backend - @dr-bonez
**Problem**: The web frontend manually defines ~755 lines of API request/response types in
`web/projects/ui/src/app/services/api/api.types.ts` that can drift from the actual Rust types.
**Problem**: In `core/src/setup.rs`, `SetupExecuteParams` has `password: EncryptedWire` (non-nullable),
but the frontend needs to send `null` for restore/transfer flows where the user keeps their existing
password. The `AttachParams` type correctly uses `Option<EncryptedWire>` for this purpose.
**Current state**: The Rust backend already has `#[ts(export)]` on RPC param types (e.g.
`AddTunnelParams`, `SetWifiEnabledParams`, `LoginParams`), and they are generated into
`core/bindings/`. However, commit `71b83245b` ("Chore/unexport api ts #2585", April 2024)
deliberately stopped building them into the SDK and had the frontend maintain its own types.
**Goal**: Reverse that decision — pipe the generated RPC types through the SDK into the frontend
so `api.types.ts` can import them instead of duplicating them. This eliminates drift between
backend and frontend API contracts.
**Fix**: Change `password: EncryptedWire` to `password: Option<EncryptedWire>` in `SetupExecuteParams`
and handle the `None` case in the `execute` handler (similar to how `attach` handles it).
- [ ] Auto-configure port forwards via UPnP/NAT-PMP/PCP - @dr-bonez

View File

@@ -5,4 +5,5 @@ export type AttachParams = {
password: EncryptedWire | null
guid: string
kiosk?: boolean
hostname: string | null
}

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SetServerHostnameParams = { hostname: string }

View File

@@ -7,4 +7,5 @@ export type SetupExecuteParams = {
password: EncryptedWire
recoverySource: RecoverySource<EncryptedWire> | null
kiosk?: boolean
hostname: string | null
}

View File

@@ -257,6 +257,7 @@ export { SetMainStatusStatus } from './SetMainStatusStatus'
export { SetMainStatus } from './SetMainStatus'
export { SetNameParams } from './SetNameParams'
export { SetOutboundGatewayParams } from './SetOutboundGatewayParams'
export { SetServerHostnameParams } from './SetServerHostnameParams'
export { SetStaticDnsParams } from './SetStaticDnsParams'
export { SetupExecuteParams } from './SetupExecuteParams'
export { SetupInfo } from './SetupInfo'

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
if (status.guid) {
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
}
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()">
@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),
{
.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 & {

View File

@@ -45,6 +45,9 @@ import { i18nKey } from '../i18n/i18n.providers'
</button>
}
</tui-textfield>
@if (error) {
<p class="error">{{ error }}</p>
}
<footer class="g-buttons">
<button
tuiButton
@@ -65,6 +68,10 @@ import { i18nKey } from '../i18n/i18n.providers'
color: var(--tui-status-warning);
}
.error {
color: var(--tui-status-negative);
}
.button {
pointer-events: auto;
margin-left: 0.25rem;
@@ -83,6 +90,7 @@ export class PromptModal {
masked = this.options.useMask
value = this.options.initialValue || ''
error = ''
get options(): PromptOptions {
return this.context.data
@@ -94,6 +102,12 @@ export class PromptModal {
submit(value: string) {
if (value || !this.options.required) {
const { pattern, patternError } = this.options
if (pattern && !new RegExp(pattern).test(value)) {
this.error = patternError || 'Invalid input'
return
}
this.error = ''
this.context.$implicit.next(value)
}
}
@@ -110,4 +124,6 @@ export interface PromptOptions {
required?: boolean
useMask?: boolean
initialValue?: string | null
pattern?: string
patternError?: string
}

View File

@@ -605,10 +605,8 @@ export default {
657: 'Wählen Sie das Laufwerk mit Ihren bestehenden StartOS-Daten aus',
658: 'Laufwerk auswählen',
659: 'Keine StartOS-Datenlaufwerke gefunden',
660: 'Master-Passwort festlegen',
660: 'Richten Sie Ihren Server ein',
661: 'Neues Passwort festlegen (optional)',
662: 'Machen Sie es gut. Schreiben Sie es auf.',
663: 'Überspringen, um Ihr bestehendes Passwort beizubehalten.',
664: 'Passwort eingeben',
665: 'Passwort bestätigen',
666: 'Fertigstellen',
@@ -693,4 +691,12 @@ export default {
755: 'Schnittstelle(n)',
756: 'Keine Portweiterleitungsregeln',
757: 'Portweiterleitungsregeln am Gateway erforderlich',
758: 'Server-Hostname',
760: 'Dieser Wert wird als Hostname Ihres Servers und mDNS-Adresse im LAN verwendet. Nur Kleinbuchstaben, Zahlen und Bindestriche sind erlaubt.',
761: 'Zufällig generieren',
762: 'Nur Kleinbuchstaben, Zahlen und Bindestriche erlaubt',
763: 'Sie sind derzeit über Ihre .local-Adresse verbunden. Das Ändern des Hostnamens erfordert einen Wechsel zur neuen .local-Adresse.',
764: 'Hostname geändert',
765: 'Neue Adresse öffnen',
766: 'Ihr Server ist jetzt erreichbar unter',
} satisfies i18n

View File

@@ -605,10 +605,8 @@ export const ENGLISH: Record<string, number> = {
'Select the drive containing your existing StartOS data': 657,
'Select Drive': 658,
'No StartOS data drives found': 659,
'Set Master Password': 660,
'Set Up Your Server': 660,
'Set New Password (Optional)': 661,
'Make it good. Write it down.': 662,
'Skip to keep your existing password.': 663,
'Enter Password': 664,
'Confirm Password': 665,
'Finish': 666,
@@ -693,4 +691,12 @@ export const ENGLISH: Record<string, number> = {
'Interface(s)': 755,
'No port forwarding rules': 756,
'Port forwarding rules required on gateway': 757,
'Server Hostname': 758,
'This value will be used as your server hostname and mDNS address on the LAN. Only lowercase letters, numbers, and hyphens are allowed.': 760,
'Randomize': 761,
'Only lowercase letters, numbers, and hyphens allowed': 762,
'You are currently connected via your .local address. Changing the hostname will require you to switch to the new .local address.': 763,
'Hostname Changed': 764,
'Open new address': 765,
'Your server is now reachable at': 766,
}

View File

@@ -605,10 +605,8 @@ export default {
657: 'Seleccione la unidad que contiene sus datos StartOS existentes',
658: 'Seleccionar unidad',
659: 'No se encontraron unidades de datos StartOS',
660: 'Establecer contraseña maestra',
660: 'Configure su servidor',
661: 'Establecer nueva contraseña (opcional)',
662: 'Que sea buena. Escríbala.',
663: 'Omitir para mantener su contraseña existente.',
664: 'Introducir contraseña',
665: 'Confirmar contraseña',
666: 'Finalizar',
@@ -693,4 +691,12 @@ export default {
755: 'Interfaz/Interfaces',
756: 'Sin reglas de redirección de puertos',
757: 'Reglas de redirección de puertos requeridas en la puerta de enlace',
758: 'Nombre de host del servidor',
760: 'Este valor se usará como el nombre de host de su servidor y la dirección mDNS en la LAN. Solo se permiten letras minúsculas, números y guiones.',
761: 'Aleatorizar',
762: 'Solo se permiten letras minúsculas, números y guiones',
763: 'Actualmente está conectado a través de su dirección .local. Cambiar el nombre de host requerirá que cambie a la nueva dirección .local.',
764: 'Nombre de host cambiado',
765: 'Abrir nueva dirección',
766: 'Su servidor ahora es accesible en',
} satisfies i18n

View File

@@ -605,10 +605,8 @@ export default {
657: 'Sélectionnez le disque contenant vos données StartOS existantes',
658: 'Sélectionner le disque',
659: 'Aucun disque de données StartOS trouvé',
660: 'Définir le mot de passe maître',
660: 'Configurez votre serveur',
661: 'Définir un nouveau mot de passe (facultatif)',
662: 'Choisissez-le bien. Notez-le.',
663: 'Ignorer pour conserver votre mot de passe existant.',
664: 'Saisir le mot de passe',
665: 'Confirmer le mot de passe',
666: 'Terminer',
@@ -693,4 +691,12 @@ export default {
755: 'Interface(s)',
756: 'Aucune règle de redirection de port',
757: 'Règles de redirection de ports requises sur la passerelle',
758: "Nom d'hôte du serveur",
760: "Cette valeur sera utilisée comme nom d'hôte de votre serveur et adresse mDNS sur le LAN. Seules les lettres minuscules, les chiffres et les tirets sont autorisés.",
761: 'Générer aléatoirement',
762: 'Seules les lettres minuscules, les chiffres et les tirets sont autorisés',
763: "Vous êtes actuellement connecté via votre adresse .local. Changer le nom d'hôte nécessitera de passer à la nouvelle adresse .local.",
764: "Nom d'hôte modifié",
765: 'Ouvrir la nouvelle adresse',
766: 'Votre serveur est maintenant accessible à',
} satisfies i18n

View File

@@ -605,10 +605,8 @@ export default {
657: 'Wybierz dysk zawierający istniejące dane StartOS',
658: 'Wybierz dysk',
659: 'Nie znaleziono dysków danych StartOS',
660: 'Ustaw hasło główne',
660: 'Skonfiguruj swój serwer',
661: 'Ustaw nowe hasło (opcjonalnie)',
662: 'Zadbaj o nie. Zapisz je.',
663: 'Pomiń, aby zachować istniejące hasło.',
664: 'Wprowadź hasło',
665: 'Potwierdź hasło',
666: 'Zakończ',
@@ -693,4 +691,12 @@ export default {
755: 'Interfejs(y)',
756: 'Brak reguł przekierowania portów',
757: 'Reguły przekierowania portów wymagane na bramce',
758: 'Nazwa hosta serwera',
760: 'Ta wartość będzie używana jako nazwa hosta serwera i adres mDNS w sieci LAN. Dozwolone są tylko małe litery, cyfry i myślniki.',
761: 'Losuj',
762: 'Dozwolone są tylko małe litery, cyfry i myślniki',
763: 'Jesteś obecnie połączony przez adres .local. Zmiana nazwy hosta będzie wymagać przełączenia na nowy adres .local.',
764: 'Nazwa hosta zmieniona',
765: 'Otwórz nowy adres',
766: 'Twój serwer jest teraz dostępny pod adresem',
} satisfies i18n

View File

@@ -61,3 +61,4 @@ export * from './util/to-local-iso-string'
export * from './util/unused'
export * from './util/keyboards'
export * from './util/languages'
export * from './util/hostname'

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@ import {
LANGUAGE_TO_CODE,
LoadingService,
} from '@start9labs/shared'
import { WA_WINDOW } from '@ng-web-apis/common'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { TuiAnimated } from '@taiga-ui/cdk'
import {
@@ -45,7 +46,7 @@ import {
import { TuiCell, tuiCellOptionsProvider } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs'
import { filter, Subscription } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { OSService } from 'src/app/services/os.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -94,6 +95,18 @@ import { KeyboardSelectComponent } from './keyboard-select.component'
}
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.server" />
<span tuiTitle>
<strong>{{ 'Server Hostname' | i18n }}</strong>
<span tuiSubtitle>
{{ server.hostname }}
</span>
</span>
<button tuiButton (click)="onHostname()">
{{ 'Change' | i18n }}
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.app-window" />
<span tuiTitle>
@@ -272,6 +285,7 @@ export default class SystemGeneralComponent {
private readonly dialog = inject(DialogService)
private readonly i18n = inject(i18nPipe)
private readonly injector = inject(INJECTOR)
private readonly win = inject(WA_WINDOW)
count = 0
@@ -346,6 +360,82 @@ export default class SystemGeneralComponent {
}
}
onHostname() {
const sub = this.dialog
.openPrompt<string>({
label: 'Server Hostname',
data: {
label: 'Hostname' as i18nKey,
message:
'This value will be used as your server hostname and mDNS address on the LAN. Only lowercase letters, numbers, and hyphens are allowed.',
placeholder: 'start9' as i18nKey,
required: true,
buttonText: 'Save',
initialValue: this.server()?.hostname || '',
pattern: '^[a-z0-9][a-z0-9-]*$',
patternError:
'Hostname must contain only lowercase letters, numbers, and hyphens, and cannot start with a hyphen.',
},
})
.subscribe(hostname => {
if (this.win.location.hostname.endsWith('.local')) {
this.confirmHostnameChange(hostname, sub)
} else {
this.saveHostname(hostname, sub)
}
})
}
private confirmHostnameChange(hostname: string, promptSub: Subscription) {
this.dialog
.openConfirm({
label: 'Warning',
data: {
content:
'You are currently connected via your .local address. Changing the hostname will require you to switch to the new .local address.',
yes: 'Save',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.saveHostname(hostname, promptSub, true))
}
private async saveHostname(
hostname: string,
promptSub: Subscription,
wasLocal = false,
) {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.setHostname({ hostname })
if (wasLocal) {
const { protocol, port } = this.win.location
const newUrl = `${protocol}//${hostname}.local${port ? ':' + port : ''}`
this.dialog
.openConfirm({
label: 'Hostname Changed',
data: {
content:
`${this.i18n.transform('Your server is now reachable at')} ${hostname}.local` as i18nKey,
yes: 'Open new address',
no: 'Dismiss',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.win.open(newUrl, '_blank'))
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
promptSub.unsubscribe()
}
}
onTitle() {
const sub = this.dialog
.openPrompt<string>({

View File

@@ -121,6 +121,8 @@ export abstract class ApiService {
abstract toggleKiosk(enable: boolean): Promise<null>
abstract setHostname(params: { hostname: string }): Promise<null>
abstract setKeyboard(params: FullKeyboard): Promise<null>
abstract setLanguage(params: SetLanguageParams): Promise<null>

View File

@@ -255,6 +255,10 @@ export class LiveApiService extends ApiService {
})
}
async setHostname(params: { hostname: string }): Promise<null> {
return this.rpcRequest({ method: 'server.set-hostname', params })
}
async setKeyboard(params: FullKeyboard): Promise<null> {
return this.rpcRequest({ method: 'server.set-keyboard', params })
}

View File

@@ -447,6 +447,21 @@ export class MockApiService extends ApiService {
return null
}
async setHostname(params: { hostname: string }): Promise<null> {
await pauseFor(1000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/hostname',
value: params.hostname,
},
]
this.mockRevision(patch)
return null
}
async setKeyboard(params: FullKeyboard): Promise<null> {
await pauseFor(1000)