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
72 changes: 72 additions & 0 deletions console/ui/e2e/hosts.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
9 changes: 7 additions & 2 deletions console/ui/e2e/link.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand Down
89 changes: 89 additions & 0 deletions console/ui/e2e/rules.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
Expand Down
26 changes: 21 additions & 5 deletions console/ui/src/components/ConsoleBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<HostSummary | null>(null);
const navigate = useNavigate();
const canWrite = useCanWrite();

// Decoded by React Router, so this is the host itself, not its URL spelling.
//
Expand Down Expand Up @@ -161,18 +164,30 @@ export default function ConsoleBody({ distribution, onUnlinked }: Props) {
current={null}
onAdd={() => setAdding(true)}
onDelete={setDeleting}
canWrite={canWrite}
/>

<main className="console-note">
<h1>No hosts yet</h1>
<p>
A host is a domain your distribution serves. Add one to start
writing redirect and rewrite rules for it.
</p>
{canWrite ? (
<p>
A host is a domain your distribution serves. Add one to start
writing redirect and rewrite rules for it.
</p>
) : (
<p>
A host is a domain your distribution serves. Your account has
read-only access: ask an editor to add one.
</p>
)}
{/* Disabled for a viewer rather than hidden, like every other write
control: the API refuses it either way (CF-59). */}
<button
className="btn btn-primary"
type="button"
onClick={() => setAdding(true)}
disabled={!canWrite}
title={canWrite ? undefined : READ_ONLY}
>
Add a host
</button>
Expand All @@ -199,6 +214,7 @@ export default function ConsoleBody({ distribution, onUnlinked }: Props) {
current={shown}
onAdd={() => setAdding(true)}
onDelete={setDeleting}
canWrite={canWrite}
/>

{known ? (
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 12 additions & 2 deletions console/ui/src/components/HostsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +40,7 @@ export default function HostsSidebar({
current,
onAdd,
onDelete,
canWrite,
}: Props) {
return (
<nav className="hosts" aria-label="Hosts">
Expand All @@ -43,10 +51,11 @@ export default function HostsSidebar({
className="hosts-add"
type="button"
onClick={onAdd}
disabled={!canWrite}
// The icon alone says nothing to a screen reader, and "add" without a
// noun is no better in a page that also adds rules.
aria-label="Add a host"
title="Add a host"
title={canWrite ? "Add a host" : READ_ONLY}
>
<IconPlus size={15} />
</button>
Expand Down Expand Up @@ -105,10 +114,11 @@ export default function HostsSidebar({
className="hosts-del"
type="button"
onClick={() => onDelete(host)}
disabled={!canWrite}
// Names the host: "Delete" alone is ambiguous read out of a list
// of them, and this is the destructive one.
aria-label={`Delete ${host.host}`}
title={`Delete ${host.host}`}
title={canWrite ? `Delete ${host.host}` : READ_ONLY}
>
<IconTrash size={14} />
</button>
Expand Down
7 changes: 3 additions & 4 deletions console/ui/src/components/OnboardingScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,10 @@ export default function OnboardingScreen({
<div className="onboard-main">
{readOnly ? (
<div className="onboard-connect">
<h1>Link your distribution</h1>
<h1>No distribution linked yet</h1>
<p className="onboard-sub">
No distribution is linked yet. Your account has read-only
access: ask an editor to link one.
Your account has read-only access, so it cannot link one. Ask an
editor or an admin to set up a distribution.
</p>
</div>
) : (
Expand Down Expand Up @@ -206,7 +206,6 @@ export default function OnboardingScreen({
className="btn btn-primary btn-lg"
type="submit"
disabled={!valid || pending}
style={!valid || pending ? { opacity: 0.5 } : undefined}
>
{pending ? "Linking…" : "Link"}
<IconArrow size={18} />
Expand Down
Loading
Loading