Files
beardrive/internal/webapp/frontend/e2e/admin.spec.ts
T
Snow LeeandClaude Opus 5 fc5f7e8ddf feat(webapp): an invite link can name the project it was sent about (BEA-170)
An org owner minted /join/<token>, pasted it into Slack, and the recipient
signed up and landed on a list of projects with nothing saying which one they
were invited for. The page that finishes the job — /<project-id>/install, which
bakes this hub's origin and this project's id into the agent paste prompt — was
one click away and nobody told them to click it.

The link may now carry ?p=<project-id>, minted from a project's Settings, and
the joiner lands on that project's install page.

Three edits and one hook:

- inviteTokenFromNext cuts `next` at the FIRST "?" before the /join/<hex>
  check. Without it a logged-out invitee on an invite-only hub (the default
  posture) silently loses the account-creation form — the recipient who most
  needs the feature is the one it broke. Every existing negative stays closed:
  "/wiki/note.md?x=/join/<tok>" cuts to "/wiki/note.md" and still fails the
  prefix.
- useFetchProjects: useProjects is disabled while the join screen is up and
  invalidateQueries never fetches a disabled query, so the "does p resolve"
  check had to fetch rather than refresh — otherwise it silently always fails.
- HubApp navigates to /<p>/install only when p is in the list the server just
  returned. That resolve IS the validator: p="/evil.com" would build
  "//evil.com/install" and pushState throws on a cross-origin target. Anything
  unresolvable lands on "/", never on "Project not found" — right for a typed
  URL, wrong as a new teammate's first screen.
- ProjectSettings People card gains an owners-only "Invite a teammate" button,
  gated on org.role (handleInviteCreate 403s a project admin who is a plain org
  member).

No invite-record change, no schema change, no new route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 09:16:54 -07:00

258 lines
11 KiB
TypeScript

import { test, expect } from "@playwright/test";
import { login, wikiId, ADMIN, MEMBER, expectToast } from "./helpers";
// Phase 4: org admin (rename, members, projects, invites, share audit) and
// hub settings (policy toggles, pending queue). Panels are not routes —
// navigation closes them. Mutating specs revert their changes: the suite
// shares one hub per run.
// The org panel opens from the account menu (sidebar footer).
async function openOrgSettings(page: import("@playwright/test").Page) {
await page.click("#account-btn");
await page.click("#menu-org-settings");
}
test("org admin: members with roles, self marked, rename round-trip", async ({ page }) => {
await login(page);
await openOrgSettings(page);
await expect(page.locator("#org-title")).toHaveText("default");
// The crumb names the surface; repeating the org name under the <h1> that
// already says it told the reader nothing.
await expect(page.locator("#crumb")).toHaveText("Organization");
await expect(page.locator(".admin-item", { hasText: ADMIN })).toContainText("(you)");
const memberRow = page.locator(".admin-item", { hasText: MEMBER });
await expect(memberRow.locator("select")).toHaveValue("member");
// Rename and revert
await page.fill("#org-rename", "renamed-org");
await page.click("#org-rename-btn");
await expectToast(page, "Renamed");
await page.click("#account-btn");
await expect(page.locator("#menu-org-settings")).toContainText("renamed-org");
await page.keyboard.press("Escape");
await page.fill("#org-rename", "default");
await page.click("#org-rename-btn");
await page.click("#account-btn");
await expect(page.locator("#menu-org-settings")).toContainText("default");
await page.keyboard.press("Escape");
});
// The org page owns a URL like every other page: the account menu is a plain
// link to it, so a deep link and a reload both land on the same view.
test("org admin: is a real route, not panel state", async ({ page }) => {
await login(page);
await openOrgSettings(page);
await expect(page).toHaveURL(/\/orgs\/[^/]+$/);
const url = page.url();
await page.reload();
await expect(page.locator("#org-title")).toHaveText("default");
await page.goto("/");
await page.goto(url);
await expect(page.locator("#org-title")).toHaveText("default");
});
test("org admin: member role change round-trip", async ({ page }) => {
await login(page);
await openOrgSettings(page);
const sel = page.locator(".admin-item", { hasText: MEMBER }).locator("select");
await sel.selectOption("owner");
await expectToast(page, "Role updated");
await expect(sel).toHaveValue("owner");
await sel.selectOption("member");
await expect(sel).toHaveValue("member");
});
test("org admin: invite create shows in list, revoke removes it", async ({ page }) => {
await login(page);
await openOrgSettings(page);
await page.click(".admin-h .pbtn"); // New invite
await expectToast(page, "Invite");
const row = page.locator(".admin-item", { hasText: "/join/" }).first();
await expect(row).toBeVisible();
await expect(row.locator(".ai-tag")).toContainText("unused");
await row.locator(".ai-del").click();
await page.click(".modal .danger-btn"); // confirm revoke
await expectToast(page, "Revoked");
await expect(page.locator(".admin-item", { hasText: "/join/" })).toHaveCount(0);
});
test("org admin: public share audit lists and revokes", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.request.post(`/api/p/${pid}/shares`, { data: { path: "index.md" } });
await openOrgSettings(page);
const row = page.locator(".admin-item", { hasText: "index.md" });
await expect(row).toBeVisible();
await expect(row.locator(".ai-tag")).toContainText("wiki");
// BEA-130: the row hands the link back. Assert the toast, not the
// clipboard — the suite grants no clipboard-read permission, and the
// confirmation is what the user actually sees.
await row.locator("button[aria-label='Copy the public link to index.md']").click();
await expectToast(page, "Copied.");
// …and Revoke is not displaced by it.
await row.locator(".ai-del").click();
await page.click(".modal .danger-btn");
await expectToast(page, "Share revoked");
await expect(page.locator(".admin-item", { hasText: "index.md" })).toHaveCount(0);
});
test("org admin: the project list is read-only; rename lives on project settings", async ({
page,
}) => {
await login(page);
const made = await (await page.request.post("/api/projects", { data: { name: "doomed" } })).json();
await page.reload(); // pick up the new project
await openOrgSettings(page);
const row = page.locator(".admin-item", { hasText: "doomed" });
await expect(row).toBeVisible();
// Neither affordance lives here any more: renaming and deleting a project
// both happen on the project's own Settings page.
await expect(row.locator(".ai-btn", { hasText: "Rename" })).toHaveCount(0);
await expect(row.locator(".ai-del")).toHaveCount(0);
// …and renaming there works, showing up in the nav.
await page.goto(`/${made.project.id}/settings`);
await page.fill("#ps-name", "doomed-2");
await page.click("#ps-save");
await expectToast(page, "Saved");
await expect(page.locator("#projects .proj-trigger")).toContainText("doomed-2");
await page.request.delete("/api/projects/" + made.project.id); // clean up
});
test("member sees the org panel read-only", async ({ page }) => {
await login(page, MEMBER);
await openOrgSettings(page);
// The role reads as a chip beside the name, not as part of it, and the
// short page explains itself rather than looking truncated.
// The chip is a sibling of the <h1>, not a child: inside it, the
// accessible name of the heading came out as "defaultMember".
await expect(page.locator(".role-chip")).toHaveText("Member");
await expect(page.locator(".admin-sub")).toContainText("Only owners");
await expect(page.locator("#org-rename")).toHaveCount(0);
await expect(page.locator(".admin-item select")).toHaveCount(0);
await expect(page.locator(".admin-item .ai-tag").first()).toBeVisible(); // role tags
});
test("hub settings: policy view, save round-trip, pending queue empty", async ({ page }) => {
await login(page);
await page.click("#account-btn");
await page.click("#menu-hub-admin");
await expect(page.locator("#crumb")).toHaveText("Signup & access");
await expect(page.locator(".admin h1")).toHaveText("Signup & access");
// Server has no SMTP: verification toggle disabled
const ver = page.locator(".admin-item.toggle").first().locator("input");
await expect(ver).toBeDisabled();
await expect(page.locator(".admin-item", { hasText: "Self-signup" })).toContainText("invite-only");
await expect(page.locator(".admin-item", { hasText: "Hub admins" })).toContainText(ADMIN);
// Toggle approval on, save, revert
const app = page.locator(".admin-item.toggle").nth(1).locator("input");
await app.check();
await page.click('.admin button:has-text("Save policy")');
await expectToast(page, "policy saved");
await app.uncheck();
await page.click('.admin button:has-text("Save policy")');
await expectToast(page, "policy saved");
await expect(page.locator(".admin-empty", { hasText: "No one is waiting" })).toBeVisible();
});
test("navigating away closes an open admin panel", async ({ page }) => {
await login(page);
await page.click("#account-btn");
await page.click("#menu-hub-admin");
await expect(page.locator(".admin h1")).toBeVisible();
await page.click('#tree .row[data-path="index.md"]');
await expect(page.locator("#content h1")).toHaveText("Wiki");
await expect(page.locator(".admin")).toHaveCount(0);
});
test("members table sorts by email", async ({ page }) => {
await login(page);
await openOrgSettings(page);
const emails = page.locator(".admin-table .admin-item .ai-main");
await expect(emails.first()).toBeVisible();
const before = await emails.allTextContents();
// Sorting is a button inside the header cell so it is reachable by
// keyboard; the header also announces the direction via aria-sort.
await page.click('.admin-table th:has-text("Member") .th-sort');
await expect(page.locator('.admin-table th:has-text("Member")')).toHaveAttribute("aria-sort", /ascending|descending/);
const after = await emails.allTextContents();
expect([...before].reverse()).toEqual(after);
});
// Project-scoped invites: the link an owner mints from a project's Settings
// carries "?p=<project-id>", so the newcomer lands on that project's install
// page — paste prompt already naming the project — instead of a nameless
// project list. An unresolvable p must fall back to "/", never to the
// "Project not found" page.
// Mints an org invite through the API and returns its bare token.
async function mintInvite(page: import("@playwright/test").Page, orgId: string) {
const out = await (await page.request.post(`/api/orgs/${orgId}/invites`)).json();
return out.url.split("/join/")[1];
}
async function defaultOrgId(page: import("@playwright/test").Page) {
const out = await (await page.request.get("/api/orgs")).json();
return out.orgs.find((o: { name: string }) => o.name === "default").id;
}
test("project settings: an owner mints an invite link scoped to this project", async ({
page,
context,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/settings`);
await page.click("#ps-invite");
await expectToast(page, "Invite link copied");
const link = await page.evaluate(() => navigator.clipboard.readText());
expect(link).toContain("/join/");
expect(link).toContain(`?p=${pid}`);
// Leave the hub as we found it: the suite shares one hub per run.
const tok = link.split("/join/")[1].split("?")[0];
await page.request.delete(`/api/orgs/${await defaultOrgId(page)}/invites/${tok}`);
});
test("project settings: a non-owner is offered no invite button", async ({ page }) => {
await login(page, MEMBER);
const pid = await wikiId(page);
await page.goto(`/${pid}/settings`);
// The card itself renders — it is only the mint control that is owners-only.
await expect(page.locator(".ps-people")).toBeVisible();
await expect(page.locator("#ps-invite")).toHaveCount(0);
});
test("a ?p= invite lands the joiner on that project's install page", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
const orgId = await defaultOrgId(page);
const tok = await mintInvite(page, orgId);
// Accepting as an existing owner is safe: AddMember never downgrades one.
await page.goto(`/join/${tok}?p=${pid}`);
await page.waitForURL(new RegExp(`/${pid}/install$`));
await expect(page.locator(".guide")).toBeVisible();
// The paste prompt names this project, which is the whole point of ?p=.
await expect(page.locator(".gd-code").first()).toContainText(pid);
await page.request.delete(`/api/orgs/${orgId}/invites/${tok}`);
});
test("an invite naming a project you cannot see falls back to the home view", async ({ page }) => {
await login(page);
const orgId = await defaultOrgId(page);
const tok = await mintInvite(page, orgId);
await page.goto(`/join/${tok}?p=00000000-0000-0000-0000-000000000000`);
await page.waitForURL(/localhost:8993\/$/);
await expect(page.locator("#sidebar")).toBeVisible();
await expect(page.locator("#content")).not.toContainText("Project not found");
await page.request.delete(`/api/orgs/${orgId}/invites/${tok}`);
});