From 4439f03bcfbb144363bf1d7a186ac157b8067acf Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 5 Nov 2026 15:43:28 -0800 Subject: [PATCH] Offer organization creation in the self-host switcher to root owners A signed-in user's GET /api/auth/self-host/capabilities answers whether they may create organizations, from the same root-owner check the create endpoint applies. The self-host switcher shows Create organization only when it says so; the server still refuses everyone else. Co-authored-by: Cursor --- .../src/implementation/root-organization.ts | 39 ++++-- .../self-host/web/src/contracts/auth.ts | 15 +++ .../routes/org.$organizationSlug.tsx | 9 +- e2e/test-plan.ts | 13 ++ .../self-host-organization-switcher.spec.ts | 123 ++++++++++++++++++ 5 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 e2e/tests/self-host-organization-switcher.spec.ts diff --git a/apps/hosted/self-host/src/implementation/root-organization.ts b/apps/hosted/self-host/src/implementation/root-organization.ts index 68f536cecf..0f3e320ea8 100644 --- a/apps/hosted/self-host/src/implementation/root-organization.ts +++ b/apps/hosted/self-host/src/implementation/root-organization.ts @@ -5,7 +5,7 @@ */ import type { AuthContext } from "@better-auth/core"; import type { BetterAuthPlugin } from "better-auth"; -import { APIError } from "better-auth/api"; +import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api"; import { migrateProductSteps } from "@executor-js/hosted-server/migrations"; import { Cause, Effect, Exit, Option, Schema } from "effect"; import { SqlClient } from "effect/sql"; @@ -50,8 +50,19 @@ const ownsRoot = (adapter: AuthContext["adapter"], userId: string) => return Option.isSome(Schema.decodeUnknownOption(Membership)(member)); }); +const runOwnsRoot = (adapter: AuthContext["adapter"], userId: string) => + Effect.runPromiseExit(ownsRoot(adapter, userId)).then( + Exit.match({ + onSuccess: (owns) => owns, + onFailure: (cause) => { + throw Cause.squash(cause); + }, + }), + ); + /** - * The root record's table and the check organization creation asks. It must follow the + * The root record's table, the check organization creation asks, and the signed-in user's + * answer to it, which the dashboard reads to offer creation. It must follow the * organization plugin: Better Auth creates tables in plugin order. */ export const selfHostRoot = () => { @@ -62,6 +73,19 @@ export const selfHostRoot = () => { init: (context) => { adapter = context.adapter; }, + endpoints: { + selfHostCapabilities: createAuthEndpoint( + "/self-host/capabilities", + { method: "GET", use: [sessionMiddleware] }, + async (ctx) => + ctx.json({ + createOrganization: await runOwnsRoot( + ctx.context.adapter, + ctx.context.session.user.id, + ), + }), + ), + }, schema: { [rootModel]: { fields: { @@ -77,16 +101,7 @@ export const selfHostRoot = () => { }, } satisfies BetterAuthPlugin, ownsRoot: (userId: string) => - adapter === undefined - ? Promise.resolve(false) - : Effect.runPromiseExit(ownsRoot(adapter, userId)).then( - Exit.match({ - onSuccess: (owns) => owns, - onFailure: (cause) => { - throw Cause.squash(cause); - }, - }), - ), + adapter === undefined ? Promise.resolve(false) : runOwnsRoot(adapter, userId), }; }; diff --git a/apps/hosted/self-host/web/src/contracts/auth.ts b/apps/hosted/self-host/web/src/contracts/auth.ts index 8946e5ff92..c2f213af97 100644 --- a/apps/hosted/self-host/web/src/contracts/auth.ts +++ b/apps/hosted/self-host/web/src/contracts/auth.ts @@ -28,6 +28,21 @@ const liveConfiguration = browserOnly( ), ); +/** + * Whether the signed-in user may create organizations: only owners of the root organization may. + * It offers the action only; the server checks again when one is created. + */ +export const organizationCreationAtom = browserOnly( + BrowserAtoms.atom( + authRequest((options) => authClient.$fetch("/self-host/capabilities", options)).pipe( + Effect.flatMap( + Schema.decodeUnknownEffect(Schema.Struct({ createOrganization: Schema.Boolean })), + ), + Effect.map((capabilities) => capabilities.createOrganization), + ), + ), +); + /** The settings the server read for this sign-in page, sent to the browser with it. */ const entryConfiguration = Atom.make>(Option.none()).pipe( Atom.serializable({ key: "self-host:sign-in-settings", schema: Schema.Option(SignInSettings) }), diff --git a/apps/hosted/self-host/web/src/implementation/routes/org.$organizationSlug.tsx b/apps/hosted/self-host/web/src/implementation/routes/org.$organizationSlug.tsx index 4e5fbde917..1b354d6094 100644 --- a/apps/hosted/self-host/web/src/implementation/routes/org.$organizationSlug.tsx +++ b/apps/hosted/self-host/web/src/implementation/routes/org.$organizationSlug.tsx @@ -1,10 +1,13 @@ import { createFileRoute, Outlet, useLocation } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/reactivity"; import { DashboardEntryPending } from "@executor-js/hosted-web/entry"; import { PagePending } from "@executor-js/hosted-web/page-pending"; import { DashboardShell } from "@executor-js/hosted-web/shell"; import { OrganizationBoundary, OrganizationContent } from "@executor-js/hosted-web/organization"; import { NameAccountDialog } from "@executor-js/hosted-web/pages/name-account-dialog"; import { Navigation } from "../navigation.tsx"; +import { organizationCreationAtom } from "../../contracts/auth.ts"; /** The URL owns this tab's organization; all product pages inherit this boundary. */ export const Route = createFileRoute("/org/$organizationSlug")({ @@ -14,9 +17,13 @@ export const Route = createFileRoute("/org/$organizationSlug")({ }); function OrganizationLayout() { const { organizationSlug } = Route.useParams(); + const creation = useAtomValue(organizationCreationAtom); return ( - }> + } + > }> diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index d4bb7674cf..396ba1f38f 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -227,6 +227,19 @@ export const scenarios = plan({ local: na("Local has no hosted organizations."), }, }, + selfHostOrganizationSwitcher: { + fixtures: "actors", + file: "self-host-organization-switcher.spec.ts", + title: + "the self-host switcher offers creation only to root owners, and switching shows each organization its own apps", + targets: { + "self-host": scheduled, + cloud: na( + "Cloud offers creation to every signed-in account; its switcher has its own scenarios.", + ), + local: na("Local has no hosted organizations."), + }, + }, selfHostOrganizationCreationBootstrap: { legacyStorage: true, file: "self-host-organization-creation.spec.ts", diff --git a/e2e/tests/self-host-organization-switcher.spec.ts b/e2e/tests/self-host-organization-switcher.spec.ts new file mode 100644 index 0000000000..79d8b98456 --- /dev/null +++ b/e2e/tests/self-host-organization-switcher.spec.ts @@ -0,0 +1,123 @@ +/** + * The self-host organization switcher offers creation to owners of the root organization only, + * as the server decides, and switching shows each organization its own apps. + */ +import { expect, layer } from "@effect/vitest"; +import { Effect } from "effect"; +import type { Page } from "playwright"; +import { Actors } from "../support/actors.ts"; +import { Api } from "../support/api.ts"; +import { appsManifest } from "../support/apps-release.ts"; +import { Browser } from "../support/browser.ts"; +import { HostedLive, withHostedCase } from "../support/case.ts"; +import { Target } from "../support/platform.ts"; +import { targetHosts } from "../support/role-hosts.ts"; +import { scenarios } from "../test-plan.ts"; + +const rootOnly = `import { defineApp, object, query, router } from "apps"; +export default defineApp({}, async () => ({ tools: router({ + ping: query({ input: object({}) }, async () => "pong"), +}) }));`; + +/** Open an organization's apps once the server has answered whether the user may create. */ +const openApps = (page: Page, origin: string, slug: string) => + Promise.all([ + page.waitForResponse((response) => response.url().endsWith("/api/auth/self-host/capabilities")), + page.goto(`${origin}/org/${slug}/apps`), + ]).then(([response]) => response.json()); + +const switcher = (page: Page) => page.getByRole("button", { name: /^Organization: / }); + +layer(HostedLive, { excludeTestServices: true })("Self-host organization switcher", (it) => { + it.effect(scenarios.selfHostOrganizationSwitcher.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const api = yield* Api, + actors = yield* Actors, + browser = yield* Browser; + const root = actors.organization; + const origin = targetHosts(yield* Target).browser; + expect( + (yield* api.request(actors.owner, "POST", `/api/organizations/${root.id}/apps/deploy`, { + name: "Root only app", + files: [{ path: "index.ts", content: rootOnly }, appsManifest], + })).status, + ).toBe(200); + + yield* browser.login(actors.owner); + expect( + yield* browser.use("The owner opens the root's apps", (page) => + openApps(page, origin, root.slug), + ), + ).toEqual({ createOrganization: true }); + yield* browser.use("The root lists its app", (page) => + page.getByText("Root only app", { exact: true }).first().waitFor(), + ); + yield* browser.use("Open the organization switcher", (page) => switcher(page).click()); + yield* browser.use("Choose Create organization", (page) => + page.getByRole("menuitem", { name: "Create organization", exact: true }).click(), + ); + yield* browser.use("Name the organization", (page) => + page.getByRole("dialog").getByLabel("Name", { exact: true }).fill("Second team"), + ); + yield* browser.checkpoint("The root owner creates an organization from the switcher"); + yield* browser.use("Create it", (page) => + page + .getByRole("dialog") + .getByRole("button", { name: "Create organization", exact: true }) + .click(), + ); + yield* browser.use("The new organization opens", (page) => + page + .waitForURL("**/org/second-team/apps") + .then(() => + page + .getByRole("button", { name: "Organization: Second team", exact: true }) + .waitFor(), + ) + .then(() => page.getByRole("button", { name: "Add app", exact: true }).waitFor()), + ); + expect( + yield* browser.use("The new organization lists no app of the root", (page) => + page.getByText("Root only app", { exact: true }).count(), + ), + ).toBe(0); + yield* browser.checkpoint("The new organization opens without the root's apps"); + + yield* browser.use("Open the organization switcher", (page) => switcher(page).click()); + yield* browser.use("Switch back to the root", (page) => + page.getByRole("menuitemradio", { name: "Evidence lab", exact: true }).click(), + ); + yield* browser.use("The root opens with its app", (page) => + page + .waitForURL(`**/org/${root.slug}/apps`) + .then(() => page.getByText("Root only app", { exact: true }).first().waitFor()), + ); + + yield* browser.login(actors.admin); + expect( + yield* browser.use("The admin opens the root's apps", (page) => + openApps(page, origin, root.slug), + ), + ).toEqual({ createOrganization: false }); + yield* browser.use("Open the admin's organization switcher", (page) => + switcher(page) + .click() + .then(() => + page.getByRole("menuitemradio", { name: "Evidence lab", exact: true }).waitFor(), + ), + ); + expect( + yield* browser.use("The admin's switcher", (page) => + Promise.all([ + page.getByRole("menuitemradio").count(), + page.getByRole("menuitem", { name: "Create organization", exact: true }).count(), + ]), + ), + ).toEqual([1, 0]); + yield* browser.checkpoint("An admin of the root is not offered creation"); + }), + ), + ); +});