diff --git a/Backend/internal/bootstrap/service.go b/Backend/internal/bootstrap/service.go index d7a1cd2..f0da58a 100644 --- a/Backend/internal/bootstrap/service.go +++ b/Backend/internal/bootstrap/service.go @@ -410,6 +410,36 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn }, nil } +func (service *Service) ResetDevelopmentState(ctx context.Context) error { + tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + if _, err := tx.Exec(ctx, ` + TRUNCATE TABLE + project_memberships, + team_memberships, + organization_memberships, + workspaces, + projects, + teams, + departments, + user_homes, + users, + organizations, + installations + RESTART IDENTITY; + `); err != nil { + return err + } + + return tx.Commit(ctx) +} + 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 diff --git a/Backend/internal/httpx/api_bootstrap_routes.go b/Backend/internal/httpx/api_bootstrap_routes.go index 8063e60..1fd35f7 100644 --- a/Backend/internal/httpx/api_bootstrap_routes.go +++ b/Backend/internal/httpx/api_bootstrap_routes.go @@ -166,6 +166,28 @@ func (routes apiRoutes) handleAppShellState(w http.ResponseWriter, r *http.Reque }) } +func (routes apiRoutes) handleDevelopmentBootstrapReset(w http.ResponseWriter, r *http.Request) { + if !routes.cfg.Config.IsDevelopment() { + WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", "The requested endpoint does not exist.") + return + } + + if err := routes.bootstrapService().ResetDevelopmentState(r.Context()); err != nil { + routes.writeBootstrapPersistenceError(w, r, err) + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "reset": true, + }, + "meta": map[string]any{ + "resource": "development-bootstrap-reset", + "developmentOnly": true, + }, + }) +} + func (routes apiRoutes) handleBootstrapInstanceStep(w http.ResponseWriter, r *http.Request) { payload, ok := decodeBootstrapRequest[bootstrapInstanceStepRequest](w, r) if !ok { diff --git a/Backend/internal/httpx/api_routes.go b/Backend/internal/httpx/api_routes.go index 00f4a38..65271d5 100644 --- a/Backend/internal/httpx/api_routes.go +++ b/Backend/internal/httpx/api_routes.go @@ -33,6 +33,10 @@ func (routes apiRoutes) Register(router chi.Router) { apiRouter.Get("/app-shell", routes.handleAppShellState) apiRouter.Get("/organizations", routes.handleOrganizations) apiRouter.Get("/workspaces", routes.handleWorkspaces) + + if routes.cfg.Config.IsDevelopment() { + apiRouter.Post("/dev/bootstrap/reset", routes.handleDevelopmentBootstrapReset) + } }) } diff --git a/Frontend/src/components/shell/data/app-shell.context.tsx b/Frontend/src/components/shell/data/app-shell.context.tsx index c2b4dfd..f4df0b2 100644 --- a/Frontend/src/components/shell/data/app-shell.context.tsx +++ b/Frontend/src/components/shell/data/app-shell.context.tsx @@ -100,9 +100,20 @@ type AppShellPayload = { workspaces: AppShellWorkspace[]; }; +const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({ + installation: payload?.installation, + admin: payload?.admin, + organizations: Array.isArray(payload?.organizations) ? payload.organizations : [], + departments: Array.isArray(payload?.departments) ? payload.departments : [], + teams: Array.isArray(payload?.teams) ? payload.teams : [], + projects: Array.isArray(payload?.projects) ? payload.projects : [], + workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [], +}); + type AppShellContextValue = { status: Accessor<"idle" | "loading" | "success" | "error">; error: Accessor; + installation: Accessor; railItems: Accessor; activeServer: Accessor; activeProject: Accessor; @@ -277,7 +288,7 @@ export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Elem throw new Error(body.error?.message || "Failed to load app shell state."); } - setPayload(body.data); + setPayload(normalizeAppShellPayload(body.data)); setStatus("success"); } catch (loadError) { setStatus("error"); @@ -292,6 +303,7 @@ export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Elem const value: AppShellContextValue = { status, error, + installation: createMemo(() => payload()?.installation), railItems: createMemo(() => buildRailItems(payload())), activeServer: createMemo(() => buildActiveServer(payload())), activeProject: createMemo(() => buildActiveProject(payload())), diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss index 86c2446..89d6174 100644 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss +++ b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss @@ -261,6 +261,14 @@ background 160ms var(--easing-standard); } +.field input:disabled, +.field select:disabled { + cursor: not-allowed; + color: var(--color-text-muted); + background: color-mix(in srgb, var(--color-surface-secondary) 92%, transparent); + border-color: color-mix(in srgb, var(--color-border) 72%, transparent); +} + .field input:focus-visible, .field select:focus-visible { outline: none; diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx index ee2f202..c6fcea8 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, createMemo, createSignal, onMount, type JSX } from "solid-js"; +import { For, Show, createEffect, createMemo, createSignal, onMount, type JSX } from "solid-js"; import { Portal } from "solid-js/web"; import { createStore } from "solid-js/store"; import { resolveAPIBase } from "../../../lib/api"; @@ -46,6 +46,37 @@ const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [ const bootstrapCompletionStorageKey = "moku.bootstrap.completed"; +const defaultInstanceForm = { + protocol: "http", + access: "local", + host: "localhost", +} as const; + +const defaultModeForm = { + mode: "personal", +} as const; + +const defaultAdminForm = { + displayName: "Admin", + email: "admin@example.com", + password: "", +} as const; + +const personalStructureDefaults = { + departmentName: "Default", + teamName: "Personal", +} as const; + +const organizationalStructureDefaults = { + departmentName: "Department", + teamName: "Team", +} as const; + +const defaultStructureForm = { + ...personalStructureDefaults, + projectName: "Project", +} as const; + const initialSubmissionState = (): BootstrapSubmissionState => ({ status: "idle", error: "", @@ -93,24 +124,10 @@ type WorkspaceHomeProps = { export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { const appShellData = useAppShellData(); - const [instanceForm, setInstanceForm] = createStore({ - protocol: "http", - access: "local", - host: "localhost", - }); - const [modeForm, setModeForm] = createStore({ - mode: "personal", - }); - const [adminForm, setAdminForm] = createStore({ - displayName: "Ronald", - email: "admin@example.com", - password: "", - }); - const [structureForm, setStructureForm] = createStore({ - departmentName: "Platform", - teamName: "Core", - projectName: "Moku", - }); + const [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm }); + const [modeForm, setModeForm] = createStore({ ...defaultModeForm }); + const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm }); + const [structureForm, setStructureForm] = createStore({ ...defaultStructureForm }); const [stepState, setStepState] = createStore>({ instance: initialSubmissionState(), mode: initialSubmissionState(), @@ -129,10 +146,54 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { setIsBootstrapStateResolved(true); }); + createEffect(() => { + if (modeForm.mode === "personal") { + setStructureForm("departmentName", personalStructureDefaults.departmentName); + setStructureForm("teamName", personalStructureDefaults.teamName); + return; + } + + if (structureForm.departmentName === personalStructureDefaults.departmentName) { + setStructureForm("departmentName", organizationalStructureDefaults.departmentName); + } + + if (structureForm.teamName === personalStructureDefaults.teamName) { + setStructureForm("teamName", organizationalStructureDefaults.teamName); + } + }); + + createEffect(() => { + if (!isBootstrapStateResolved()) { + return; + } + + if (appShellData.status() !== "success") { + return; + } + + const installation = appShellData.installation(); + const isPersistedBootstrap = installation?.isBootstrapped ?? false; + + if (isPersistedBootstrap) { + return; + } + + if (!isBootstrapComplete() && isWizardOpen()) { + return; + } + + writeBootstrapCompletion(false); + setIsBootstrapComplete(false); + setIsWizardOpen(true); + resetWizardState(); + }); + const sidebarToggleLabel = (): string => props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar"; const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`; const apiBase = (): string => resolveAPIBase(); + const bootstrapTargetLabel = (): string => + modeForm.mode === "personal" ? "Personal server" : "Organization server"; const currentStep = createMemo( () => bootstrapStepDefinitions[currentStepIndex()] ?? bootstrapStepDefinitions[0]!, ); @@ -141,6 +202,20 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1; const canDismissWizard = (): boolean => isBootstrapComplete(); + const resetWizardState = (): void => { + setInstanceForm({ ...defaultInstanceForm }); + setModeForm({ ...defaultModeForm }); + setAdminForm({ ...defaultAdminForm }); + setStructureForm({ ...defaultStructureForm }); + setStepState({ + instance: initialSubmissionState(), + mode: initialSubmissionState(), + admin: initialSubmissionState(), + structure: initialSubmissionState(), + }); + setCurrentStepIndex(0); + }; + const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise => { setStepState(step, { status: "submitting", error: "" }); @@ -201,6 +276,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { } if (isLastStep()) { + await appShellData.reload(); writeBootstrapCompletion(true); setIsBootstrapComplete(true); setIsWizardOpen(false); @@ -268,7 +344,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
Bootstrap -

{appShellData.activeServer().name}

+

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