Merge branch 'Features/Backend/Bootstrap-Reset'

This commit is contained in:
MangoPig
2026-06-19 19:57:46 +01:00
6 changed files with 185 additions and 31 deletions

View File

@@ -410,6 +410,36 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
}, nil }, 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) { func (service *Service) GetInstallation(ctx context.Context) (*InstallationRecord, error) {
record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, ` record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, `
SELECT id::text, mode::text, access::text, protocol::text, host, is_bootstrapped SELECT id::text, mode::text, access::text, protocol::text, host, is_bootstrapped

View File

@@ -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) { func (routes apiRoutes) handleBootstrapInstanceStep(w http.ResponseWriter, r *http.Request) {
payload, ok := decodeBootstrapRequest[bootstrapInstanceStepRequest](w, r) payload, ok := decodeBootstrapRequest[bootstrapInstanceStepRequest](w, r)
if !ok { if !ok {

View File

@@ -33,6 +33,10 @@ func (routes apiRoutes) Register(router chi.Router) {
apiRouter.Get("/app-shell", routes.handleAppShellState) apiRouter.Get("/app-shell", routes.handleAppShellState)
apiRouter.Get("/organizations", routes.handleOrganizations) apiRouter.Get("/organizations", routes.handleOrganizations)
apiRouter.Get("/workspaces", routes.handleWorkspaces) apiRouter.Get("/workspaces", routes.handleWorkspaces)
if routes.cfg.Config.IsDevelopment() {
apiRouter.Post("/dev/bootstrap/reset", routes.handleDevelopmentBootstrapReset)
}
}) })
} }

View File

@@ -100,9 +100,20 @@ type AppShellPayload = {
workspaces: AppShellWorkspace[]; 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 = { type AppShellContextValue = {
status: Accessor<"idle" | "loading" | "success" | "error">; status: Accessor<"idle" | "loading" | "success" | "error">;
error: Accessor<string>; error: Accessor<string>;
installation: Accessor<AppShellInstallation | undefined>;
railItems: Accessor<readonly RailItem[]>; railItems: Accessor<readonly RailItem[]>;
activeServer: Accessor<ActiveServer>; activeServer: Accessor<ActiveServer>;
activeProject: Accessor<ActiveProject>; activeProject: Accessor<ActiveProject>;
@@ -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."); throw new Error(body.error?.message || "Failed to load app shell state.");
} }
setPayload(body.data); setPayload(normalizeAppShellPayload(body.data));
setStatus("success"); setStatus("success");
} catch (loadError) { } catch (loadError) {
setStatus("error"); setStatus("error");
@@ -292,6 +303,7 @@ export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Elem
const value: AppShellContextValue = { const value: AppShellContextValue = {
status, status,
error, error,
installation: createMemo(() => payload()?.installation),
railItems: createMemo(() => buildRailItems(payload())), railItems: createMemo(() => buildRailItems(payload())),
activeServer: createMemo(() => buildActiveServer(payload())), activeServer: createMemo(() => buildActiveServer(payload())),
activeProject: createMemo(() => buildActiveProject(payload())), activeProject: createMemo(() => buildActiveProject(payload())),

View File

@@ -261,6 +261,14 @@
background 160ms var(--easing-standard); 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 input:focus-visible,
.field select:focus-visible { .field select:focus-visible {
outline: none; outline: none;

View File

@@ -1,6 +1,6 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx // 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 { Portal } from "solid-js/web";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { resolveAPIBase } from "../../../lib/api"; import { resolveAPIBase } from "../../../lib/api";
@@ -46,6 +46,37 @@ const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
const bootstrapCompletionStorageKey = "moku.bootstrap.completed"; 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 => ({ const initialSubmissionState = (): BootstrapSubmissionState => ({
status: "idle", status: "idle",
error: "", error: "",
@@ -93,24 +124,10 @@ type WorkspaceHomeProps = {
export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const appShellData = useAppShellData(); const appShellData = useAppShellData();
const [instanceForm, setInstanceForm] = createStore({ const [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm });
protocol: "http", const [modeForm, setModeForm] = createStore({ ...defaultModeForm });
access: "local", const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm });
host: "localhost", const [structureForm, setStructureForm] = createStore({ ...defaultStructureForm });
});
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 [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({ const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({
instance: initialSubmissionState(), instance: initialSubmissionState(),
mode: initialSubmissionState(), mode: initialSubmissionState(),
@@ -129,10 +146,54 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
setIsBootstrapStateResolved(true); 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 => const sidebarToggleLabel = (): string =>
props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar"; props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar";
const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`; const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
const apiBase = (): string => resolveAPIBase(); const apiBase = (): string => resolveAPIBase();
const bootstrapTargetLabel = (): string =>
modeForm.mode === "personal" ? "Personal server" : "Organization server";
const currentStep = createMemo<BootstrapStepDefinition>( const currentStep = createMemo<BootstrapStepDefinition>(
() => bootstrapStepDefinitions[currentStepIndex()] ?? bootstrapStepDefinitions[0]!, () => bootstrapStepDefinitions[currentStepIndex()] ?? bootstrapStepDefinitions[0]!,
); );
@@ -141,6 +202,20 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1; const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1;
const canDismissWizard = (): boolean => isBootstrapComplete(); 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<boolean> => { const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise<boolean> => {
setStepState(step, { status: "submitting", error: "" }); setStepState(step, { status: "submitting", error: "" });
@@ -201,6 +276,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
} }
if (isLastStep()) { if (isLastStep()) {
await appShellData.reload();
writeBootstrapCompletion(true); writeBootstrapCompletion(true);
setIsBootstrapComplete(true); setIsBootstrapComplete(true);
setIsWizardOpen(false); setIsWizardOpen(false);
@@ -268,7 +344,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<section class={styles.hero} data-slot="workspace-home-hero"> <section class={styles.hero} data-slot="workspace-home-hero">
<span class={styles.eyebrow}>Bootstrap</span> <span class={styles.eyebrow}>Bootstrap</span>
<h1 class={styles.title}>{appShellData.activeServer().name}</h1> <h1 class={styles.title}>{isBootstrapComplete() ? appShellData.activeServer().name : bootstrapTargetLabel()}</h1>
<Show when={isBootstrapStateResolved() && !isBootstrapComplete()}> <Show when={isBootstrapStateResolved() && !isBootstrapComplete()}>
<div class={styles.heroActions}> <div class={styles.heroActions}>
<button type="button" class={styles.primaryButton} onClick={(): void => setIsWizardOpen(true)}> <button type="button" class={styles.primaryButton} onClick={(): void => setIsWizardOpen(true)}>
@@ -288,7 +364,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<header class={styles.wizardHeader} data-slot="bootstrap-wizard-header"> <header class={styles.wizardHeader} data-slot="bootstrap-wizard-header">
<div class={styles.wizardHeaderCopy}> <div class={styles.wizardHeaderCopy}>
<h2 id="bootstrap-wizard-title" class={styles.wizardTitle}> <h2 id="bootstrap-wizard-title" class={styles.wizardTitle}>
Bootstrap {appShellData.activeServer().name} Bootstrap {bootstrapTargetLabel()}
</h2> </h2>
</div> </div>
<Show when={canDismissWizard()}> <Show when={canDismissWizard()}>
@@ -379,13 +455,13 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<> <>
<label class={styles.field}> <label class={styles.field}>
<span class={styles.fieldLabel}>Display name</span> <span class={styles.fieldLabel}>Display name</span>
<input <input
type="text" type="text"
value={adminForm.displayName} value={adminForm.displayName}
onInput={(event): void => setAdminForm("displayName", event.currentTarget.value)} onInput={(event): void => setAdminForm("displayName", event.currentTarget.value)}
placeholder="First admin" placeholder="Admin"
/> />
</label> </label>
<label class={styles.field}> <label class={styles.field}>
<span class={styles.fieldLabel}>Email</span> <span class={styles.fieldLabel}>Email</span>
<input <input
@@ -414,8 +490,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<input <input
type="text" type="text"
value={structureForm.departmentName} value={structureForm.departmentName}
disabled={modeForm.mode === "personal"}
onInput={(event): void => setStructureForm("departmentName", event.currentTarget.value)} onInput={(event): void => setStructureForm("departmentName", event.currentTarget.value)}
placeholder="Platform" placeholder={organizationalStructureDefaults.departmentName}
/> />
</label> </label>
<label class={styles.field}> <label class={styles.field}>
@@ -423,8 +500,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<input <input
type="text" type="text"
value={structureForm.teamName} value={structureForm.teamName}
disabled={modeForm.mode === "personal"}
onInput={(event): void => setStructureForm("teamName", event.currentTarget.value)} onInput={(event): void => setStructureForm("teamName", event.currentTarget.value)}
placeholder="Core" placeholder={organizationalStructureDefaults.teamName}
/> />
</label> </label>
<label class={styles.field}> <label class={styles.field}>