From 5f1f95a5528be613e47a9dba9c1c1cf58311cd80 Mon Sep 17 00:00:00 2001 From: scott Date: Fri, 16 Oct 2026 15:19:47 -0400 Subject: [PATCH] fix(console): make the console read-only for viewers across hosts, rules and linking --- console/ui/e2e/hosts.e2e.ts | 72 +++++++++++++++ console/ui/e2e/link.e2e.ts | 9 +- console/ui/e2e/rules.e2e.ts | 89 +++++++++++++++++++ console/ui/src/components/ConsoleBody.tsx | 26 ++++-- console/ui/src/components/HostsSidebar.tsx | 14 ++- .../ui/src/components/OnboardingScreen.tsx | 7 +- console/ui/src/components/RuleEditor.tsx | 27 ++++-- console/ui/src/components/RuleList.tsx | 4 + console/ui/src/index.css | 34 +++++-- 9 files changed, 253 insertions(+), 29 deletions(-) diff --git a/console/ui/e2e/hosts.e2e.ts b/console/ui/e2e/hosts.e2e.ts index d0dd3a2..146132a 100644 --- a/console/ui/e2e/hosts.e2e.ts +++ b/console/ui/e2e/hosts.e2e.ts @@ -229,6 +229,78 @@ test("an empty target invites adding a host, keeping the rail", async ({ ).toBeVisible(); }); +test.describe("a viewer", () => { + // CF-59. Disabled rather than hidden, like every other write control: a dead + // button with a reason reads as a permission. The API refuses either way. + test.beforeEach(({ api }) => api.signedInAs("viewer")); + + test("cannot add or delete a host, but can still open one", async ({ + page, + api, + }) => { + api.setHosts(await seed(page)); + await gotoConsole(page); + + const rail = page.getByRole("navigation", { name: "Hosts" }); + const add = rail.getByRole("button", { name: "Add a host" }); + await expect(add).toBeDisabled(); + await expect(add).toHaveAttribute("title", /read-only/i); + + const deletes = rail.getByRole("button", { name: /^Delete / }); + await expect(deletes).toHaveCount(3); + for (const button of await deletes.all()) { + await expect(button).toBeDisabled(); + await expect(button).toHaveAttribute("title", /read-only/i); + } + + await row(page, "shop.example.com").click(); + await expect(page).toHaveURL(/\/console\/hosts\/shop\.example\.com$/); + await expect(page.locator("h1.host-name")).toHaveText("shop.example.com"); + }); + + test("the keyboard skips add and delete", async ({ page, api }) => { + api.setHosts(await seed(page, [www])); + await gotoConsole(page); + await expect(page.locator("h1.host-name")).toHaveText("www.example.com"); + + // From the host link, Tab goes on past its delete button. + await row(page, "www.example.com").focus(); + await page.keyboard.press("Tab"); + await expect( + page.getByRole("button", { name: "Delete www.example.com" }), + ).not.toBeFocused(); + await expect(page.locator(".hosts-del:focus")).toHaveCount(0); + + // And back from the host link, past the add button above it. + await row(page, "www.example.com").focus(); + await page.keyboard.press("Shift+Tab"); + await expect(page.locator(".hosts-add:focus")).toHaveCount(0); + }); + + test("an empty target says to ask an editor", async ({ page, api }) => { + api.setHosts([]); + await seed(page); + await gotoConsole(page); + + const main = page.locator("main"); + await expect( + main.getByRole("heading", { name: "No hosts yet" }), + ).toBeVisible(); + await expect( + main.getByRole("button", { name: "Add a host" }), + ).toBeDisabled(); + await expect( + main.getByRole("button", { name: "Add a host" }), + ).toHaveAttribute("title", /read-only/i); + await expect(main).toContainText("ask an editor to add one"); + await expect( + page + .getByRole("navigation", { name: "Hosts" }) + .getByRole("button", { name: "Add a host" }), + ).toBeDisabled(); + }); +}); + test("a failed load says so and can be retried", async ({ page, api }) => { await seed(page); api.setHosts([www]); diff --git a/console/ui/e2e/link.e2e.ts b/console/ui/e2e/link.e2e.ts index a72129c..e62eca4 100644 --- a/console/ui/e2e/link.e2e.ts +++ b/console/ui/e2e/link.e2e.ts @@ -42,10 +42,15 @@ test("a viewer gets no form, and who to ask instead", async ({ page, api }) => { await page.reload(); await expect( - page.getByRole("heading", { name: "Link your distribution" }), + page.getByRole("heading", { name: "No distribution linked yet" }), + ).toBeVisible(); + await expect( + page.getByText("Ask an editor or an admin to set up a distribution"), ).toBeVisible(); - await expect(page.getByText("ask an editor to link one")).toBeVisible(); await expect(page.getByLabel("CloudFront distribution")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Link", exact: true }), + ).toHaveCount(0); }); test("keeps submit disabled until both fields have a value", async ({ diff --git a/console/ui/e2e/rules.e2e.ts b/console/ui/e2e/rules.e2e.ts index 95c70fb..2941ef8 100644 --- a/console/ui/e2e/rules.e2e.ts +++ b/console/ui/e2e/rules.e2e.ts @@ -102,6 +102,29 @@ test("a viewer sees the write controls, disabled and explained", async ({ await expect(strayCreate(page)).toHaveCount(0); }); +test("a viewer's empty host offers no live way to create a rule", async ({ + page, + api, +}) => { + // CF-59. The "No rules yet" state's own two buttons were never wired to the + // role, so a viewer could open the editor and only learn on save. + api.signedInAs("viewer"); + api.setHosts([host("www.example.com")]); + api.setRules([]); + await openHost(page); + + await expect( + page.getByRole("heading", { name: "No rules yet" }), + ).toBeVisible(); + for (const name of ["New redirect", "New rewrite"]) { + const button = page.getByRole("button", { name }); + await expect(button).toBeDisabled(); + await expect(button).toHaveAttribute("title", /read-only/i); + // And looks it: a disabled .btn used to be styled exactly like a live one. + await expect(button).toHaveCSS("opacity", "0.5"); + } +}); + test("creating a rule is offered once, in the host header", async ({ page, api, @@ -134,6 +157,72 @@ test("a viewer can still open a rule to read it", async ({ page, api }) => { await expect(page.getByLabel("Redirect URL")).toBeVisible(); }); +test("a viewer's rule opens read-only, with nothing to save", async ({ + page, + api, +}) => { + // CF-59. The editor took no role, so a viewer could change the fields and + // only learn on save that the API refuses it. + api.signedInAs("viewer"); + api.setHosts([www]); + api.setRules([redirect]); + await openHost(page); + + await page.getByRole("button", { name: /^Edit / }).click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toContainText("View redirect"); + await expect(page.getByLabel("Redirect URL")).toHaveValue( + "https://www.example.com/new", + ); + await expect(page.getByLabel("Redirect URL")).toBeDisabled(); + await expect(dialog.getByRole("switch", { name: "Disabled" })).toBeDisabled(); + + const save = dialog.getByRole("button", { name: "Save changes" }); + await expect(save).toBeDisabled(); + await expect(save).toHaveAttribute("title", /read-only/i); + + await dialog + .locator(".modal-actions") + .getByRole("button", { name: "Close" }) + .click(); + await expect(dialog).toHaveCount(0); + expect( + api.calls.filter((c) => c.method !== "GET" && c.url.includes("/targets")), + ).toEqual([]); +}); + +test("a viewer's country rule does not ask the distribution", async ({ + page, + api, +}) => { + // The check is a write as far as the API is concerned: a viewer asking would + // only be refused, and the editor would say the distribution could not be + // checked. + api.signedInAs("viewer"); + api.setHosts([www]); + api.setRules([ + { + ...redirect, + matches: [ + ...redirect.matches, + { matchType: "country", matchOperator: "equals", matchValue: "FR" }, + ], + } as Rule, + ]); + await openHost(page); + + await page.getByRole("button", { name: /^Edit / }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + // Enabled, the check goes to "loading" as the editor mounts; it never leaves + // "idle" for a viewer. + await expect(page.locator("form#rule-editor")).toHaveAttribute( + "data-geo-check", + "idle", + ); + expect(api.calls.filter((c) => c.url.endsWith("/geo-readiness"))).toEqual([]); +}); + test("an editor's controls are live", async ({ page, api }) => { api.setHosts([www]); api.setRules([redirect]); diff --git a/console/ui/src/components/ConsoleBody.tsx b/console/ui/src/components/ConsoleBody.tsx index 10f4ac6..78ba37f 100644 --- a/console/ui/src/components/ConsoleBody.tsx +++ b/console/ui/src/components/ConsoleBody.tsx @@ -25,6 +25,8 @@ interface Props { onUnlinked?: (targetId: string) => void; } +const READ_ONLY = "Your account has read-only access"; + /** What the rule editor is open on: an existing rule, or the kind being created. */ type EditorTarget = Rule | "redirect" | "rewrite" | null; @@ -47,6 +49,7 @@ export default function ConsoleBody({ distribution, onUnlinked }: Props) { // go with it, and those counts are only in the list. const [deleting, setDeleting] = useState(null); const navigate = useNavigate(); + const canWrite = useCanWrite(); // Decoded by React Router, so this is the host itself, not its URL spelling. // @@ -161,18 +164,30 @@ export default function ConsoleBody({ distribution, onUnlinked }: Props) { current={null} onAdd={() => setAdding(true)} onDelete={setDeleting} + canWrite={canWrite} />

No hosts yet

-

- A host is a domain your distribution serves. Add one to start - writing redirect and rewrite rules for it. -

+ {canWrite ? ( +

+ A host is a domain your distribution serves. Add one to start + writing redirect and rewrite rules for it. +

+ ) : ( +

+ A host is a domain your distribution serves. Your account has + read-only access: ask an editor to add one. +

+ )} + {/* Disabled for a viewer rather than hidden, like every other write + control: the API refuses it either way (CF-59). */} @@ -199,6 +214,7 @@ export default function ConsoleBody({ distribution, onUnlinked }: Props) { current={shown} onAdd={() => setAdding(true)} onDelete={setDeleting} + canWrite={canWrite} /> {known ? ( @@ -300,7 +316,7 @@ function HostWorkspace({ // and say why reads it as a permission. The API refuses either way — this only // decides what the user is told. const canWrite = useCanWrite(); - const readOnly = canWrite ? undefined : "Your account has read-only access"; + const readOnly = canWrite ? undefined : READ_ONLY; /** Marks a row busy for the duration of a write, so it cannot be double-fired. */ const withBusy = useCallback( diff --git a/console/ui/src/components/HostsSidebar.tsx b/console/ui/src/components/HostsSidebar.tsx index d20eb35..4e495f7 100644 --- a/console/ui/src/components/HostsSidebar.tsx +++ b/console/ui/src/components/HostsSidebar.tsx @@ -10,8 +10,15 @@ interface Props { onAdd: () => void; /** Takes the whole summary, so the confirmation can name what will be lost. */ onDelete: (host: HostSummary) => void; + /** + * False for a viewer: add and delete stay on screen but disabled, with the + * reason, like every other write control. The host links still work (CF-59). + */ + canWrite: boolean; } +const READ_ONLY = "Your account has read-only access"; + /** * How many rules a host has, in words. The count includes disabled rules — it * counts what is stored, matching the badges beside it and the list the main @@ -33,6 +40,7 @@ export default function HostsSidebar({ current, onAdd, onDelete, + canWrite, }: Props) { return (