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:
Cliff Hall 2026-06-29 11:56:27 -04:00 • committed by GitHub
commit 4a927d3978
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 295 additions and 22 deletions

View file

@ -1,3 +1,4 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
@ -5,8 +6,27 @@ import { ClientSettingsForm } from "./ClientSettingsForm";
import {
EMPTY_CLIENT_SETTINGS,
ISSUER_URL_ERROR,
type ClientSettingsFormValues,
} from "./clientSettingsValues";
/** Stateful host so the controlled issuer input reflects edits, like the app. */
function ClientSettingsFormHarness({
initial,
}: {
initial: ClientSettingsFormValues;
}) {
const [settings, setSettings] = useState(initial);
return (
<ClientSettingsForm
settings={settings}
expandedSections={["ema"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={setSettings}
emaIdpLoginState="none"
/>
);
}
describe("ClientSettingsForm EMA IdP session", () => {
it("shows signed-in state and sign out", async () => {
const user = userEvent.setup();
@ -53,7 +73,8 @@ describe("ClientSettingsForm EMA IdP session", () => {
).not.toBeInTheDocument();
});
it("shows an inline error for an invalid issuer URL", () => {
it("defers the invalid-issuer error until the field is blurred", async () => {
const user = userEvent.setup();
renderWithMantine(
<ClientSettingsForm
settings={{
@ -68,9 +89,79 @@ describe("ClientSettingsForm EMA IdP session", () => {
/>,
);
// Invalid value present, but the error stays hidden until the user leaves
// the field — no nagging while it may still be mid-typing.
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
await user.click(screen.getByLabelText("Issuer"));
await user.tab(); // blur the issuer field
expect(screen.getByText(ISSUER_URL_ERROR)).toBeInTheDocument();
});
it("clears the issuer error live once a valid URL is entered after blur", async () => {
const user = userEvent.setup();
renderWithMantine(
<ClientSettingsFormHarness
initial={{
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "not-a-url",
}}
/>,
);
const issuer = screen.getByLabelText("Issuer");
await user.click(issuer);
await user.tab(); // touched -> error shows for the invalid value
expect(screen.getByText(ISSUER_URL_ERROR)).toBeInTheDocument();
// Once touched, the error tracks the value live: fixing it clears the error
// without needing another blur.
await user.clear(issuer);
await user.type(issuer, "https://idp.test");
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
});
it("reveals the issuer error without blur when revealIssuerError is set", () => {
renderWithMantine(
<ClientSettingsForm
settings={{
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "not-a-url",
}}
expandedSections={["ema"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
emaIdpLoginState="none"
revealIssuerError
/>,
);
// Parent forced the error on (e.g. a close was attempted) — shown even
// though the field was never blurred.
expect(screen.getByText(ISSUER_URL_ERROR)).toBeInTheDocument();
});
it("shows no error for a valid issuer even when revealIssuerError is set", () => {
renderWithMantine(
<ClientSettingsForm
settings={{
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "https://idp.test",
}}
expandedSections={["ema"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
emaIdpLoginState="none"
revealIssuerError
/>,
);
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
});
it("shows no issuer error for a valid URL", () => {
renderWithMantine(
<ClientSettingsForm

View file

@ -1,3 +1,4 @@
import { useState } from "react";
import {
Accordion,
Badge,
@ -24,6 +25,12 @@ export interface ClientSettingsFormProps {
onSettingsChange: (settings: ClientSettingsFormValues) => void;
emaIdpLoginState?: EmaIdpLoginState;
onEmaIdpLogout?: () => void;
/**
* Force the issuer error to show even before the field is blurred. The parent
* sets this when a save/close is attempted with an invalid issuer, so the user
* isn't left with a silently-dropped value and no explanation.
*/
revealIssuerError?: boolean;
}
const HintText = Text.withProps({
@ -38,12 +45,26 @@ export function ClientSettingsForm({
onSettingsChange,
emaIdpLoginState = "none",
onEmaIdpLogout,
revealIssuerError = false,
}: ClientSettingsFormProps) {
function patch(partial: Partial<ClientSettingsFormValues>) {
onSettingsChange({ ...settings, ...partial });
}
// Defer the issuer error until the field has been blurred so it doesn't nag
// mid-typing (e.g. while "https:/…" is still incomplete). Once touched it
// updates live, so the error clears as soon as a valid URL is entered. The
// parent also forces it on via `revealIssuerError` when a close/save is
// attempted with an invalid value, so the value is never silently dropped
// without explanation. The persist gate (canPersistClientSettingsDraft)
// validates independently — an invalid issuer is never written regardless.
const [issuerTouched, setIssuerTouched] = useState(false);
const errors = validateClientSettings(settings);
const showIssuerError =
(issuerTouched || revealIssuerError) && errors.issuer
? errors.issuer
: undefined;
const showIdpSession =
settings.emaEnabled &&
@ -82,7 +103,8 @@ export function ClientSettingsForm({
description="Your enterprise IdP issuer URL."
value={settings.issuer}
onChange={(e) => patch({ issuer: e.currentTarget.value })}
error={errors.issuer}
onBlur={() => setIssuerTouched(true)}
error={showIssuerError}
rightSectionPointerEvents="auto"
rightSection={
settings.issuer ? (