diff --git a/.changeset/selfhost-admin-area-access.md b/.changeset/selfhost-admin-area-access.md new file mode 100644 index 0000000000..119284c3e7 --- /dev/null +++ b/.changeset/selfhost-admin-area-access.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Hide the self-hosted Admin area from non-admin members and refuse direct access before member details or invite controls are rendered. diff --git a/apps/host-selfhost/web/routes/__root.tsx b/apps/host-selfhost/web/routes/__root.tsx index 96796c2a25..75b1a55343 100644 --- a/apps/host-selfhost/web/routes/__root.tsx +++ b/apps/host-selfhost/web/routes/__root.tsx @@ -46,22 +46,16 @@ export const Route = createRootRoute({ component: RootComponent, }); -// Self-host adds the account's API keys and the instance Admin page (members + -// invite links) to the shared nav. The Admin page and its API gate to -// owner/admin, so a non-admin who opens it just sees the access notice. -const selfHostNavItems = [ - ...defaultShellNavItems, - { to: "/api-keys", label: "API keys" }, +// Self-host adds account API keys for every member. The instance administration +// surfaces are appended separately after the active member is confirmed as an +// owner/admin, so plain members are not offered links that only refuse them. +const selfHostNavItems = [...defaultShellNavItems, { to: "/api-keys", label: "API keys" }]; + +const selfHostAdminNavItems = [ { to: "/admin", label: "Admin" }, + { to: "/users", label: "Users" }, ]; -// Sections only an owner/admin of the instance may open. Users reads the -// tenant-wide admin plane, gated on a Better Auth owner/admin member, so a -// plain member is not shown a link that would only refuse them. (The existing -// /admin entry predates this and stays unconditional — it is this instance's -// member/invite page, and its own notice covers a non-admin who opens it.) -const selfHostAdminNavItems = [{ to: "/users", label: "Users" }]; - const signOut = async () => { await authClient.signOut(); window.location.href = "/"; diff --git a/apps/host-selfhost/web/routes/app/admin.tsx b/apps/host-selfhost/web/routes/app/admin.tsx index c07a8069bd..d5cf3140b0 100644 --- a/apps/host-selfhost/web/routes/app/admin.tsx +++ b/apps/host-selfhost/web/routes/app/admin.tsx @@ -10,6 +10,7 @@ import { CopyButton } from "@executor-js/react/components/copy-button"; import { Input } from "@executor-js/react/components/input"; import { Label } from "@executor-js/react/components/label"; import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; +import { isTenantAdminMember, type TenantMemberRow } from "@executor-js/react/lib/admin-access"; import { useExecutorDocumentTitle } from "@executor-js/react/lib/document-title"; import { orgMembersAtom, @@ -25,28 +26,68 @@ export const Route = createFileRoute("/{-$orgSlug}/admin")({ }); const ROLES = ["member", "admin"] as const; +type AdminAccess = "loading" | "allowed" | "denied"; -// Instance admin console. Members reuse the shared account atoms; invite codes -// are the self-host join mechanism. The API gates to owner/admin, so a -// non-admin who opens this just sees load failures. +// Instance admin console. The member list is also used by ordinary workspace +// surfaces, so opening this route must check the active member's role before it +// renders any member data or admin controls. The APIs remain the authority for +// every admin mutation. function AdminPage() { useExecutorDocumentTitle("Admin"); + const members = useAtomValue(orgMembersAtom); + const access = AsyncResult.match(members, { + onInitial: (): AdminAccess => "loading", + onFailure: (): AdminAccess => "denied", + onSuccess: ({ value }): AdminAccess => + isTenantAdminMember(value.members as readonly TenantMemberRow[]) ? "allowed" : "denied", + }); + return (
-
-

Admin

-

- Manage members and invite links for this instance. -

-
- - + {access === "loading" ? ( + Checking admin access… + ) : access === "allowed" ? ( + + ) : ( + + )}
); } +function AdminConsole() { + return ( + <> +
+

Admin

+

+ Manage members and invite links for this instance. +

+
+ + + + ); +} + +function AdminAccessDenied() { + return ( +
+

+ Admin only +

+

+ You don't have access to this instance's admin area +

+

+ Managing members and invite links requires an admin or owner role. +

+
+ ); +} + function MembersSection() { const result = useAtomValue(orgMembersAtom); const [roleState, doUpdateRole] = useAtom(updateMemberRole, { mode: "promiseExit" }); diff --git a/e2e/selfhost/admin-area-access.test.ts b/e2e/selfhost/admin-area-access.test.ts new file mode 100644 index 0000000000..b3a8dcfee6 --- /dev/null +++ b/e2e/selfhost/admin-area-access.test.ts @@ -0,0 +1,80 @@ +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; +import { createInvitedIdentity } from "../targets/selfhost"; + +scenario( + "Admin · the self-hosted admin area is visible and accessible only to admins", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const owner = yield* target.newIdentity(); + const member = yield* Effect.promise(() => + createInvitedIdentity(target.baseUrl, owner, { + role: "member", + emailPrefix: "admin-area-member", + }), + ); + + yield* browser.session(owner, async ({ page, step }) => { + await step("The instance owner can open Admin from the sidebar", async () => { + await visit(page, "/"); + const adminLink = page.locator("nav").getByRole("link", { name: "Admin", exact: true }); + await adminLink.waitFor({ state: "visible", timeout: 30_000 }); + await adminLink.click(); + await page.getByRole("heading", { name: "Admin", exact: true }).waitFor({ + state: "visible", + timeout: 30_000, + }); + await page + .getByText(owner.credentials?.email ?? "", { exact: true }) + .first() + .waitFor({ + state: "visible", + timeout: 30_000, + }); + }); + }); + + yield* browser.session(member, async ({ page, step }) => { + await step("A plain member is not offered the Admin section", async () => { + // Wait for the member list that drives role-aware navigation to settle. + // Otherwise asserting immediately after the shell appears could pass + // merely because every admin link starts hidden during hydration. + await visit(page, "/organization"); + await page + .getByText(member.credentials?.email ?? "", { exact: true }) + .first() + .waitFor({ state: "visible", timeout: 30_000 }); + expect( + await page.locator("nav").getByRole("link", { name: "Admin", exact: true }).count(), + "a plain member must not see the Admin link", + ).toBe(0); + }); + + await step("A plain member who opens the Admin URL is refused", async () => { + await visit(page, "/admin"); + await page.getByText("You don't have access to this instance's admin area").waitFor({ + state: "visible", + timeout: 30_000, + }); + expect( + await page.getByText(owner.credentials?.email ?? "", { exact: true }).count(), + "the refusal must not expose the owner's email", + ).toBe(0); + expect( + await page.getByRole("heading", { name: "Members", exact: true }).count(), + "the refusal must replace the member directory", + ).toBe(0); + expect( + await page.getByRole("button", { name: "Create invite" }).count(), + "the refusal must replace the admin controls", + ).toBe(0); + }); + }); + }), +);