fix(web): defer issuer validation to onBlur, block silent-drop on close, require a real host URL (closes #1544) (#1545)
* fix(web): defer IdP issuer URL error to onBlur in Client Settings (#1544) The Enterprise-Managed Authorization Issuer field validated its URL inline on every render, so the 'Must be an http(s) URL' error nagged while the user was still mid-typing (e.g. before 'https:/…' is complete). Gate the issuer error display on an issuerTouched flag set onBlur; once touched it tracks the value live, so the error clears as soon as a valid URL is entered. The persist gate (canPersistClientSettingsDraft → validateClientSettings) is unchanged and still validates independently, so an invalid issuer is never written to the backend regardless of touched state. Closes #1544 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): reveal issuer error and block close on invalid issuer (#1544) Extend the on-blur deferral so an invalid issuer can't be silently dropped on modal close (the auto-save flush would otherwise discard it with no feedback). ClientSettingsModal now intercepts its close (X / Esc / overlay): if the issuer is invalid it reveals the field error (via the form's revealIssuerError prop, overriding on-blur gating) and keeps the modal open instead of closing. The user can fix the URL or clear the field — an empty issuer is valid to leave. The block lives in the modal (unit-tested) rather than App, so App.tsx stays a thin flush+close that only runs when the close is allowed; the persist gate is unchanged. Reveal state resets via the modal's open/close remount key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): require a real host in isAbsoluteHttpUrl, not just an http(s) scheme (#1544) The issuer validation used URL.canParse + an http(s) protocol check, which the WHATWG parser accepts for bare/degenerate values that aren't actually usable URLs — e.g. https://foo, https://example, https://. , https://.. all passed. Tighten the shared rule (used by both the Zod config schema and the form's inline validation): after parsing and the http(s) scheme check, require the host to actually look like a host — localhost, an IPv6 literal, or a dotted domain/IPv4 with at least two non-empty labels. Single-label, bare-dot, and empty-label hosts are now rejected. Add core tests for the accepted (dotted/IPv4/IPv6/localhost) and newly-rejected cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f43fafc08b
commit
4a927d3978
2 changed files with 295 additions and 22 deletions
|
|
@ -1,8 +1,12 @@
|
|||
import { useState } from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { ClientSettingsModal } from "./ClientSettingsModal";
|
||||
import { EMPTY_CLIENT_SETTINGS } from "../ClientSettingsForm/clientSettingsValues";
|
||||
import {
|
||||
EMPTY_CLIENT_SETTINGS,
|
||||
ISSUER_URL_ERROR,
|
||||
} from "../ClientSettingsForm/clientSettingsValues";
|
||||
|
||||
describe("ClientSettingsModal", () => {
|
||||
it("renders the title when opened", () => {
|
||||
|
|
@ -120,6 +124,94 @@ describe("ClientSettingsModal", () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks close and reveals the issuer error when the issuer is invalid", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "not-a-url",
|
||||
}}
|
||||
onClose={onClose}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Error is hidden initially (form gates it on blur)...
|
||||
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
|
||||
// ...but a close attempt reveals it and does NOT close (no silent drop).
|
||||
await user.click(
|
||||
document.querySelector("button.mantine-CloseButton-root")!,
|
||||
);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(ISSUER_URL_ERROR)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows close once the invalid issuer is corrected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
function Host() {
|
||||
const [settings, setSettings] = useState({
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "not-a-url",
|
||||
});
|
||||
return (
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={settings}
|
||||
onClose={onClose}
|
||||
onSettingsChange={setSettings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
renderWithMantine(<Host />);
|
||||
|
||||
const close = () =>
|
||||
user.click(document.querySelector("button.mantine-CloseButton-root")!);
|
||||
|
||||
await close(); // blocked, error revealed
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
const issuer = screen.getByLabelText("Issuer");
|
||||
await user.clear(issuer);
|
||||
await user.type(issuer, "https://idp.test");
|
||||
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
|
||||
|
||||
await close(); // now valid -> closes
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows close when an invalid issuer is cleared (empty is valid to leave)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
function Host() {
|
||||
const [settings, setSettings] = useState({
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "not-a-url",
|
||||
});
|
||||
return (
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={settings}
|
||||
onClose={onClose}
|
||||
onSettingsChange={setSettings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
renderWithMantine(<Host />);
|
||||
|
||||
await user.clear(screen.getByLabelText("Issuer"));
|
||||
await user.click(
|
||||
document.querySelector("button.mantine-CloseButton-root")!,
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not render the modal body when closed", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import {
|
|||
type ClientSettingsSection,
|
||||
} from "../ClientSettingsForm/ClientSettingsForm";
|
||||
import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js";
|
||||
import type { ClientSettingsFormValues } from "../ClientSettingsForm/clientSettingsValues.js";
|
||||
import {
|
||||
validateClientSettings,
|
||||
type ClientSettingsFormValues,
|
||||
} from "../ClientSettingsForm/clientSettingsValues.js";
|
||||
|
||||
const ALL_SECTIONS: ClientSettingsSection[] = ["ema"];
|
||||
|
||||
|
|
@ -30,6 +33,7 @@ export function ClientSettingsModal({
|
|||
const [expandedSections, setExpandedSections] = useState<
|
||||
ClientSettingsSection[]
|
||||
>(["ema"]);
|
||||
const [revealIssuerError, setRevealIssuerError] = useState(false);
|
||||
|
||||
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
|
||||
|
||||
|
|
@ -37,10 +41,24 @@ export function ClientSettingsModal({
|
|||
setExpandedSections(allExpanded ? [] : ALL_SECTIONS);
|
||||
}
|
||||
|
||||
// Closing (X / Esc / overlay) is the implicit "save" for this auto-saving
|
||||
// modal — the parent flushes the debounced persist on close. If the issuer is
|
||||
// invalid the persist gate would silently drop it, so instead of closing we
|
||||
// reveal the issuer error (overriding the form's on-blur gating). The user
|
||||
// can fix the URL or clear the field — an empty issuer is valid to leave —
|
||||
// and then close. Resets via the parent's open/close remount key.
|
||||
function handleClose() {
|
||||
if (validateClientSettings(settings).issuer) {
|
||||
setRevealIssuerError(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
onClose={handleClose}
|
||||
withCloseButton={false}
|
||||
size="lg"
|
||||
centered
|
||||
|
|
@ -55,7 +73,7 @@ export function ClientSettingsModal({
|
|||
<Title order={4} ta="center" flex={1}>
|
||||
Client Settings
|
||||
</Title>
|
||||
<CloseButton onClick={onClose} />
|
||||
<CloseButton onClick={handleClose} />
|
||||
</Group>
|
||||
<ClientSettingsForm
|
||||
settings={settings}
|
||||
|
|
@ -64,6 +82,7 @@ export function ClientSettingsModal({
|
|||
onSettingsChange={onSettingsChange}
|
||||
emaIdpLoginState={emaIdpLoginState}
|
||||
onEmaIdpLogout={onEmaIdpLogout}
|
||||
revealIssuerError={revealIssuerError}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
|
|
|||
Loading…
Reference in a new issue