Skip to content
4 changes: 4 additions & 0 deletions changelog/7739-fix-edit-sso-provider-save-button.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type: Fixed
description: Fixed Edit SSO Provider Save button being permanently disabled after creating a provider
pr: 7739
labels: []
Original file line number Diff line number Diff line change
Expand Up @@ -64,22 +64,28 @@ export const transformOrganizationToFormValues = (
openIDProvider: OpenIDProvider,
): SSOProviderFormValues => ({ ...openIDProvider });

const SSOProviderFormValidationSchema = Yup.object().shape({
provider: Yup.string().required().label("Provider"),
name: Yup.string().required().label("Name"),
client_id: Yup.string().required().label("Client ID"),
client_secret: Yup.string().required().label("Client Secret"),
scopes: Yup.array().of(Yup.string()).label("Scopes"),
verify_email: Yup.boolean().optional().label("Verify Email"),
verify_email_field: Yup.string()
.optional()
.nullable()
.label("Userinfo object verify email field"),
email_field: Yup.string()
.optional()
.nullable()
.label("Userinfo object email field"),
});
export const getSSOProviderFormValidationSchema = (isEditMode: boolean) =>
Yup.object().shape({
Comment thread
wadesdev marked this conversation as resolved.
provider: Yup.string().required().label("Provider"),
name: Yup.string().required().label("Name"),
client_id: isEditMode
? Yup.string().optional().label("Client ID")
: Yup.string().required().label("Client ID"),
client_secret: isEditMode
? Yup.string().optional().label("Client Secret")
: Yup.string().required().label("Client Secret"),
// nullable — the API type allows null for scopes so that the form save button is not disabled (Array<string> | null)
scopes: Yup.array().of(Yup.string()).nullable().label("Scopes"),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .nullable() addition to scopes is a separate fix bundled here — the comment hints that without it, a null scopes value from the API would fail validation and keep the save button disabled. This is worth calling out explicitly in the PR description / changelog, since the changelog entry only mentions the credential fields fix. It's a good catch regardless.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is ultimately what wound up allowing for the save button to enable, so I'm not sure why it's considered separate.

verify_email: Yup.boolean().optional().label("Verify Email"),
Comment thread
wadesdev marked this conversation as resolved.
verify_email_field: Yup.string()
.optional()
.nullable()
.label("Userinfo object verify email field"),
email_field: Yup.string()
.optional()
.nullable()
.label("Userinfo object email field"),
});

const CustomProviderExtraFields = () => {
const {
Expand Down Expand Up @@ -183,6 +189,11 @@ const SSOProviderForm = ({
onSuccess,
onClose,
}: SSOProviderFormProps) => {
const isEditMode = !!openIDProvider;
const validationSchema = useMemo(
() => getSSOProviderFormValidationSchema(isEditMode),
[isEditMode],
);
const [createOpenIDProviderMutationTrigger] =
useCreateOpenIDProviderMutation();
const [updateOpenIDProviderMutation] = useUpdateOpenIDProviderMutation();
Expand Down Expand Up @@ -221,8 +232,20 @@ const SSOProviderForm = ({
}
}
};
if (initialValues.id) {
const result = await updateOpenIDProviderMutation(values);
if (isEditMode) {
// Strip empty credential fields — the backend preserves existing encrypted
// values when client_id / client_secret are absent from the payload.
const {
client_id: clientId,
client_secret: clientSecret,
...rest
} = values;
const payload = {
...rest,
...(clientId ? { client_id: clientId } : {}),
...(clientSecret ? { client_secret: clientSecret } : {}),
};
const result = await updateOpenIDProviderMutation(payload);
Comment on lines +237 to +248
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The truthy check for credential stripping works correctly for the "keep existing" case. One edge case to be aware of: a user could provide a new client_id without a new client_secret (or vice versa), and the payload would then update only one of the two. Whether the backend handles that gracefully (rotating one credential independently) is worth confirming. If mixed updates aren't supported, a cross-field validation rule in edit mode (either both provided or both empty) would prevent a confusing partial update.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backend will support optional updates after https://github.com/ethyca/fidesplus/pull/3284/changes

handleResult(result);
} else {
const result = await createOpenIDProviderMutationTrigger(values);
Expand Down Expand Up @@ -274,7 +297,7 @@ const SSOProviderForm = ({
initialValues={initialValues}
enableReinitialize
onSubmit={handleSubmit}
validationSchema={SSOProviderFormValidationSchema}
validationSchema={validationSchema}
>
{({ dirty, isValid, values }) => (
<Form data-testid="openIDProvider-form">
Expand All @@ -293,7 +316,7 @@ const SSOProviderForm = ({
tooltip="Unique identifier for your provider"
variant="stacked"
isRequired
disabled={!!initialValues.id}
disabled={isEditMode}
/>
<CustomTextInput
id="name"
Expand All @@ -310,7 +333,10 @@ const SSOProviderForm = ({
type="password"
tooltip="Client ID for your provider"
variant="stacked"
isRequired
isRequired={!isEditMode}
placeholder={
isEditMode ? "Leave blank to keep existing" : undefined
}
/>
<CustomTextInput
id="client_secret"
Expand All @@ -319,7 +345,10 @@ const SSOProviderForm = ({
type="password"
tooltip="Client secret for your provider"
variant="stacked"
isRequired
isRequired={!isEditMode}
placeholder={
isEditMode ? "Leave blank to keep existing" : undefined
}
/>
{values.provider === "azure" && renderAzureProviderExtraFields()}
{values.provider === "okta" && renderOktaProviderExtraFields()}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { getSSOProviderFormValidationSchema } from "../SSOProviderForm";

const BASE_VALUES = {
provider: "google",
name: "My Provider",
};

describe("getSSOProviderFormValidationSchema", () => {
describe("create mode", () => {
const schema = getSSOProviderFormValidationSchema(false);

it("requires client_id", async () => {
await expect(
schema.validateAt("client_id", { ...BASE_VALUES, client_id: "" }),
).rejects.toThrow("Client ID is a required field");
});

it("requires client_secret", async () => {
await expect(
schema.validateAt("client_secret", {
...BASE_VALUES,
client_secret: "",
}),
).rejects.toThrow("Client Secret is a required field");
});

it("passes when credentials are provided", async () => {
await expect(
schema.validate({
...BASE_VALUES,
client_id: "my-client-id",
client_secret: "my-secret",
}),
).resolves.toBeDefined();
});
});

describe("edit mode", () => {
const schema = getSSOProviderFormValidationSchema(true);

it("allows empty client_id", async () => {
await expect(
schema.validateAt("client_id", { ...BASE_VALUES, client_id: "" }),
).resolves.toBeDefined();
});

it("allows empty client_secret", async () => {
await expect(
schema.validateAt("client_secret", {
...BASE_VALUES,
client_secret: "",
}),
).resolves.toBeDefined();
});

it("allows undefined client_id and client_secret", async () => {
await expect(schema.validate(BASE_VALUES)).resolves.toBeDefined();
});

it("accepts provided credentials (rotation)", async () => {
await expect(
schema.validate({
...BASE_VALUES,
client_id: "new-client-id",
client_secret: "new-secret",
}),
).resolves.toBeDefined();
});
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good coverage of the validation schema in both modes. The payload-stripping logic in the submit handler (the clientId ? { client_id: clientId } : {} spread) isn't covered by these tests. That's the core behavioral change for credential rotation, so it would be worth adding a test for the submit path — e.g., verifying that an empty client_id is excluded from the mutation payload, and that a provided one is included.

Loading