Follow-up to #1545. The close-block only fired for an invalid issuer, so an EMA config enabled with a valid issuer but a blank client ID was still silently dropped on close by the persist gate — the same no-feedback bug, different field. - validateClientSettings gains a requireComplete mode that also flags the required IdP fields (issuer, clientId) when blank, with distinct per-field messages. Inline (as-you-type) validation is unchanged — required-but-blank errors only surface on reveal, so the form never nags about not-yet-filled fields. - canPersistClientSettingsDraft is redefined in terms of that mode, so the persist gate and the close-time field errors share one source of truth. - ClientSettingsModal.handleClose now blocks + reveals on ANY require-complete error (invalid issuer OR blank required field) and keeps the modal open. The form prop revealIssuerError becomes revealErrors and surfaces the client-ID error too. Escape hatch: disable enterprise IdP (an empty issuer no longer closes, since that's itself incomplete). Tests cover the blank-clientId close path, fix-each-field, and disable-EMA escape, plus requireComplete validation. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
13dafe457b
commit
b2a6ee1dcf
6 changed files with 382 additions and 88 deletions
|
|
@ -10,6 +10,8 @@ import {
|
||||||
import {
|
import {
|
||||||
EMPTY_CLIENT_SETTINGS,
|
EMPTY_CLIENT_SETTINGS,
|
||||||
ISSUER_URL_ERROR,
|
ISSUER_URL_ERROR,
|
||||||
|
ISSUER_REQUIRED_ERROR,
|
||||||
|
CLIENT_ID_REQUIRED_ERROR,
|
||||||
type ClientSettingsFormValues,
|
type ClientSettingsFormValues,
|
||||||
} from "./clientSettingsValues";
|
} from "./clientSettingsValues";
|
||||||
|
|
||||||
|
|
@ -139,7 +141,7 @@ describe("ClientSettingsForm EMA IdP session", () => {
|
||||||
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
|
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reveals the issuer error without blur when revealIssuerError is set", () => {
|
it("reveals the issuer error without blur when revealErrors is set", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ClientSettingsForm
|
<ClientSettingsForm
|
||||||
settings={{
|
settings={{
|
||||||
|
|
@ -151,7 +153,7 @@ describe("ClientSettingsForm EMA IdP session", () => {
|
||||||
onExpandedSectionsChange={vi.fn()}
|
onExpandedSectionsChange={vi.fn()}
|
||||||
onSettingsChange={vi.fn()}
|
onSettingsChange={vi.fn()}
|
||||||
emaIdpLoginState="none"
|
emaIdpLoginState="none"
|
||||||
revealIssuerError
|
revealErrors
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -160,23 +162,63 @@ describe("ClientSettingsForm EMA IdP session", () => {
|
||||||
expect(screen.getByText(ISSUER_URL_ERROR)).toBeInTheDocument();
|
expect(screen.getByText(ISSUER_URL_ERROR)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows no error for a valid issuer even when revealIssuerError is set", () => {
|
it("reveals required errors for blank issuer and client ID when revealErrors is set", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ClientSettingsForm
|
||||||
|
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
|
||||||
|
expandedSections={["ema"]}
|
||||||
|
onExpandedSectionsChange={vi.fn()}
|
||||||
|
onSettingsChange={vi.fn()}
|
||||||
|
emaIdpLoginState="none"
|
||||||
|
revealErrors
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both required IdP fields are blank; a close/save attempt surfaces them
|
||||||
|
// instead of silently dropping the config.
|
||||||
|
expect(screen.getByText(ISSUER_REQUIRED_ERROR)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(CLIENT_ID_REQUIRED_ERROR)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show required errors for blank fields before revealErrors", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ClientSettingsForm
|
||||||
|
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
|
||||||
|
expandedSections={["ema"]}
|
||||||
|
onExpandedSectionsChange={vi.fn()}
|
||||||
|
onSettingsChange={vi.fn()}
|
||||||
|
emaIdpLoginState="none"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inline validation must not nag about not-yet-filled required fields.
|
||||||
|
expect(screen.queryByText(ISSUER_REQUIRED_ERROR)).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText(CLIENT_ID_REQUIRED_ERROR),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no error for a valid issuer even when revealErrors is set", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ClientSettingsForm
|
<ClientSettingsForm
|
||||||
settings={{
|
settings={{
|
||||||
...EMPTY_CLIENT_SETTINGS,
|
...EMPTY_CLIENT_SETTINGS,
|
||||||
emaEnabled: true,
|
emaEnabled: true,
|
||||||
issuer: "https://idp.test",
|
issuer: "https://idp.test",
|
||||||
|
clientId: "client-1",
|
||||||
}}
|
}}
|
||||||
expandedSections={["ema"]}
|
expandedSections={["ema"]}
|
||||||
onExpandedSectionsChange={vi.fn()}
|
onExpandedSectionsChange={vi.fn()}
|
||||||
onSettingsChange={vi.fn()}
|
onSettingsChange={vi.fn()}
|
||||||
emaIdpLoginState="none"
|
emaIdpLoginState="none"
|
||||||
revealIssuerError
|
revealErrors
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
|
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText(CLIENT_ID_REQUIRED_ERROR),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows no issuer error for a valid URL", () => {
|
it("shows no issuer error for a valid URL", () => {
|
||||||
|
|
@ -251,7 +293,7 @@ describe("ClientSettingsForm EMA IdP session", () => {
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reveals the CIMD URL error without blur when revealClientMetadataUrlError is set", () => {
|
it("reveals the CIMD URL error without blur when revealErrors is set", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ClientSettingsForm
|
<ClientSettingsForm
|
||||||
settings={{
|
settings={{
|
||||||
|
|
@ -263,7 +305,7 @@ describe("ClientSettingsForm EMA IdP session", () => {
|
||||||
onExpandedSectionsChange={vi.fn()}
|
onExpandedSectionsChange={vi.fn()}
|
||||||
onSettingsChange={vi.fn()}
|
onSettingsChange={vi.fn()}
|
||||||
emaIdpLoginState="none"
|
emaIdpLoginState="none"
|
||||||
revealClientMetadataUrlError
|
revealErrors
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,16 +30,12 @@ export interface ClientSettingsFormProps {
|
||||||
emaIdpLoginState?: EmaIdpLoginState;
|
emaIdpLoginState?: EmaIdpLoginState;
|
||||||
onEmaIdpLogout?: () => void;
|
onEmaIdpLogout?: () => void;
|
||||||
/**
|
/**
|
||||||
* Force the issuer error to show even before the field is blurred. The parent
|
* Force all field errors to show, including required-but-blank fields (EMA's
|
||||||
* sets this when a save/close is attempted with an invalid issuer, so the user
|
* issuer/clientId, CIMD's metadata URL). The parent sets this when a
|
||||||
* isn't left with a silently-dropped value and no explanation.
|
* save/close is attempted with an incomplete or invalid config, so nothing is
|
||||||
|
* silently dropped without explanation.
|
||||||
*/
|
*/
|
||||||
revealIssuerError?: boolean;
|
revealErrors?: boolean;
|
||||||
/**
|
|
||||||
* Force the CIMD metadata URL error to show even before the field is blurred.
|
|
||||||
* Same contract as {@link revealIssuerError} for the CIMD URL field.
|
|
||||||
*/
|
|
||||||
revealClientMetadataUrlError?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const HintText = Text.withProps({
|
const HintText = Text.withProps({
|
||||||
|
|
@ -54,8 +50,7 @@ export function ClientSettingsForm({
|
||||||
onSettingsChange,
|
onSettingsChange,
|
||||||
emaIdpLoginState = "none",
|
emaIdpLoginState = "none",
|
||||||
onEmaIdpLogout,
|
onEmaIdpLogout,
|
||||||
revealIssuerError = false,
|
revealErrors = false,
|
||||||
revealClientMetadataUrlError = false,
|
|
||||||
}: ClientSettingsFormProps) {
|
}: ClientSettingsFormProps) {
|
||||||
function patch(partial: Partial<ClientSettingsFormValues>) {
|
function patch(partial: Partial<ClientSettingsFormValues>) {
|
||||||
onSettingsChange((prev) => ({ ...prev, ...partial }));
|
onSettingsChange((prev) => ({ ...prev, ...partial }));
|
||||||
|
|
@ -64,23 +59,31 @@ export function ClientSettingsForm({
|
||||||
// Defer the issuer error until the field has been blurred so it doesn't nag
|
// 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
|
// 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
|
// 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
|
// parent forces all errors on via `revealErrors` when a close/save is
|
||||||
// attempted with an invalid value, so the value is never silently dropped
|
// attempted with an incomplete or invalid config — including the
|
||||||
// without explanation. The persist gate (canPersistClientSettingsDraft)
|
// required-but-blank fields, which inline validation otherwise leaves silent.
|
||||||
// validates independently — an invalid issuer is never written regardless.
|
// The persist gate (canPersistClientSettingsDraft) validates independently, so
|
||||||
|
// an incomplete/invalid config is never written regardless of these flags.
|
||||||
const [issuerTouched, setIssuerTouched] = useState(false);
|
const [issuerTouched, setIssuerTouched] = useState(false);
|
||||||
const [clientMetadataUrlTouched, setClientMetadataUrlTouched] =
|
const [clientMetadataUrlTouched, setClientMetadataUrlTouched] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const errors = validateClientSettings(settings);
|
// Inline errors flag only a filled-in-wrong field (issuer URL); the
|
||||||
const showIssuerError =
|
// require-complete set adds the blank-required fields surfaced on reveal.
|
||||||
(issuerTouched || revealIssuerError) && errors.issuer
|
const inlineErrors = validateClientSettings(settings);
|
||||||
? errors.issuer
|
const revealedErrors = validateClientSettings(settings, {
|
||||||
|
requireComplete: true,
|
||||||
|
});
|
||||||
|
const showIssuerError = revealErrors
|
||||||
|
? revealedErrors.issuer
|
||||||
|
: issuerTouched
|
||||||
|
? inlineErrors.issuer
|
||||||
: undefined;
|
: undefined;
|
||||||
const showClientMetadataUrlError =
|
const showClientIdError = revealErrors ? revealedErrors.clientId : undefined;
|
||||||
(clientMetadataUrlTouched || revealClientMetadataUrlError) &&
|
const showClientMetadataUrlError = revealErrors
|
||||||
errors.clientMetadataUrl
|
? revealedErrors.clientMetadataUrl
|
||||||
? errors.clientMetadataUrl
|
: clientMetadataUrlTouched
|
||||||
|
? inlineErrors.clientMetadataUrl
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const showIdpSession =
|
const showIdpSession =
|
||||||
|
|
@ -134,6 +137,7 @@ export function ClientSettingsForm({
|
||||||
description="Client id registered with your enterprise IdP."
|
description="Client id registered with your enterprise IdP."
|
||||||
value={settings.clientId}
|
value={settings.clientId}
|
||||||
onChange={(e) => patch({ clientId: e.currentTarget.value })}
|
onChange={(e) => patch({ clientId: e.currentTarget.value })}
|
||||||
|
error={showClientIdError}
|
||||||
rightSectionPointerEvents="auto"
|
rightSectionPointerEvents="auto"
|
||||||
rightSection={
|
rightSection={
|
||||||
settings.clientId ? (
|
settings.clientId ? (
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ import {
|
||||||
EMPTY_CLIENT_SETTINGS,
|
EMPTY_CLIENT_SETTINGS,
|
||||||
formValuesToClientConfig,
|
formValuesToClientConfig,
|
||||||
ISSUER_URL_ERROR,
|
ISSUER_URL_ERROR,
|
||||||
|
ISSUER_REQUIRED_ERROR,
|
||||||
|
CLIENT_ID_REQUIRED_ERROR,
|
||||||
|
CLIENT_METADATA_URL_REQUIRED_ERROR,
|
||||||
validateClientSettings,
|
validateClientSettings,
|
||||||
} from "./clientSettingsValues";
|
} from "./clientSettingsValues";
|
||||||
|
|
||||||
|
|
@ -457,4 +460,92 @@ describe("clientSettingsValues", () => {
|
||||||
}),
|
}),
|
||||||
).toEqual({});
|
).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("validateClientSettings does not flag blank required fields by default", () => {
|
||||||
|
expect(
|
||||||
|
validateClientSettings({
|
||||||
|
emaEnabled: true,
|
||||||
|
issuer: "",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
...emptyCimd,
|
||||||
|
}),
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateClientSettings with requireComplete flags blank issuer and clientId", () => {
|
||||||
|
expect(
|
||||||
|
validateClientSettings(
|
||||||
|
{
|
||||||
|
emaEnabled: true,
|
||||||
|
issuer: "",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
...emptyCimd,
|
||||||
|
},
|
||||||
|
{ requireComplete: true },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
issuer: ISSUER_REQUIRED_ERROR,
|
||||||
|
clientId: CLIENT_ID_REQUIRED_ERROR,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateClientSettings with requireComplete keeps the URL error over the required error", () => {
|
||||||
|
expect(
|
||||||
|
validateClientSettings(
|
||||||
|
{
|
||||||
|
emaEnabled: true,
|
||||||
|
issuer: "not-a-url",
|
||||||
|
clientId: "cid",
|
||||||
|
clientSecret: "",
|
||||||
|
...emptyCimd,
|
||||||
|
},
|
||||||
|
{ requireComplete: true },
|
||||||
|
),
|
||||||
|
).toEqual({ issuer: ISSUER_URL_ERROR });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateClientSettings with requireComplete flags only the blank clientId when the issuer is valid", () => {
|
||||||
|
expect(
|
||||||
|
validateClientSettings(
|
||||||
|
{
|
||||||
|
emaEnabled: true,
|
||||||
|
issuer: "https://idp.test",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
...emptyCimd,
|
||||||
|
},
|
||||||
|
{ requireComplete: true },
|
||||||
|
),
|
||||||
|
).toEqual({ clientId: CLIENT_ID_REQUIRED_ERROR });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateClientSettings with requireComplete flags a blank CIMD URL", () => {
|
||||||
|
expect(
|
||||||
|
validateClientSettings(
|
||||||
|
{
|
||||||
|
emaEnabled: false,
|
||||||
|
issuer: "",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
cimdEnabled: true,
|
||||||
|
clientMetadataUrl: "",
|
||||||
|
},
|
||||||
|
{ requireComplete: true },
|
||||||
|
),
|
||||||
|
).toEqual({ clientMetadataUrl: CLIENT_METADATA_URL_REQUIRED_ERROR });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("canPersistClientSettingsDraft blocks a blank clientId with a valid issuer", () => {
|
||||||
|
expect(
|
||||||
|
canPersistClientSettingsDraft({
|
||||||
|
emaEnabled: true,
|
||||||
|
issuer: "https://idp.test",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
...emptyCimd,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,35 +7,72 @@ import {
|
||||||
/** Field-level error message for an issuer that is not an http(s) URL. */
|
/** Field-level error message for an issuer that is not an http(s) URL. */
|
||||||
export const ISSUER_URL_ERROR =
|
export const ISSUER_URL_ERROR =
|
||||||
"Must be an http(s) URL, like https://idp.example.com";
|
"Must be an http(s) URL, like https://idp.example.com";
|
||||||
|
/** Field-level errors for a required field left blank while its feature is enabled. */
|
||||||
|
export const ISSUER_REQUIRED_ERROR =
|
||||||
|
"Issuer is required when enterprise IdP configuration is enabled";
|
||||||
|
export const CLIENT_ID_REQUIRED_ERROR =
|
||||||
|
"Client ID is required when enterprise IdP configuration is enabled";
|
||||||
|
export const CLIENT_METADATA_URL_REQUIRED_ERROR =
|
||||||
|
"Metadata document URL is required when CIMD is enabled";
|
||||||
|
|
||||||
/** Field-level validation errors for the client settings form. */
|
/** Field-level validation errors for the client settings form. */
|
||||||
export interface ClientSettingsErrors {
|
export interface ClientSettingsErrors {
|
||||||
issuer?: string;
|
issuer?: string;
|
||||||
|
clientId?: string;
|
||||||
clientMetadataUrl?: string;
|
clientMetadataUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ValidateClientSettingsOptions {
|
||||||
|
/**
|
||||||
|
* Also flag required fields left blank. Off by default so inline (as-you-type)
|
||||||
|
* validation only flags a field the user has actually filled in wrong; turned
|
||||||
|
* on when a save/close is attempted so an incomplete config is surfaced rather
|
||||||
|
* than silently dropped.
|
||||||
|
*/
|
||||||
|
requireComplete?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline validation for the client settings form. Only flags fields that the
|
* Validation for the client settings form. By default only flags fields the
|
||||||
* user has actually filled in — empty required fields are gated by
|
* user has filled in wrong (a non-empty, non-http(s) issuer; a malformed CIMD
|
||||||
* {@link canPersistClientSettingsDraft} rather than surfaced as errors.
|
* metadata URL). With `requireComplete`, also flags the required fields of an
|
||||||
|
* enabled feature (EMA's issuer/clientId, CIMD's metadata URL) when they are
|
||||||
|
* blank — used at save/close time so an enabled-but-incomplete config never
|
||||||
|
* persists silently.
|
||||||
*/
|
*/
|
||||||
export function validateClientSettings(
|
export function validateClientSettings(
|
||||||
values: ClientSettingsFormValues,
|
values: ClientSettingsFormValues,
|
||||||
|
options: ValidateClientSettingsOptions = {},
|
||||||
): ClientSettingsErrors {
|
): ClientSettingsErrors {
|
||||||
const errors: ClientSettingsErrors = {};
|
const errors: ClientSettingsErrors = {};
|
||||||
if (
|
|
||||||
values.emaEnabled &&
|
if (values.emaEnabled) {
|
||||||
values.issuer.trim() !== "" &&
|
const issuer = values.issuer.trim();
|
||||||
!isAbsoluteHttpUrl(values.issuer)
|
if (issuer === "") {
|
||||||
) {
|
if (options.requireComplete) errors.issuer = ISSUER_REQUIRED_ERROR;
|
||||||
errors.issuer = ISSUER_URL_ERROR;
|
} else if (!isAbsoluteHttpUrl(values.issuer)) {
|
||||||
}
|
errors.issuer = ISSUER_URL_ERROR;
|
||||||
if (values.cimdEnabled && values.clientMetadataUrl.trim() !== "") {
|
}
|
||||||
const cimdError = getCimdClientMetadataUrlError(values.clientMetadataUrl);
|
|
||||||
if (cimdError) {
|
if (options.requireComplete && values.clientId.trim() === "") {
|
||||||
errors.clientMetadataUrl = cimdError;
|
errors.clientId = CLIENT_ID_REQUIRED_ERROR;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (values.cimdEnabled) {
|
||||||
|
const url = values.clientMetadataUrl.trim();
|
||||||
|
if (url === "") {
|
||||||
|
if (options.requireComplete) {
|
||||||
|
errors.clientMetadataUrl = CLIENT_METADATA_URL_REQUIRED_ERROR;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const cimdError = getCimdClientMetadataUrlError(values.clientMetadataUrl);
|
||||||
|
if (cimdError) {
|
||||||
|
errors.clientMetadataUrl = cimdError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,18 +145,18 @@ export function formValuesToClientConfig(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Skip debounced persist while required fields are blank for enabled features. */
|
/**
|
||||||
|
* Skip the debounced persist while an enabled feature's required fields are
|
||||||
|
* blank or invalid (EMA's issuer/clientId, CIMD's metadata URL). Defers to
|
||||||
|
* {@link validateClientSettings} in `requireComplete` mode so the persist gate
|
||||||
|
* and the close-time field errors can never drift: the same condition that
|
||||||
|
* blocks the write also surfaces the field-level errors that explain why.
|
||||||
|
*/
|
||||||
export function canPersistClientSettingsDraft(
|
export function canPersistClientSettingsDraft(
|
||||||
values: ClientSettingsFormValues,
|
values: ClientSettingsFormValues,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (values.emaEnabled) {
|
return (
|
||||||
if (values.issuer.trim() === "" || values.clientId.trim() === "")
|
Object.keys(validateClientSettings(values, { requireComplete: true }))
|
||||||
return false;
|
.length === 0
|
||||||
}
|
);
|
||||||
if (values.cimdEnabled) {
|
|
||||||
if (!values.clientMetadataUrl.trim()) return false;
|
|
||||||
}
|
|
||||||
// Defer to validateClientSettings so the persist gate and inline field errors
|
|
||||||
// can never drift — invalid values are never sent to the backend.
|
|
||||||
return Object.keys(validateClientSettings(values)).length === 0;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { ClientSettingsModal } from "./ClientSettingsModal";
|
||||||
import {
|
import {
|
||||||
EMPTY_CLIENT_SETTINGS,
|
EMPTY_CLIENT_SETTINGS,
|
||||||
ISSUER_URL_ERROR,
|
ISSUER_URL_ERROR,
|
||||||
|
CLIENT_ID_REQUIRED_ERROR,
|
||||||
|
CLIENT_METADATA_URL_REQUIRED_ERROR,
|
||||||
type ClientSettingsFormValues,
|
type ClientSettingsFormValues,
|
||||||
} from "../ClientSettingsForm/clientSettingsValues";
|
} from "../ClientSettingsForm/clientSettingsValues";
|
||||||
import { CIMD_METADATA_URL_INVALID_ERROR } from "@inspector/core/client/config-parse.js";
|
import { CIMD_METADATA_URL_INVALID_ERROR } from "@inspector/core/client/config-parse.js";
|
||||||
|
|
@ -150,6 +152,9 @@ describe("ClientSettingsModal", () => {
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const clickClose = (user: ReturnType<typeof userEvent.setup>) =>
|
||||||
|
user.click(document.querySelector("button.mantine-CloseButton-root")!);
|
||||||
|
|
||||||
it("blocks close and reveals the issuer error when the issuer is invalid", async () => {
|
it("blocks close and reveals the issuer error when the issuer is invalid", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
|
|
@ -160,6 +165,7 @@ describe("ClientSettingsModal", () => {
|
||||||
...EMPTY_CLIENT_SETTINGS,
|
...EMPTY_CLIENT_SETTINGS,
|
||||||
emaEnabled: true,
|
emaEnabled: true,
|
||||||
issuer: "not-a-url",
|
issuer: "not-a-url",
|
||||||
|
clientId: "client-1",
|
||||||
}}
|
}}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSettingsChange={vi.fn()}
|
onSettingsChange={vi.fn()}
|
||||||
|
|
@ -169,13 +175,35 @@ describe("ClientSettingsModal", () => {
|
||||||
// Error is hidden initially (form gates it on blur)...
|
// Error is hidden initially (form gates it on blur)...
|
||||||
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
|
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
|
||||||
// ...but a close attempt reveals it and does NOT close (no silent drop).
|
// ...but a close attempt reveals it and does NOT close (no silent drop).
|
||||||
await user.click(
|
await clickClose(user);
|
||||||
document.querySelector("button.mantine-CloseButton-root")!,
|
|
||||||
);
|
|
||||||
expect(onClose).not.toHaveBeenCalled();
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
expect(screen.getByText(ISSUER_URL_ERROR)).toBeInTheDocument();
|
expect(screen.getByText(ISSUER_URL_ERROR)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks close and reveals the required error when the client ID is blank", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<ClientSettingsModal
|
||||||
|
opened
|
||||||
|
settings={{
|
||||||
|
...EMPTY_CLIENT_SETTINGS,
|
||||||
|
emaEnabled: true,
|
||||||
|
issuer: "https://idp.test", // valid issuer, but clientId is blank
|
||||||
|
}}
|
||||||
|
onClose={onClose}
|
||||||
|
onSettingsChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByText(CLIENT_ID_REQUIRED_ERROR),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
await clickClose(user);
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText(CLIENT_ID_REQUIRED_ERROR)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("allows close once the invalid issuer is corrected", async () => {
|
it("allows close once the invalid issuer is corrected", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
|
|
@ -184,6 +212,7 @@ describe("ClientSettingsModal", () => {
|
||||||
...EMPTY_CLIENT_SETTINGS,
|
...EMPTY_CLIENT_SETTINGS,
|
||||||
emaEnabled: true,
|
emaEnabled: true,
|
||||||
issuer: "not-a-url",
|
issuer: "not-a-url",
|
||||||
|
clientId: "client-1", // complete except for the issuer
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<ClientSettingsModal
|
<ClientSettingsModal
|
||||||
|
|
@ -196,10 +225,7 @@ describe("ClientSettingsModal", () => {
|
||||||
}
|
}
|
||||||
renderWithMantine(<Host />);
|
renderWithMantine(<Host />);
|
||||||
|
|
||||||
const close = () =>
|
await clickClose(user); // blocked, error revealed
|
||||||
user.click(document.querySelector("button.mantine-CloseButton-root")!);
|
|
||||||
|
|
||||||
await close(); // blocked, error revealed
|
|
||||||
expect(onClose).not.toHaveBeenCalled();
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const issuer = screen.getByLabelText("Issuer");
|
const issuer = screen.getByLabelText("Issuer");
|
||||||
|
|
@ -207,18 +233,18 @@ describe("ClientSettingsModal", () => {
|
||||||
await user.type(issuer, "https://idp.test");
|
await user.type(issuer, "https://idp.test");
|
||||||
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
|
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
|
||||||
|
|
||||||
await close(); // now valid -> closes
|
await clickClose(user); // now complete + valid -> closes
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows close when an invalid issuer is cleared (empty is valid to leave)", async () => {
|
it("allows close once a blank client ID is filled", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
function Host() {
|
function Host() {
|
||||||
const [settings, setSettings] = useState({
|
const [settings, setSettings] = useState({
|
||||||
...EMPTY_CLIENT_SETTINGS,
|
...EMPTY_CLIENT_SETTINGS,
|
||||||
emaEnabled: true,
|
emaEnabled: true,
|
||||||
issuer: "not-a-url",
|
issuer: "https://idp.test", // complete except for the clientId
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<ClientSettingsModal
|
<ClientSettingsModal
|
||||||
|
|
@ -231,10 +257,43 @@ describe("ClientSettingsModal", () => {
|
||||||
}
|
}
|
||||||
renderWithMantine(<Host />);
|
renderWithMantine(<Host />);
|
||||||
|
|
||||||
await user.clear(screen.getByLabelText("Issuer"));
|
await clickClose(user); // blocked on blank clientId
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Client ID"), "client-1");
|
||||||
|
await clickClose(user); // now complete -> closes
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows close by disabling enterprise IdP when the config is incomplete", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
function Host() {
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
...EMPTY_CLIENT_SETTINGS,
|
||||||
|
emaEnabled: true, // enabled but issuer + clientId blank
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<ClientSettingsModal
|
||||||
|
opened
|
||||||
|
settings={settings}
|
||||||
|
onClose={onClose}
|
||||||
|
onSettingsChange={setSettings}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
renderWithMantine(<Host />);
|
||||||
|
|
||||||
|
await clickClose(user); // blocked: required fields blank
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Disabling enterprise IdP is the escape hatch — nothing required to save.
|
||||||
await user.click(
|
await user.click(
|
||||||
document.querySelector("button.mantine-CloseButton-root")!,
|
screen.getByRole("checkbox", {
|
||||||
|
name: "Enable enterprise IdP configuration",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
await clickClose(user);
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -304,14 +363,40 @@ describe("ClientSettingsModal", () => {
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows close when an invalid CIMD URL is cleared (empty is valid to leave)", async () => {
|
it("blocks close and reveals the required error when the CIMD URL is blank", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<ClientSettingsModal
|
||||||
|
opened
|
||||||
|
settings={{
|
||||||
|
...EMPTY_CLIENT_SETTINGS,
|
||||||
|
cimdEnabled: true, // enabled but metadata URL blank
|
||||||
|
}}
|
||||||
|
onClose={onClose}
|
||||||
|
onSettingsChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByText(CLIENT_METADATA_URL_REQUIRED_ERROR),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
await user.click(
|
||||||
|
document.querySelector("button.mantine-CloseButton-root")!,
|
||||||
|
);
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
screen.getByText(CLIENT_METADATA_URL_REQUIRED_ERROR),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows close once a blank CIMD URL is filled", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
function Host() {
|
function Host() {
|
||||||
const [settings, setSettings] = useState({
|
const [settings, setSettings] = useState({
|
||||||
...EMPTY_CLIENT_SETTINGS,
|
...EMPTY_CLIENT_SETTINGS,
|
||||||
cimdEnabled: true,
|
cimdEnabled: true, // enabled but metadata URL blank
|
||||||
clientMetadataUrl: "not-a-url",
|
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<ClientSettingsModal
|
<ClientSettingsModal
|
||||||
|
|
@ -324,8 +409,51 @@ describe("ClientSettingsModal", () => {
|
||||||
}
|
}
|
||||||
renderWithMantine(<Host />);
|
renderWithMantine(<Host />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
document.querySelector("button.mantine-CloseButton-root")!,
|
||||||
|
); // blocked on blank URL
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await expandCimdSection(user);
|
await expandCimdSection(user);
|
||||||
await user.clear(screen.getByLabelText("Client ID metadata document URL"));
|
await user.type(
|
||||||
|
screen.getByLabelText("Client ID metadata document URL"),
|
||||||
|
"https://example.com/cimd.json",
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
document.querySelector("button.mantine-CloseButton-root")!,
|
||||||
|
);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows close by disabling CIMD when the URL is blank", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
function Host() {
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
...EMPTY_CLIENT_SETTINGS,
|
||||||
|
cimdEnabled: true, // enabled but metadata URL blank
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<ClientSettingsModal
|
||||||
|
opened
|
||||||
|
settings={settings}
|
||||||
|
onClose={onClose}
|
||||||
|
onSettingsChange={setSettings}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
renderWithMantine(<Host />);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
document.querySelector("button.mantine-CloseButton-root")!,
|
||||||
|
); // blocked: URL required
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Disabling CIMD is the escape hatch — nothing required to save.
|
||||||
|
await expandCimdSection(user);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("checkbox", { name: "Use Client ID Metadata Document" }),
|
||||||
|
);
|
||||||
await user.click(
|
await user.click(
|
||||||
document.querySelector("button.mantine-CloseButton-root")!,
|
document.querySelector("button.mantine-CloseButton-root")!,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,7 @@ export function ClientSettingsModal({
|
||||||
const [expandedSections, setExpandedSections] = useState<
|
const [expandedSections, setExpandedSections] = useState<
|
||||||
ClientSettingsSection[]
|
ClientSettingsSection[]
|
||||||
>(["ema"]);
|
>(["ema"]);
|
||||||
const [revealIssuerError, setRevealIssuerError] = useState(false);
|
const [revealErrors, setRevealErrors] = useState(false);
|
||||||
const [revealClientMetadataUrlError, setRevealClientMetadataUrlError] =
|
|
||||||
useState(false);
|
|
||||||
|
|
||||||
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
|
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
|
||||||
|
|
||||||
|
|
@ -47,21 +45,16 @@ export function ClientSettingsModal({
|
||||||
setExpandedSections(allExpanded ? [] : ALL_SECTIONS);
|
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. A client config
|
||||||
// modal — the parent flushes the debounced persist on close. If a validated
|
// that's incomplete (blank required field) or invalid (bad issuer / CIMD URL)
|
||||||
// URL field is invalid the persist gate would silently drop it, so instead of
|
// would be silently dropped by the persist gate, so instead of closing we
|
||||||
// closing we reveal the field error (overriding the form's on-blur gating).
|
// reveal the field errors (overriding the form's on-blur gating) and keep the
|
||||||
// The user can fix the URL or clear the field — an empty value is valid to
|
// modal open. The user fixes the fields, or disables the feature, then closes.
|
||||||
// leave — and then close. Resets via the parent's open/close remount key.
|
// Resets via the parent's open/close remount key.
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
const errors = validateClientSettings(settings);
|
const errors = validateClientSettings(settings, { requireComplete: true });
|
||||||
if (errors.issuer) {
|
if (Object.keys(errors).length > 0) {
|
||||||
setRevealIssuerError(true);
|
setRevealErrors(true);
|
||||||
}
|
|
||||||
if (errors.clientMetadataUrl) {
|
|
||||||
setRevealClientMetadataUrlError(true);
|
|
||||||
}
|
|
||||||
if (errors.issuer || errors.clientMetadataUrl) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -94,8 +87,7 @@ export function ClientSettingsModal({
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
emaIdpLoginState={emaIdpLoginState}
|
emaIdpLoginState={emaIdpLoginState}
|
||||||
onEmaIdpLogout={onEmaIdpLogout}
|
onEmaIdpLogout={onEmaIdpLogout}
|
||||||
revealIssuerError={revealIssuerError}
|
revealErrors={revealErrors}
|
||||||
revealClientMetadataUrlError={revealClientMetadataUrlError}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue