fix: accessible names for the authorization-parameter rows (#2018)
Copilot round 2 (suppressed comments). Five of the seven findings were the accessibility of the new row list and one stray cast; all are addressed here. - The reserved-key reason is now the key input's `error` **string** rather than a bare boolean plus free-standing text. Mantine renders it in the input's own error slot and wires the `aria-describedby` association, so a screen reader learns *why* the field is invalid; a boolean only set `aria-invalid`. This also deletes the duplicate `ReservedParamError` text and its Stack wrapper. - Each control carries a per-row `aria-label` — the section heading and the placeholders are not programmatically associated with the inputs, so the key box, the value box, and a remove button announcing only "X" were indistinguishable to assistive technology. The label names the row by its key, falling back to "row N" while the key is still blank. - Dropped an unnecessary `as unknown as Record<string, unknown>` in the servers-route integration test: `MCPConfig.mcpServers` is already typed as `Record<string, StoredMCPServer>`, so the entry's `oauth` reads directly. Three tests cover the new behavior (accessible names, the blank-key row-number fallback, and the aria-describedby association), each verified to fail against the pre-fix component. No change to the reserved-key rule or the EMA boundary. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
362e2f419d
commit
55734f3776
3 changed files with 105 additions and 23 deletions
|
|
@ -826,6 +826,83 @@ describe("ServerSettingsForm", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #2018 — the section heading and the placeholders are not programmatically
|
||||||
|
// associated with these inputs, so each control carries its own aria-label and
|
||||||
|
// the reserved-key reason is the input's `error` string (which Mantine wires
|
||||||
|
// to the input via aria-describedby) rather than free-standing text.
|
||||||
|
it("gives each authorization-parameter control an accessible name", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{
|
||||||
|
...emptySettings,
|
||||||
|
oauthAuthorizationParams: [{ key: "kc_idp_hint", value: "corp" }],
|
||||||
|
}}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("textbox", {
|
||||||
|
name: "Authorization parameter name, kc_idp_hint",
|
||||||
|
}),
|
||||||
|
).toHaveValue("kc_idp_hint");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("textbox", {
|
||||||
|
name: "Authorization parameter value, kc_idp_hint",
|
||||||
|
}),
|
||||||
|
).toHaveValue("corp");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Remove authorization parameter kc_idp_hint",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a row number in the accessible name when the key is blank", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{
|
||||||
|
...emptySettings,
|
||||||
|
oauthAuthorizationParams: [{ key: "", value: "" }],
|
||||||
|
}}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("textbox", {
|
||||||
|
name: "Authorization parameter name, row 1",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The reason must reach assistive tech through the input, not just as text on
|
||||||
|
// the page: Mantine emits the `error` string with an id the input references.
|
||||||
|
it("associates the reserved-key reason with the key input", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{
|
||||||
|
...emptySettings,
|
||||||
|
oauthAuthorizationParams: [{ key: "state", value: "spoofed" }],
|
||||||
|
}}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const keyInput = screen.getByRole("textbox", {
|
||||||
|
name: "Authorization parameter name, state",
|
||||||
|
});
|
||||||
|
const describedBy = keyInput.getAttribute("aria-describedby");
|
||||||
|
expect(describedBy).toBeTruthy();
|
||||||
|
const reason = describedBy
|
||||||
|
?.split(" ")
|
||||||
|
.map((id) => document.getElementById(id)?.textContent ?? "")
|
||||||
|
.join(" ");
|
||||||
|
expect(reason).toContain(
|
||||||
|
'"state" is set by the authorization flow and cannot be overridden.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not flag a blank authorization-parameter row as reserved", () => {
|
it("does not flag a blank authorization-parameter row as reserved", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ServerSettingsForm
|
<ServerSettingsForm
|
||||||
|
|
|
||||||
|
|
@ -202,11 +202,6 @@ const FieldDescription = Text.withProps({
|
||||||
c: "dimmed",
|
c: "dimmed",
|
||||||
});
|
});
|
||||||
|
|
||||||
const ReservedParamError = Text.withProps({
|
|
||||||
size: "xs",
|
|
||||||
c: "var(--inspector-danger-text)",
|
|
||||||
});
|
|
||||||
|
|
||||||
const ClearStoredOAuthButton = Button.withProps({
|
const ClearStoredOAuthButton = Button.withProps({
|
||||||
variant: "light",
|
variant: "light",
|
||||||
color: "red",
|
color: "red",
|
||||||
|
|
@ -268,9 +263,17 @@ function KeyValueRows({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reserved-key rejection is inline per row (#2018): the key input goes red with
|
// Reserved-key rejection is inline per row (#2018): the reason is passed as the
|
||||||
// the reason under it, and the section-level warning below repeats it so the
|
// key input's `error` **string**, so Mantine renders it in the input's own error
|
||||||
// rejection is visible even when the offending row is scrolled out of view.
|
// slot and wires the `aria-describedby` association — a bare boolean would mark
|
||||||
|
// the field invalid without telling a screen reader why. The section-level Alert
|
||||||
|
// below repeats it so the rejection stays visible when the offending row is
|
||||||
|
// scrolled out of view.
|
||||||
|
//
|
||||||
|
// Each control carries a per-row `aria-label`: the section heading and the
|
||||||
|
// placeholders are not programmatically associated with the inputs, so without
|
||||||
|
// one an assistive technology cannot tell the key box from the value box, and
|
||||||
|
// the remove button announces only "X".
|
||||||
function AuthorizationParamRows({
|
function AuthorizationParamRows({
|
||||||
params,
|
params,
|
||||||
onChange,
|
onChange,
|
||||||
|
|
@ -282,13 +285,15 @@ function AuthorizationParamRows({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{params.map((param, index) => (
|
{params.map((param, index) => {
|
||||||
<Stack key={index} gap={4}>
|
const rowLabel = param.key.trim() || `row ${index + 1}`;
|
||||||
<Group grow>
|
return (
|
||||||
|
<Group key={index} grow align="flex-start">
|
||||||
<ClearableTextInput
|
<ClearableTextInput
|
||||||
|
aria-label={`Authorization parameter name, ${rowLabel}`}
|
||||||
placeholder="Parameter (e.g. kc_idp_hint)"
|
placeholder="Parameter (e.g. kc_idp_hint)"
|
||||||
value={param.key}
|
value={param.key}
|
||||||
error={authorizationParamKeyError(param.key) !== undefined}
|
error={authorizationParamKeyError(param.key)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange(index, e.currentTarget.value, param.value)
|
onChange(index, e.currentTarget.value, param.value)
|
||||||
}
|
}
|
||||||
|
|
@ -301,6 +306,7 @@ function AuthorizationParamRows({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ClearableTextInput
|
<ClearableTextInput
|
||||||
|
aria-label={`Authorization parameter value, ${rowLabel}`}
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
value={param.value}
|
value={param.value}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|
@ -312,15 +318,15 @@ function AuthorizationParamRows({
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<RemoveIcon onClick={() => onRemove(index)}>X</RemoveIcon>
|
<RemoveIcon
|
||||||
|
aria-label={`Remove authorization parameter ${rowLabel}`}
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
>
|
||||||
|
X
|
||||||
|
</RemoveIcon>
|
||||||
</Group>
|
</Group>
|
||||||
{authorizationParamKeyError(param.key) ? (
|
);
|
||||||
<ReservedParamError>
|
})}
|
||||||
{authorizationParamKeyError(param.key)}
|
|
||||||
</ReservedParamError>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -471,9 +471,8 @@ describe("/api/servers routes", () => {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const stored = readConfig(h.configPath).mcpServers
|
const stored = readConfig(h.configPath).mcpServers.authparams;
|
||||||
.authparams as unknown as Record<string, unknown>;
|
expect(stored?.oauth).toEqual({
|
||||||
expect(stored.oauth).toEqual({
|
|
||||||
authorizationParams: { kc_idp_hint: "corp" },
|
authorizationParams: { kc_idp_hint: "corp" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue