fix(ui): treat a cleared credential field as a deletion

A cleared field stays mounted carrying an empty value, so it was neither listed for
deletion here nor sent in the update, since the caller drops empty values from the
payload. The old value survived: clearing a destination left the previous one still
configured and still receiving requests, which for a federated credential now carry a
minted token.

Emptiness counts as a deletion now. A masked but untouched value is a non-empty
string, so it is still preserved.
This commit is contained in:
derhornspieler 2026-08-25 10:43:46 -04:00
parent d3e3fdad7f
commit b5e757ce3a
2 changed files with 19 additions and 2 deletions

View file

@ -98,6 +98,13 @@ describe("computeCredentialValuesToDelete", () => {
expect(computeCredentialValuesToDelete(original, mounted)).toEqual([]);
});
it("deletes a field the operator cleared, since the caller drops it from the payload", () => {
const original = { api_base: "https://old.gateway.internal" };
const mounted = { api_base: "" };
expect(computeCredentialValuesToDelete(original, mounted)).toEqual(["api_base"]);
});
it("keeps a field the operator genuinely changed", () => {
const original = { api_key: "sk-***1234" };
const mounted = { api_key: "sk-new-real-key" };

View file

@ -47,11 +47,21 @@ export function resetCredentialFormOnProviderChange(
*
* `mountedValues` must be the full projected form state (masked-but-untouched fields included),
* not the caller's post-filter payload: a masked value that the operator never touched is still
* mounted and must be preserved, not read as "absent, so delete it".
* mounted and must be preserved, not read as "absent, so delete it". A masked value is a
* non-empty string, which is what separates it from a field the operator emptied.
*
* A cleared field stays mounted carrying an empty value, so emptiness counts as a deletion too.
* Without that it is neither deleted here nor sent in the update (the caller drops empty values
* from the payload), and the old value survives: clearing a destination would leave the previous
* one still receiving requests that now carry a minted federation token.
*/
export function computeCredentialValuesToDelete(
originalValues: Record<string, unknown>,
mountedValues: Record<string, unknown>,
): string[] {
return Object.keys(originalValues).filter((key) => !(key in mountedValues));
return Object.keys(originalValues).filter((key) => {
if (!(key in mountedValues)) return true;
const value = mountedValues[key];
return value === "" || value === null || value === undefined;
});
}