Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 0 additions & 3 deletions docs/src/content/docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions docs/src/content/docs/launching-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ Open `https://apps.<cluster-domain>`:
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.

Expand Down Expand Up @@ -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

Expand Down
6 changes: 2 additions & 4 deletions ui/e2e/launch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -124,7 +122,7 @@ test.describe("launch form", () => {
managers: { users: ["dave"], groups: ["web-team"] },
subdomain: "team-site",
},
landingPage: { enabled: false },
landingPage: { enabled: true },
},
},
]);
Expand Down
42 changes: 16 additions & 26 deletions ui/src/components/app-form/sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import {
type FormState,
IDLE_MAX,
IDLE_MIN,
LANDING_PAGE_LOCKED,
PRESETS,
type Preset,
type SourceTab,
Expand Down Expand Up @@ -905,33 +904,24 @@ export function AccessSection({ form }: { form: FormContext }) {
</span>
</div>
</FormField>
{LANDING_PAGE_LOCKED ? (
<div className={`space-y-1 ${row}`}>
<p className="font-medium text-sm">Show on the landing page</p>
<p className="flex items-center gap-1.5 text-muted-foreground text-sm">
<Lock aria-hidden="true" className="size-3.5 shrink-0" />
{onLandingPage ? "Listed." : "Not listed."} Changing this here is not available yet.
<div className={`flex items-center justify-between gap-3 ${row}`}>
<div>
<p id="landing-label" className="font-medium text-sm">
Show on the landing page
</p>
<p id="landing-help" className={HELP}>
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.
</p>
</div>
) : (
<div className={`flex items-center justify-between gap-3 ${row}`}>
<div>
<p id="landing-label" className="font-medium text-sm">
Show on the landing page
</p>
<p id="landing-help" className={HELP}>
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.
</p>
</div>
<Switch
checked={onLandingPage}
onCheckedChange={(v) => set("onLandingPage", v)}
aria-labelledby="landing-label"
aria-describedby="landing-help"
/>
</div>
)}
<Switch
checked={onLandingPage}
onCheckedChange={(v) => set("onLandingPage", v)}
aria-labelledby="landing-label"
aria-describedby="landing-help"
/>
</div>
</div>
</CardContent>
</Card>
Expand Down
29 changes: 9 additions & 20 deletions ui/src/components/app-form/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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);
Expand All @@ -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<string, string> }
Expand Down
34 changes: 25 additions & 9 deletions ui/tests/components/app-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
});
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down
Loading