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 (
- Manage members and invite links for this instance. -
-+ Manage members and invite links for this instance. +
++ Admin only +
++ Managing members and invite links requires an admin or owner role. +
+