From 5c62fa64bb7cdb95b2dee57239c0e5510d9469ed Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Wed, 21 Oct 2026 10:52:28 -0400 Subject: [PATCH] feat(ui): unlock the launch form's landing-page switch The switch was read-only because the landing page could hide a tile only by group, not by username. Apps now restrict by group only (#158), so the form offers it again. A launch sends exactly what the switch shows, even when the cluster default is on; an edit sends landingPage only when the switch is flipped, so an App on the cluster default keeps it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016pvMUQJoLkBDk6B94TTm45 --- README.md | 1 - docs/src/content/docs/getting-started.md | 3 -- docs/src/content/docs/launching-apps.md | 11 ++++--- ui/e2e/launch.spec.ts | 6 ++-- ui/src/components/app-form/sections.tsx | 42 +++++++++--------------- ui/src/components/app-form/state.ts | 29 +++++----------- ui/tests/components/app-form.test.tsx | 34 ++++++++++++++----- 7 files changed, 58 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 64d0993..7837adf 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,6 @@ The user guide is at [packs.nebari.dev/apps-pack](https://packs.nebari.dev/apps- - `access.groups` is enforced in the app pod by the apps-authz sidecar, not at the gateway (Apps restrict access by Keycloak group only); group changes apply when the user's token refreshes (5 minutes by default). -- The launch form's landing-page switch is locked for now; register a tile through the API, the MCP server or `kubectl`. - Users see and manage the Apps they launched or are listed as managers of (`access.managers`); members of `api.adminGroups` manage all of them. Ownership cannot be transferred yet. diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.md index 3e95da2..74bf2ab 100644 --- a/docs/src/content/docs/getting-started.md +++ b/docs/src/content/docs/getting-started.md @@ -174,9 +174,6 @@ or pilot. user's token refreshes (5 minutes by default). A private app with no groups is open to any signed-in realm user. Access is by group only; there is no per-user allow list. See [Access control](/launching-apps/#access-control). -- **The launch form's landing-page switch is locked for now.** Tiles are off by default for - launched apps (`apps.landingPage.enabled` / `spec.landingPage.enabled`); only the pack's own - "Apps" tile is registered. See [Landing page](/launching-apps/#landing-page). - **Authorization is owner, manager or admin.** The API lets a user see and manage the Apps they launched or are listed as a [manager](/launching-apps/#managers) of; members of `api.adminGroups` manage all of them. Ownership cannot be transferred yet, and anyone with diff --git a/docs/src/content/docs/launching-apps.md b/docs/src/content/docs/launching-apps.md index f271d18..c9c1cf3 100644 --- a/docs/src/content/docs/launching-apps.md +++ b/docs/src/content/docs/launching-apps.md @@ -50,8 +50,8 @@ Open `https://apps.`: edit, saved secrets show their names only: leave a value empty to keep it, type to replace it, or remove the row. 5. **Access** — public toggle, allowed groups, and the subdomain, which follows - the name (lowercased, other characters turned into hyphens) until you edit it. The - landing-page option is locked off for now (see [Landing page](#landing-page)). + the name (lowercased, other characters turned into hyphens) until you edit it, and + whether the app gets a tile on the Nebari landing page (see [Landing page](#landing-page)). 6. **Managers** — other users and groups who may manage the app with you (see [Managers](#managers)). Read-only when a manager edits the app. @@ -348,9 +348,10 @@ cluster default with the chart value `apps.landingPage.enabled: true`, after whi *out* with `enabled: false`. Either way the app is reachable at its URL and listed in the Apps UI. -The launch form's "Show on the landing page" option is **locked** for now: the form -registers new apps with `enabled: false` and an edit keeps whatever the App already has. Register a tile through the API, the MCP tool or `kubectl` if you need -one. +In the launch form, **Show on the landing page** sets it. A new app gets exactly what the +switch shows, even when the cluster default is on; an edit changes the stored value only when +you flip the switch. A private app's tile is shown only to members of its `access.groups` +(any signed-in user when it lists none). ## Idle apps diff --git a/ui/e2e/launch.spec.ts b/ui/e2e/launch.spec.ts index 1e23f59..ba116a8 100644 --- a/ui/e2e/launch.spec.ts +++ b/ui/e2e/launch.spec.ts @@ -101,9 +101,7 @@ test.describe("launch form", () => { await page.getByLabel("Allowed groups").fill("analysts , finance,"); await page.getByLabel("Manager users").fill("dave"); await page.getByLabel("Manager groups").fill("web-team"); - // The landing-page option is locked: a note, not a switch. - await expect(page.getByRole("switch", { name: /landing page/ })).toHaveCount(0); - await expect(page.getByText(/Changing this here is not available yet/)).toBeVisible(); + await page.getByRole("switch", { name: /landing page/ }).click(); await page.getByRole("button", { name: "Launch app" }).click(); await expect(page).toHaveURL("/apps/apps/team-site"); @@ -124,7 +122,7 @@ test.describe("launch form", () => { managers: { users: ["dave"], groups: ["web-team"] }, subdomain: "team-site", }, - landingPage: { enabled: false }, + landingPage: { enabled: true }, }, }, ]); diff --git a/ui/src/components/app-form/sections.tsx b/ui/src/components/app-form/sections.tsx index eeb2ef2..c80de5c 100644 --- a/ui/src/components/app-form/sections.tsx +++ b/ui/src/components/app-form/sections.tsx @@ -26,7 +26,6 @@ import { type FormState, IDLE_MAX, IDLE_MIN, - LANDING_PAGE_LOCKED, PRESETS, type Preset, type SourceTab, @@ -905,33 +904,24 @@ export function AccessSection({ form }: { form: FormContext }) { - {LANDING_PAGE_LOCKED ? ( -
-

Show on the landing page

-

-

+
+

+ Show on the landing page +

+

+ Add a tile for this app to the Nebari landing page. A private app's tile is shown + only to members of its allowed groups. The app is reachable at its URL and listed + here either way.

- ) : ( -
-
-

- Show on the landing page -

-

- Add a tile for this app to the Nebari landing page. Off by default - the app is - reachable at its URL and listed here either way. -

-
- set("onLandingPage", v)} - aria-labelledby="landing-label" - aria-describedby="landing-help" - /> -
- )} + set("onLandingPage", v)} + aria-labelledby="landing-label" + aria-describedby="landing-help" + /> +
diff --git a/ui/src/components/app-form/state.ts b/ui/src/components/app-form/state.ts index ee49a27..952fcc8 100644 --- a/ui/src/components/app-form/state.ts +++ b/ui/src/components/app-form/state.ts @@ -177,9 +177,8 @@ export function initialState(initial: App | undefined, isEdit: boolean): FormSta managerUsers: (initial?.access?.managers?.users ?? []).join(", "), managerGroups: (initial?.access?.managers?.groups ?? []).join(", "), subdomain: initial?.access?.subdomain ?? "", - // The landing-page switch is read-only for now (LANDING_PAGE_LOCKED): it - // shows the App's current value, a new app is registered off, and an - // edit leaves whatever the App has. + // Unset shows as off; the tile is opt-in unless the cluster default says + // otherwise (see buildPayload). onLandingPage: initial?.landingPage?.enabled ?? false, touched: {}, submitted: false, @@ -382,12 +381,6 @@ function buildSource(s: FormState): AppSource | undefined { return undefined; // 'upload' handled separately; 'keep' leaves source untouched } -/** - * The form does not offer the landing-page switch yet: new apps are not - * listed and edits keep the App's current value. - */ -export const LANDING_PAGE_LOCKED = true; - /** * Whether the caller may change who manages the App: anyone launching one, * and on an edit its owner or an admin (managers may not). @@ -450,9 +443,10 @@ export function buildPayload( const managers = buildManagers(s); if (body.access && canEditManagers(isEdit, initial) && (managers || initial?.access?.managers)) body.access.managers = managers ?? null; - // An omitted landingPage keeps the stored value; while the switch is - // locked the form never changes it. - if (!LANDING_PAGE_LOCKED) body.landingPage = { enabled: s.onLandingPage }; + // An omitted landingPage keeps the stored value, so an App that relies on + // the cluster default keeps doing so until the switch is flipped. + if (s.onLandingPage !== (initial?.landingPage?.enabled ?? false)) + body.landingPage = { enabled: s.onLandingPage }; const source = buildSource(s); if (source) body.source = source; const secrets = buildSecrets(s, true); @@ -473,14 +467,9 @@ export function buildPayload( subdomain: s.subdomain, ...(buildManagers(s) ? { managers: buildManagers(s) } : {}), }, - // Locked: registered off explicitly, so a cluster default of "on" does - // not list it either. Unlocked, the tile is opt-in (unset = cluster - // default). - ...(LANDING_PAGE_LOCKED - ? { landingPage: { enabled: false } } - : s.onLandingPage - ? { landingPage: { enabled: true } } - : {}), + // Sent either way, so the App gets what the switch shows even when the + // cluster default is "on". + landingPage: { enabled: s.onLandingPage }, // A launch never sends null (buildSecrets only adds those on an edit). ...(buildSecrets(s, false) ? { secrets: buildSecrets(s, false) as Record } diff --git a/ui/tests/components/app-form.test.tsx b/ui/tests/components/app-form.test.tsx index a3dd0a2..d5c6e09 100644 --- a/ui/tests/components/app-form.test.tsx +++ b/ui/tests/components/app-form.test.tsx @@ -188,7 +188,7 @@ describe("AppForm (create)", () => { groups: ["analytics", "finance"], subdomain: "sales", }, - // The landing-page switch is locked: new apps are registered off. + // Off is sent explicitly, so a cluster default of "on" does not list it. landingPage: { enabled: false }, }); }); @@ -200,12 +200,17 @@ describe("AppForm (create)", () => { ); }); - it("shows the locked landing-page option as a note, not a switch", () => { - setup(); - expect(screen.queryByRole("switch", { name: /landing page/ })).toBeNull(); - expect( - screen.getByText(/Not listed\. Changing this here is not available yet\./), - ).toBeVisible(); + it("registers a landing-page tile when the switch is on", async () => { + const { user, onSubmit, submit } = setup(); + await user.type(screen.getByLabelText("Name"), "docs"); + await user.type(screen.getByLabelText("Display name"), "Docs"); + await user.click(screen.getByRole("tab", { name: "Git" })); + await user.type(screen.getByLabelText("Repository URL"), "https://github.com/example/docs"); + await user.click(screen.getByRole("switch", { name: /landing page/ })); + await submit(); + const payload = onSubmit.mock.calls[0][0]; + if (payload.kind !== "create") throw new Error(`expected a create, got ${payload.kind}`); + expect(payload.body.landingPage).toEqual({ enabled: true }); }); it("launches from git with resources, env and a public URL", async () => { @@ -501,14 +506,14 @@ describe("AppForm (edit)", () => { expect(payload.body).not.toHaveProperty("secrets"); }); - it("round-trips allowed groups and never changes a registered tile", async () => { + it("round-trips allowed groups and leaves an unchanged tile alone", async () => { const listed = { ...EXISTING, access: { public: false, groups: ["alice-team", "bob-team"], subdomain: "docs-site" }, landingPage: { enabled: true }, } as App; const { user, onSubmit, submit } = setup({ mode: "edit", initial: listed }); - expect(screen.getByText(/^Listed\./)).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: /landing page/ })).toBeChecked(); const groups = screen.getByLabelText("Allowed groups"); expect(groups).toHaveValue("alice-team, bob-team"); await user.clear(groups); @@ -525,6 +530,17 @@ describe("AppForm (edit)", () => { expect(payload.body).not.toHaveProperty("landingPage"); }); + it("sends the tile when the landing-page switch is flipped on an edit", async () => { + const listed = { ...EXISTING, landingPage: { enabled: true } } as App; + const { user, onSubmit, submit } = setup({ mode: "edit", initial: listed }); + await user.click(screen.getByRole("switch", { name: /landing page/ })); + await submit(); + + const payload = onSubmit.mock.calls[0][0]; + if (payload.kind !== "patch") throw new Error(`expected a patch, got ${payload.kind}`); + expect(payload.body.landingPage).toEqual({ enabled: false }); + }); + it("lets the owner change managers and clear them with null", async () => { const managed = { ...EXISTING,