Skip to content
Open
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
39 changes: 27 additions & 12 deletions apps/hosted/self-host/src/implementation/root-organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -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: {
Expand All @@ -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),
};
};

Expand Down
15 changes: 15 additions & 0 deletions apps/hosted/self-host/web/src/contracts/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>("/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.Option<SignInSettings>>(Option.none()).pipe(
Atom.serializable({ key: "self-host:sign-in-settings", schema: Schema.Option(SignInSettings) }),
Expand Down
Original file line number Diff line number Diff line change
@@ -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")({
Expand All @@ -14,9 +17,13 @@ export const Route = createFileRoute("/org/$organizationSlug")({
});
function OrganizationLayout() {
const { organizationSlug } = Route.useParams();
const creation = useAtomValue(organizationCreationAtom);
return (
<OrganizationBoundary slug={organizationSlug}>
<DashboardShell allowCreateOrganization={false} navigation={<Navigation />}>
<DashboardShell
allowCreateOrganization={AsyncResult.isSuccess(creation) && creation.value}
navigation={<Navigation />}
>
<OrganizationContent pending={<PagePending />}>
<Outlet />
<NameAccountDialog />
Expand Down
13 changes: 13 additions & 0 deletions e2e/test-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
123 changes: 123 additions & 0 deletions e2e/tests/self-host-organization-switcher.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
}),
),
);
});