diff --git a/clients/web/src/components/elements/CopyButton/CopyButton.test.tsx b/clients/web/src/components/elements/CopyButton/CopyButton.test.tsx
index 9a4b462a..0b663cae 100644
--- a/clients/web/src/components/elements/CopyButton/CopyButton.test.tsx
+++ b/clients/web/src/components/elements/CopyButton/CopyButton.test.tsx
@@ -10,6 +10,17 @@ describe("CopyButton", () => {
expect(screen.getByText("⎘")).toBeInTheDocument();
});
+ it("names itself 'Copy' with no label, and qualifies the name when given one", () => {
+ const { unmount } = renderWithMantine();
+ expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
+ unmount();
+
+ renderWithMantine();
+ expect(
+ screen.getByRole("button", { name: "Copy ID Token" }),
+ ).toBeInTheDocument();
+ });
+
it("triggers a click without throwing when clipboard is unavailable", async () => {
const user = userEvent.setup();
renderWithMantine();
diff --git a/clients/web/src/components/elements/CopyButton/CopyButton.tsx b/clients/web/src/components/elements/CopyButton/CopyButton.tsx
index 7a86ca08..5b87719d 100644
--- a/clients/web/src/components/elements/CopyButton/CopyButton.tsx
+++ b/clients/web/src/components/elements/CopyButton/CopyButton.tsx
@@ -11,6 +11,15 @@ export interface CopyButtonProps {
* rows (e.g. beside a Code block). Icon size is unchanged.
*/
flush?: boolean;
+ /**
+ * Names what is being copied, for the accessible label only ("Copy ID
+ * Token"). Set it when more than one CopyButton can appear in the same
+ * region, so button-by-button screen-reader navigation can tell them apart;
+ * a lone control needs no qualifier. The visible tooltip stays "Copy" either
+ * way, and the accessible name keeps "Copy"/"Copied" as its first word so it
+ * still contains the visible text (WCAG 2.5.3).
+ */
+ label?: string;
}
const CopyActionIcon = ActionIcon.withProps({
@@ -18,7 +27,8 @@ const CopyActionIcon = ActionIcon.withProps({
fz: 24,
});
-export function CopyButton({ value, flush = false }: CopyButtonProps) {
+export function CopyButton({ value, flush = false, label }: CopyButtonProps) {
+ const qualifier = label ? ` ${label}` : "";
return (
{({ copied, copy }) => (
@@ -26,7 +36,7 @@ export function CopyButton({ value, flush = false }: CopyButtonProps) {
{copied ? "\u2713" : "\u2398"}
diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx
index a634b9de..c6f1e598 100644
--- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx
+++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx
@@ -189,7 +189,9 @@ export const WithOAuthIdToken: Story = {
const canvas = within(canvasElement);
expect(canvas.getByText("ID Token")).toBeInTheDocument();
// Only the ID token is a JWT here, so exactly one decode toggle is offered.
- await userEvent.click(canvas.getByRole("button", { name: "Decode JWT" }));
+ await userEvent.click(
+ canvas.getByRole("button", { name: "Decode JWT for ID Token" }),
+ );
expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument();
},
};
diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx
index e33c63ad..f9f3d2fa 100644
--- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx
+++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx
@@ -495,6 +495,41 @@ describe("ConnectionInfoContent", () => {
expect(screen.getByText("id-token-456")).toBeInTheDocument();
});
+ it("gives the two token rows' controls distinct accessible names", () => {
+ // Both rows carry a copy control, and both tokens here are JWTs so both
+ // carry a decode toggle. Screen-reader button navigation has only the
+ // accessible name to go on, so the four must not collide (#2019 review).
+ const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
+ renderWithMantine(
+ ,
+ );
+
+ const names = screen
+ .getAllByRole("button")
+ .map((button) => button.getAttribute("aria-label") ?? button.textContent)
+ .filter((name): name is string => Boolean(name));
+ expect(new Set(names).size).toBe(names.length);
+
+ for (const name of [
+ "Copy Access Token",
+ "Copy ID Token",
+ "Decode JWT for Access Token",
+ "Decode JWT for ID Token",
+ ]) {
+ expect(screen.getByRole("button", { name })).toBeInTheDocument();
+ }
+ });
+
it("renders the ID Token row on its own when there is no access token", () => {
renderWithMantine(
{
expect(screen.getByText("Access Token")).toBeInTheDocument();
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Copy Access Token" }),
+ ).toBeInTheDocument();
});
it("replaces raw token with decoded JSON and restores on toggle", async () => {
@@ -20,11 +22,15 @@ describe("OAuthTokenField", () => {
renderWithMantine();
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
- await user.click(screen.getByRole("button", { name: "Decode JWT" }));
+ await user.click(
+ screen.getByRole("button", { name: "Decode JWT for Access Token" }),
+ );
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument();
- await user.click(screen.getByRole("button", { name: "Show token" }));
+ await user.click(
+ screen.getByRole("button", { name: "Show token for Access Token" }),
+ );
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
});
@@ -37,7 +43,7 @@ describe("OAuthTokenField", () => {
/>,
);
expect(
- screen.queryByRole("button", { name: "Decode JWT" }),
+ screen.queryByRole("button", { name: "Decode JWT for Access Token" }),
).not.toBeInTheDocument();
});
@@ -50,8 +56,10 @@ describe("OAuthTokenField", () => {
});
renderWithMantine();
- await user.click(screen.getByRole("button", { name: "Decode JWT" }));
- await user.click(screen.getByRole("button", { name: "Copy" }));
+ await user.click(
+ screen.getByRole("button", { name: "Decode JWT for Access Token" }),
+ );
+ await user.click(screen.getByRole("button", { name: "Copy Access Token" }));
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('"sub": "user"'),
@@ -68,7 +76,7 @@ describe("OAuthTokenField", () => {
});
renderWithMantine();
- await user.click(screen.getByRole("button", { name: "Copy" }));
+ await user.click(screen.getByRole("button", { name: "Copy Access Token" }));
expect(writeText).toHaveBeenCalledWith(jwt);
});
@@ -101,10 +109,25 @@ describe("OAuthTokenField", () => {
expect(screen.getByText("ID Token")).toBeInTheDocument();
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
- await user.click(screen.getByRole("button", { name: "Decode JWT" }));
+ await user.click(
+ screen.getByRole("button", { name: "Decode JWT for ID Token" }),
+ );
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
});
+ it("qualifies the decode and copy accessible names with the row label", () => {
+ // Two rows can render simultaneously (Access Token + ID Token), so the
+ // controls must not share accessible names — see #2019 review.
+ renderWithMantine();
+
+ expect(
+ screen.getByRole("button", { name: "Decode JWT for ID Token" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Copy ID Token" }),
+ ).toBeInTheDocument();
+ });
+
it("omits the clear action when no handler is given", () => {
renderWithMantine();
expect(
diff --git a/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx
index a9c19b2d..abd5c4a9 100644
--- a/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx
+++ b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx
@@ -107,6 +107,7 @@ export function OAuthTokenField({
setShowDecoded((open) => !open)}
aria-pressed={showDecoded}
+ aria-label={`${showDecoded ? "Show token" : "Decode JWT"} for ${label}`}
>
{showDecoded ? "Show token" : "Decode JWT"}
@@ -130,7 +131,7 @@ export function OAuthTokenField({
)}
-
+
);