Description
When a user opened an existing auth config in the dashboard, added a scope, and clicked Save, the saved record's client_secret ended up clobbered with the masked round-trip value (e.g. \"abcd...wxyz\") returned by the GET endpoint. Subsequent connection attempts failed because Apollo decrypted the mask string and handed it to the OAuth provider as the real secret (T-10976).
Tracing the flow:
GET /api/v3.1/auth_configs/{nanoid} → Apollo's redactAuthConfigCredentials masks every non-public credential value via maskSensitiveString (apps/apollo/src/lib/auth_config/utils/redactCredentials.ts:71).
- The dashboard's
CredentialForm seeded its inputs from that response (credential-form/index.tsx useMemo), so fieldValues.client_secret started life as the mask.
- On Save, both
manage-auth-config-tab (project surface) and manage-auth-config-sheet (consumer surface) packed every truthy fieldValues entry into the PATCH body.
- Apollo's
updateAuthConfigForCustomType merges { ...existingCredentials, ...incoming } (apps/apollo/src/lib/auth_config/updateAuthConfig.ts:229), so the mask overwrote the real encrypted secret.
This PR fixes both halves of the dashboard half of the bug:
- Stop seeding sensitive fields from the GET response.
CredentialForm skips any field name matched by isSecretField (covers client_secret, api_key, *token*, *secret*, etc.). When such a field has a stored value, the input renders with a \"•••• (saved, leave empty to keep)\" placeholder so the user understands the field is intentionally blank — leaving it empty preserves the saved secret, typing a new value overwrites it.
- Send only fields the user actually edited.
CredentialFormData now exposes dirtyFieldNames, sourced from react-hook-form's formState.dirtyFields. Both manage handlers filter the PATCH body by that set instead of looping over every truthy fieldValues entry. Creation flows are unaffected — they don't pass existingCredentials, so user input remains the only source of dirty fields.
The two defenses are complementary: even if a non-sensitive field were seeded with a value that looked like a mask (it won't, but defensively), the dirty-field gate would still keep it out of the PATCH body.
How did I test this PR
- `doppler run -- pnpm typecheck` — clean.
- `doppler run -- pnpm lint` — same 102 warnings / 5 errors as origin/main (all pre-existing on files I didn't touch); 0 new issues on the four files I changed.
- Walked through the dirty-field math by hand: when only the scopes combobox changes, `formState.dirtyFields.fieldValues` is empty, so `dirtyFieldNames` is `[]` and the PATCH body is `{ credentials: { scopes: [...] } }` — no `client_id` / `client_secret` round-trip. When the user types a new secret, `dirtyFieldNames` contains that key only, so the body becomes `{ credentials: { scopes: [...], client_secret: "new-value" } }`.
- Old dashboard sister PR (ComposioHQ/frontend) ships the equivalent fix; sandbox cannot run a full E2E against staging because the dashboard isn't auto-booted here, so verification was static — happy to record a browser walkthrough on request.
Files changed:
- `src/components/core/auth-config/credential-form/index.tsx` — skip seeding secret fields, surface `savedSecretFieldNames` to the field component, expose `dirtyFieldNames` on the imperative handle.
- `src/components/core/auth-config/credential-form/_components/credential-field.tsx` — accept `hasSavedSecret`, swap the placeholder when set.
- `src/app/(project)/[org]/[project]/auth-configs/[id]/_components/manage/manage-auth-config-tab.tsx` — filter PATCH body by `dirtyFieldNames`.
- `src/app/(connect)/[org]/~/connect/apps/[slug]/_components/manage-auth-config-sheet.tsx` — same fix on the consumer surface.
`CredentialFormHandle` is a public type used by both create and manage flows; the new `dirtyFieldNames` field is additive and the create flows ignore it (they read `fieldValues` / `scopes` directly).