fix(web): validate IdP issuer URL inline in Client Settings (#1533) (#1534)

The Enterprise-Managed Authorization "Issuer" field accepted arbitrary
non-URL text and only failed at save time, surfacing a raw Zod issue array
in "Failed to save client settings" toasts.

- Inline field-level validation: the Issuer input shows a friendly error
  ("Must be an http(s) URL, like https://idp.example.com").
- canPersistClientSettingsDraft gates on issuer validity (deferring to
  validateClientSettings) so an invalid value is never sent to the backend.
- Extract isAbsoluteHttpUrl in core/client/config-parse so the Zod schema
  and the form share one http(s)-only URL rule.
- Harden the fallback save-error toast via formatClientConfigLoadError.

Closes #1533

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-27 16:08:22 -04:00 • committed by GitHub
commit 1dc0c744cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 183 additions and 7 deletions

View file

@ -2,7 +2,10 @@ import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ClientSettingsForm } from "./ClientSettingsForm";
import { EMPTY_CLIENT_SETTINGS } from "./clientSettingsValues";
import {
EMPTY_CLIENT_SETTINGS,
ISSUER_URL_ERROR,
} from "./clientSettingsValues";
describe("ClientSettingsForm EMA IdP session", () => {
it("shows signed-in state and sign out", async () => {
@ -50,6 +53,42 @@ describe("ClientSettingsForm EMA IdP session", () => {
).not.toBeInTheDocument();
});
it("shows an inline error for an invalid issuer URL", () => {
renderWithMantine(
<ClientSettingsForm
settings={{
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "not-a-url",
}}
expandedSections={["ema"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
emaIdpLoginState="none"
/>,
);
expect(screen.getByText(ISSUER_URL_ERROR)).toBeInTheDocument();
});
it("shows no issuer error for a valid URL", () => {
renderWithMantine(
<ClientSettingsForm
settings={{
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "https://idp.test",
}}
expandedSections={["ema"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
emaIdpLoginState="none"
/>,
);
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
});
it("shows expired state with sign out", () => {
renderWithMantine(
<ClientSettingsForm

View file

@ -10,7 +10,10 @@ import {
} from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js";
import type { ClientSettingsFormValues } from "./clientSettingsValues.js";
import {
validateClientSettings,
type ClientSettingsFormValues,
} from "./clientSettingsValues.js";
export type ClientSettingsSection = "ema";
@ -40,6 +43,8 @@ export function ClientSettingsForm({
onSettingsChange({ ...settings, ...partial });
}
const errors = validateClientSettings(settings);
const showIdpSession =
settings.emaEnabled &&
settings.issuer.trim() !== "" &&
@ -77,6 +82,7 @@ export function ClientSettingsForm({
description="Your enterprise IdP issuer URL."
value={settings.issuer}
onChange={(e) => patch({ issuer: e.currentTarget.value })}
error={errors.issuer}
rightSectionPointerEvents="auto"
rightSection={
settings.issuer ? (

View file

@ -4,6 +4,8 @@ import {
clientConfigToFormValues,
EMPTY_CLIENT_SETTINGS,
formValuesToClientConfig,
ISSUER_URL_ERROR,
validateClientSettings,
} from "./clientSettingsValues";
describe("clientSettingsValues", () => {
@ -128,4 +130,59 @@ describe("clientSettingsValues", () => {
}),
).toBe(false);
});
it("canPersistClientSettingsDraft blocks an invalid issuer URL", () => {
expect(
canPersistClientSettingsDraft({
emaEnabled: true,
issuer: "not-a-url",
clientId: "cid",
clientSecret: "",
}),
).toBe(false);
});
it("validateClientSettings flags an invalid issuer URL", () => {
expect(
validateClientSettings({
emaEnabled: true,
issuer: "not-a-url",
clientId: "cid",
clientSecret: "",
}),
).toEqual({ issuer: ISSUER_URL_ERROR });
});
it("validateClientSettings passes a valid issuer URL", () => {
expect(
validateClientSettings({
emaEnabled: true,
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "",
}),
).toEqual({});
});
it("validateClientSettings ignores an empty issuer", () => {
expect(
validateClientSettings({
emaEnabled: true,
issuer: "",
clientId: "cid",
clientSecret: "",
}),
).toEqual({});
});
it("validateClientSettings ignores issuer when EMA disabled", () => {
expect(
validateClientSettings({
emaEnabled: false,
issuer: "not-a-url",
clientId: "cid",
clientSecret: "",
}),
).toEqual({});
});
});

View file

@ -1,4 +1,33 @@
import type { ClientConfig } from "@inspector/core/client/types.js";
import { isAbsoluteHttpUrl } from "@inspector/core/client/config-parse.js";
/** Field-level error message for an issuer that is not an http(s) URL. */
export const ISSUER_URL_ERROR =
"Must be an http(s) URL, like https://idp.example.com";
/** Field-level validation errors for the client settings form. */
export interface ClientSettingsErrors {
issuer?: string;
}
/**
* Inline validation for the client settings form. Only flags fields that the
* user has actually filled in — empty required fields are gated by
* {@link canPersistClientSettingsDraft} rather than surfaced as errors.
*/
export function validateClientSettings(
values: ClientSettingsFormValues,
): ClientSettingsErrors {
const errors: ClientSettingsErrors = {};
if (
values.emaEnabled &&
values.issuer.trim() !== "" &&
!isAbsoluteHttpUrl(values.issuer)
) {
errors.issuer = ISSUER_URL_ERROR;
}
return errors;
}
/** Form shape for install-level client settings (client.json). */
export interface ClientSettingsFormValues {
@ -65,5 +94,10 @@ export function canPersistClientSettingsDraft(
values: ClientSettingsFormValues,
): boolean {
if (!values.emaEnabled) return true;
return values.issuer.trim() !== "" && values.clientId.trim() !== "";
if (values.issuer.trim() === "" || values.clientId.trim() === "")
return false;
// Defer to validateClientSettings so the persist gate and the inline field
// errors can never drift: an invalid issuer is never sent to the backend, and
// the field error guides the user instead of a raw validation failure toast.
return Object.keys(validateClientSettings(values)).length === 0;
}