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
5 changes: 5 additions & 0 deletions .changeset/selfhost-admin-area-access.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 7 additions & 13 deletions apps/host-selfhost/web/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "/";
Expand Down
63 changes: 52 additions & 11 deletions apps/host-selfhost/web/routes/app/admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto flex max-w-3xl flex-col gap-10 px-6 py-10 lg:px-8 lg:py-14">
<header className="space-y-1">
<h1 className="font-display text-3xl tracking-tight text-foreground">Admin</h1>
<p className="text-sm text-muted-foreground">
Manage members and invite links for this instance.
</p>
</header>
<MembersSection />
<InvitesSection />
{access === "loading" ? (
<Notice>Checking admin access…</Notice>
) : access === "allowed" ? (
<AdminConsole />
) : (
<AdminAccessDenied />
)}
</div>
</div>
);
}

function AdminConsole() {
return (
<>
<header className="space-y-1">
<h1 className="font-display text-3xl tracking-tight text-foreground">Admin</h1>
<p className="text-sm text-muted-foreground">
Manage members and invite links for this instance.
</p>
</header>
<MembersSection />
<InvitesSection />
</>
);
}

function AdminAccessDenied() {
return (
<div className="rounded-lg border border-border bg-card p-8">
<p className="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
Admin only
</p>
<h1 className="mt-2 text-base font-semibold text-foreground">
You don&apos;t have access to this instance&apos;s admin area
</h1>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
Managing members and invite links requires an admin or owner role.
</p>
</div>
);
}

function MembersSection() {
const result = useAtomValue(orgMembersAtom);
const [roleState, doUpdateRole] = useAtom(updateMemberRole, { mode: "promiseExit" });
Expand Down
80 changes: 80 additions & 0 deletions e2e/selfhost/admin-area-access.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
}),
);
Loading