From 699574e3459a2c858790a74e4ac271e34deae981 Mon Sep 17 00:00:00 2001 From: MangoPig Date: Fri, 19 Jun 2026 22:47:12 +0100 Subject: [PATCH] Fix: Polish bootstrap flow --- .../migrations/000003_installation_name.sql | 9 + Backend/internal/bootstrap/service.go | 34 ++-- .../internal/httpx/api_bootstrap_routes.go | 15 +- .../shell/data/app-shell.context.tsx | 27 ++- .../WorkspaceHome/WorkspaceHome.module.scss | 50 +++++- .../WorkspaceHome/WorkspaceHome.tsx | 169 +++++++++++------- 6 files changed, 205 insertions(+), 99 deletions(-) create mode 100644 Backend/db/migrations/000003_installation_name.sql diff --git a/Backend/db/migrations/000003_installation_name.sql b/Backend/db/migrations/000003_installation_name.sql new file mode 100644 index 0000000..ee1ab8b --- /dev/null +++ b/Backend/db/migrations/000003_installation_name.sql @@ -0,0 +1,9 @@ +-- +goose Up + +ALTER TABLE installations + ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT ''; + +-- +goose Down + +ALTER TABLE installations + DROP COLUMN IF EXISTS name; diff --git a/Backend/internal/bootstrap/service.go b/Backend/internal/bootstrap/service.go index f0da58a..d3184ad 100644 --- a/Backend/internal/bootstrap/service.go +++ b/Backend/internal/bootstrap/service.go @@ -52,6 +52,7 @@ type SaveInstanceInput struct { type SaveModeInput struct { Mode string + Name string } type SaveAdminInput struct { @@ -69,6 +70,7 @@ type SaveStructureInput struct { type InstallationRecord struct { ID string `json:"id"` + Name string `json:"name"` Mode string `json:"mode"` Access string `json:"access"` Protocol string `json:"protocol"` @@ -176,9 +178,10 @@ func NewService(db *database.DB) *Service { func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInput) (InstallationRecord, error) { row := service.db.Pool.QueryRow(ctx, ` - INSERT INTO installations (singleton, mode, access, protocol, host) + INSERT INTO installations (singleton, name, mode, access, protocol, host) VALUES ( TRUE, + COALESCE((SELECT name FROM installations WHERE singleton = TRUE LIMIT 1), ''), COALESCE((SELECT mode FROM installations WHERE singleton = TRUE LIMIT 1), 'personal'::instance_mode), $1::instance_access, $2::instance_protocol, @@ -190,7 +193,7 @@ func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInpu protocol = EXCLUDED.protocol, host = EXCLUDED.host, updated_at = NOW() - RETURNING id::text, mode::text, access::text, protocol::text, host, is_bootstrapped; + RETURNING id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped; `, input.Access, input.Protocol, input.Host) return scanInstallationRecord(row) @@ -198,20 +201,22 @@ func (service *Service) SaveInstance(ctx context.Context, input SaveInstanceInpu func (service *Service) SaveMode(ctx context.Context, input SaveModeInput) (InstallationRecord, error) { row := service.db.Pool.QueryRow(ctx, ` - INSERT INTO installations (singleton, mode, access, protocol, host) + INSERT INTO installations (singleton, name, mode, access, protocol, host) VALUES ( TRUE, + $2, $1::instance_mode, COALESCE((SELECT access FROM installations WHERE singleton = TRUE LIMIT 1), 'local'::instance_access), COALESCE((SELECT protocol FROM installations WHERE singleton = TRUE LIMIT 1), 'http'::instance_protocol), - COALESCE((SELECT host FROM installations WHERE singleton = TRUE LIMIT 1), $2) + COALESCE((SELECT host FROM installations WHERE singleton = TRUE LIMIT 1), $3) ) ON CONFLICT (singleton) DO UPDATE SET + name = EXCLUDED.name, mode = EXCLUDED.mode, updated_at = NOW() - RETURNING id::text, mode::text, access::text, protocol::text, host, is_bootstrapped; - `, input.Mode, defaultInstallationHost) + RETURNING id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped; + `, input.Mode, input.Name, defaultInstallationHost) return scanInstallationRecord(row) } @@ -301,7 +306,7 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn organizationName := strings.TrimSpace(input.OrganizationName) if organizationName == "" { - organizationName = defaultRootOrganizationName(installation.Mode, installation.Host, admin.DisplayName) + organizationName = defaultRootOrganizationName(installation.Name, installation.Mode, installation.Host, admin.DisplayName) } organization, err := upsertNamedRecord(ctx, tx, ` @@ -442,7 +447,7 @@ func (service *Service) ResetDevelopmentState(ctx context.Context) error { func (service *Service) GetInstallation(ctx context.Context) (*InstallationRecord, error) { record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, ` - SELECT id::text, mode::text, access::text, protocol::text, host, is_bootstrapped + SELECT id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped FROM installations WHERE singleton = TRUE LIMIT 1; @@ -597,7 +602,7 @@ func (service *Service) GetAppShellState(ctx context.Context) (AppShellState, er func scanInstallationRecord(row pgx.Row) (InstallationRecord, error) { var record InstallationRecord - if err := row.Scan(&record.ID, &record.Mode, &record.Access, &record.Protocol, &record.Host, &record.IsBootstrapped); err != nil { + if err := row.Scan(&record.ID, &record.Name, &record.Mode, &record.Access, &record.Protocol, &record.Host, &record.IsBootstrapped); err != nil { return InstallationRecord{}, err } @@ -802,7 +807,7 @@ func (service *Service) listWorkspaces(ctx context.Context) ([]WorkspaceRecord, func loadInstallation(ctx context.Context, tx pgx.Tx) (InstallationRecord, error) { return scanInstallationRecord(tx.QueryRow(ctx, ` - SELECT id::text, mode::text, access::text, protocol::text, host, is_bootstrapped + SELECT id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped FROM installations WHERE singleton = TRUE LIMIT 1; @@ -829,7 +834,7 @@ func updateBootstrappedInstallation(ctx context.Context, tx pgx.Tx) (Installatio UPDATE installations SET is_bootstrapped = TRUE, bootstrapped_at = COALESCE(bootstrapped_at, NOW()), updated_at = NOW() WHERE singleton = TRUE - RETURNING id::text, mode::text, access::text, protocol::text, host, is_bootstrapped; + RETURNING id::text, name, mode::text, access::text, protocol::text, host, is_bootstrapped; `)) } @@ -860,10 +865,15 @@ func upsertWorkspace(ctx context.Context, tx pgx.Tx, organizationID, name, slug, return err } -func defaultRootOrganizationName(mode, host, adminDisplayName string) string { +func defaultRootOrganizationName(installationName, mode, host, adminDisplayName string) string { + trimmedInstallationName := strings.TrimSpace(installationName) trimmedHost := strings.TrimSpace(host) trimmedAdminDisplayName := strings.TrimSpace(adminDisplayName) + if trimmedInstallationName != "" { + return trimmedInstallationName + } + if strings.EqualFold(mode, defaultInstallationMode) { if trimmedAdminDisplayName != "" { return fmt.Sprintf("%s %s", trimmedAdminDisplayName, defaultPersonalServerSuffix) diff --git a/Backend/internal/httpx/api_bootstrap_routes.go b/Backend/internal/httpx/api_bootstrap_routes.go index 1fd35f7..ea01a84 100644 --- a/Backend/internal/httpx/api_bootstrap_routes.go +++ b/Backend/internal/httpx/api_bootstrap_routes.go @@ -20,6 +20,7 @@ type bootstrapInstanceStepRequest struct { type bootstrapModeStepRequest struct { Mode string `json:"mode"` + Name string `json:"name"` } type bootstrapAdminStepRequest struct { @@ -234,13 +235,19 @@ func (routes apiRoutes) handleBootstrapModeStep(w http.ResponseWriter, r *http.R return } payload.Mode = strings.ToLower(strings.TrimSpace(payload.Mode)) + payload.Name = strings.TrimSpace(payload.Name) if payload.Mode != "personal" && payload.Mode != "organizational" { WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Mode must be either 'personal' or 'organizational'.") return } - record, err := routes.bootstrapService().SaveMode(r.Context(), bootstrapservice.SaveModeInput{Mode: payload.Mode}) + if payload.Name == "" { + WriteError(w, http.StatusBadRequest, RequestIDFromContext(r.Context()), "invalid_request", "Name is required.") + return + } + + record, err := routes.bootstrapService().SaveMode(r.Context(), bootstrapservice.SaveModeInput{Mode: payload.Mode, Name: payload.Name}) if err != nil { routes.writeBootstrapPersistenceError(w, r, err) return @@ -356,7 +363,11 @@ func (routes apiRoutes) writeBootstrapPersistenceError(w http.ResponseWriter, r WriteError(w, http.StatusConflict, RequestIDFromContext(r.Context()), "bootstrap_prerequisite_missing", err.Error()) default: routes.cfg.Logger.Error("persist bootstrap step", "error", err, "path", r.URL.Path) - WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "bootstrap_persist_failed", "Failed to persist bootstrap data.") + message := "Failed to persist bootstrap data." + if routes.cfg.Config.IsDevelopment() { + message = message + " " + err.Error() + } + WriteError(w, http.StatusInternalServerError, RequestIDFromContext(r.Context()), "bootstrap_persist_failed", message) } } diff --git a/Frontend/src/components/shell/data/app-shell.context.tsx b/Frontend/src/components/shell/data/app-shell.context.tsx index f4df0b2..68ac064 100644 --- a/Frontend/src/components/shell/data/app-shell.context.tsx +++ b/Frontend/src/components/shell/data/app-shell.context.tsx @@ -34,6 +34,7 @@ import { type AppShellInstallation = { id: string; + name: string; mode: "personal" | "organizational" | string; access: string; protocol: string; @@ -151,11 +152,12 @@ const buildRailItems = (payload: AppShellPayload | null): readonly RailItem[] => } const kind = payload.installation.mode === "personal" ? "personal" : "organization"; + const serverName = payload.installation.name || payload.organizations[0]?.name || payload.installation.host; return payload.organizations.map((organization, index) => ({ id: organization.id, - label: organization.name, - abbreviation: buildAbbreviation(organization.name, kind === "personal" ? "P" : "O"), + label: serverName || organization.name, + abbreviation: buildAbbreviation(serverName || organization.name, kind === "personal" ? "P" : "O"), kind, active: index === 0, })); @@ -170,11 +172,12 @@ const buildActiveServer = (payload: AppShellPayload | null): ActiveServer => { } const kind = installation.mode === "personal" ? "personal" : "organization"; + const serverName = installation.name || organization.name || installation.host; return { id: installation.id, - name: organization.name || installation.host || fallbackActiveServer.name, - abbreviation: buildAbbreviation(organization.name || installation.host, kind === "personal" ? "P" : "O"), + name: serverName || fallbackActiveServer.name, + abbreviation: buildAbbreviation(serverName, kind === "personal" ? "P" : "O"), kind, connectedLabel: kind === "organization" ? `${payload?.teams.length ?? 0} connected` : undefined, subtitle: kind === "personal" ? installation.host || payload?.admin?.homeTitle || "Personal home" : undefined, @@ -255,7 +258,7 @@ const buildActiveUserProfile = (payload: AppShellPayload | null): ActiveUserProf return fallbackActiveUserProfile; } - const organizationName = payload.organizations[0]?.name ?? fallbackActiveServer.name; + const organizationName = payload.installation?.name || payload.organizations[0]?.name || fallbackActiveServer.name; const departmentName = payload.departments[0]?.name; return { @@ -282,10 +285,20 @@ export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Elem }, }); - const body = (await response.json()) as { data?: AppShellPayload; error?: { message?: string } }; + const body = (await response.json()) as { + data?: AppShellPayload; + error?: { message?: string } | string; + message?: string; + }; + const errorMessage = + typeof body.message === "string" + ? body.message + : typeof body.error === "string" + ? body.error + : body.error?.message; if (!response.ok || !body.data) { - throw new Error(body.error?.message || "Failed to load app shell state."); + throw new Error(errorMessage || "Failed to load app shell state."); } setPayload(normalizeAppShellPayload(body.data)); diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss index 89d6174..33131bd 100644 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss +++ b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss @@ -92,12 +92,6 @@ flex-wrap: wrap; } -.eyebrow { - @include text-caption; - color: var(--color-text-muted); - text-transform: uppercase; -} - .title { @include text-display; font-family: var(--font-family-display); @@ -246,10 +240,16 @@ color: var(--color-text-muted); } +.fieldHelp { + @include text-caption; + color: var(--color-text-muted); +} + .field input, .field select { min-height: var(--control-size-md); width: 100%; + font: inherit; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface-elevated); @@ -527,13 +527,45 @@ } .wizardPanel { - width: calc(100vw - (var(--space-4) * 2)); - max-height: calc(100dvh - (var(--space-4) * 2)); - margin: var(--space-4) auto; + width: 100vw; + max-height: 100dvh; + margin: 0; padding: var(--space-3); + padding-bottom: calc(var(--space-3) + env(safe-area-inset-bottom, 0px)); + border-radius: 0; + } + + .wizardHeader, + .wizardBody, + .wizardSidebar { + gap: var(--space-3); + } + + .wizardSteps { + grid-auto-flow: column; + grid-auto-columns: minmax(10rem, 1fr); + overflow-x: auto; + padding-bottom: var(--space-1); + scrollbar-width: thin; + } + + .wizardStepButton { + min-width: 10rem; } .wizardFormActions { gap: var(--space-2); + flex-direction: column-reverse; + align-items: stretch; + } + + .wizardFormActions .primaryButton { + margin-left: 0; + } + + .wizardFormActions .primaryButton, + .wizardFormActions .secondaryButton, + .wizardCloseButton { + width: 100%; } } diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx index c6fcea8..d908a26 100644 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx +++ b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx @@ -1,6 +1,6 @@ // Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx -import { For, Show, createEffect, createMemo, createSignal, onMount, type JSX } from "solid-js"; +import { For, Show, createEffect, createMemo, createSignal, type JSX } from "solid-js"; import { Portal } from "solid-js/web"; import { createStore } from "solid-js/store"; import { resolveAPIBase } from "../../../lib/api"; @@ -44,8 +44,6 @@ const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [ }, ]; -const bootstrapCompletionStorageKey = "moku.bootstrap.completed"; - const defaultInstanceForm = { protocol: "http", access: "local", @@ -54,6 +52,7 @@ const defaultInstanceForm = { const defaultModeForm = { mode: "personal", + name: "", } as const; const defaultAdminForm = { @@ -82,27 +81,6 @@ const initialSubmissionState = (): BootstrapSubmissionState => ({ error: "", }); -const readBootstrapCompletion = (): boolean => { - if (typeof window === "undefined") { - return false; - } - - return window.localStorage.getItem(bootstrapCompletionStorageKey) === "true"; -}; - -const writeBootstrapCompletion = (isComplete: boolean): void => { - if (typeof window === "undefined") { - return; - } - - if (isComplete) { - window.localStorage.setItem(bootstrapCompletionStorageKey, "true"); - return; - } - - window.localStorage.removeItem(bootstrapCompletionStorageKey); -}; - const readResponseBody = async (response: Response): Promise => { const raw = await response.text(); @@ -117,6 +95,52 @@ const readResponseBody = async (response: Response): Promise => { } }; +const readResponseError = (step: BootstrapStepKey, data: unknown): string => { + const fallback = `Bootstrap ${step} request failed.`; + + if (typeof data === "string") { + const message = data.trim(); + return message || fallback; + } + + if (!data || typeof data !== "object") { + return fallback; + } + + const record = data as { + error?: string; + message?: string; + requestId?: string; + }; + const message = typeof record.message === "string" ? record.message.trim() : ""; + const errorCode = typeof record.error === "string" ? record.error.trim() : ""; + const requestId = typeof record.requestId === "string" ? record.requestId.trim() : ""; + + if (!message && !errorCode && !requestId) { + return fallback; + } + + const details: string[] = []; + + if (errorCode) { + details.push(`code: ${errorCode}`); + } + + if (requestId) { + details.push(`request: ${requestId}`); + } + + if (message && details.length > 0) { + return `${message} (${details.join(", ")})`; + } + + if (message) { + return message; + } + + return `${fallback} (${details.join(", ")})`; +}; + type WorkspaceHomeProps = { sidebarCollapsed: boolean; onToggleSidebarCollapse: () => void; @@ -139,13 +163,6 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { const [isWizardOpen, setIsWizardOpen] = createSignal(false); const [currentStepIndex, setCurrentStepIndex] = createSignal(0); - onMount(() => { - const isComplete = readBootstrapCompletion(); - setIsBootstrapComplete(isComplete); - setIsWizardOpen(!isComplete); - setIsBootstrapStateResolved(true); - }); - createEffect(() => { if (modeForm.mode === "personal") { setStructureForm("departmentName", personalStructureDefaults.departmentName); @@ -163,29 +180,27 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { }); createEffect(() => { - if (!isBootstrapStateResolved()) { + const shellStatus = appShellData.status(); + + if (shellStatus === "idle" || shellStatus === "loading") { return; } - if (appShellData.status() !== "success") { + if (shellStatus !== "success") { return; } - const installation = appShellData.installation(); + const installationAccessor = appShellData.installation; + const installation = typeof installationAccessor === "function" ? installationAccessor() : undefined; const isPersistedBootstrap = installation?.isBootstrapped ?? false; - if (isPersistedBootstrap) { - return; + if (!isPersistedBootstrap) { + resetWizardState(); } - if (!isBootstrapComplete() && isWizardOpen()) { - return; - } - - writeBootstrapCompletion(false); - setIsBootstrapComplete(false); - setIsWizardOpen(true); - resetWizardState(); + setIsBootstrapComplete(isPersistedBootstrap); + setIsWizardOpen(!isPersistedBootstrap); + setIsBootstrapStateResolved(true); }); const sidebarToggleLabel = (): string => @@ -194,6 +209,8 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { const apiBase = (): string => resolveAPIBase(); const bootstrapTargetLabel = (): string => modeForm.mode === "personal" ? "Personal server" : "Organization server"; + const bootstrapNamePlaceholder = (): string => + modeForm.mode === "personal" ? "Personal server name" : "Organization server name"; const currentStep = createMemo( () => bootstrapStepDefinitions[currentStepIndex()] ?? bootstrapStepDefinitions[0]!, ); @@ -231,11 +248,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { const data = await readResponseBody(response); if (!response.ok) { - throw new Error( - typeof data?.error?.message === "string" - ? data.error.message - : `Bootstrap ${step} request failed.`, - ); + throw new Error(readResponseError(step, data)); } setStepState(step, { @@ -277,9 +290,13 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { if (isLastStep()) { await appShellData.reload(); - writeBootstrapCompletion(true); - setIsBootstrapComplete(true); - setIsWizardOpen(false); + const installationAccessor = appShellData.installation; + const installation = typeof installationAccessor === "function" ? installationAccessor() : undefined; + const isPersistedBootstrap = installation?.isBootstrapped ?? false; + + setIsBootstrapComplete(isPersistedBootstrap); + setIsWizardOpen(!isPersistedBootstrap); + setIsBootstrapStateResolved(true); return; } @@ -343,7 +360,6 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
- Bootstrap

{isBootstrapComplete() ? appShellData.activeServer().name : bootstrapTargetLabel()}

@@ -441,15 +457,27 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { - - - + + <> + + + + <> @@ -473,13 +501,16 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { + setAdminForm("password", event.currentTarget.value)} + placeholder="Create a strong password" + /> + + Use at least 12 characters with uppercase, lowercase, numbers, and symbols. + +