From 32fd50bf802e915c84674244d24a0b08f2e92ca7 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:52:50 +0000 Subject: [PATCH] Show a notice when a saved field value was rejected Managed-auth fields carry replace_existing when the site rejected the stored credential. Carry it through the canonical projection and render a notice on the field so the user knows a new value is required. --- .changeset/replace-existing-field-notice.md | 5 + .../src/appearance/types.ts | 2 + .../src/components/UnifiedAuthForm.tsx | 5 + packages/managed-auth-react/src/lib/types.ts | 2 + .../src/localization/defaults.ts | 2 + .../src/localization/types.ts | 1 + .../src/session/state.test.ts | 91 +++++++++++++++++++ .../managed-auth-react/src/session/state.ts | 28 ++++-- .../managed-auth-react/src/styles/styles.css | 6 ++ 9 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 .changeset/replace-existing-field-notice.md diff --git a/.changeset/replace-existing-field-notice.md b/.changeset/replace-existing-field-notice.md new file mode 100644 index 0000000..f21e2de --- /dev/null +++ b/.changeset/replace-existing-field-notice.md @@ -0,0 +1,5 @@ +--- +"@onkernel/managed-auth-react": minor +--- + +Show a notice on fields whose saved value the site rejected, driven by the `replace_existing` flag on managed-auth fields. Adds the `fieldReplaceExistingNotice` label and the `inputReplaceNotice` appearance slot. diff --git a/packages/managed-auth-react/src/appearance/types.ts b/packages/managed-auth-react/src/appearance/types.ts index 16eb621..1d004a5 100644 --- a/packages/managed-auth-react/src/appearance/types.ts +++ b/packages/managed-auth-react/src/appearance/types.ts @@ -71,6 +71,8 @@ export interface AppearanceElements { input?: ElementValue; /** Small hint text under an input. */ inputHint?: ElementValue; + /** Notice under an input whose saved value was rejected. */ + inputReplaceNotice?: ElementValue; /** Password show/hide toggle button. */ passwordToggle?: ElementValue; /** All buttons — base. */ diff --git a/packages/managed-auth-react/src/components/UnifiedAuthForm.tsx b/packages/managed-auth-react/src/components/UnifiedAuthForm.tsx index 49395f0..cfc4749 100644 --- a/packages/managed-auth-react/src/components/UnifiedAuthForm.tsx +++ b/packages/managed-auth-react/src/components/UnifiedAuthForm.tsx @@ -300,6 +300,11 @@ export function UnifiedAuthForm({ } /> )} + {field.replace_existing && ( +

+ {l.fieldReplaceExistingNotice} +

+ )} {field.hint && (

{field.hint}

)} diff --git a/packages/managed-auth-react/src/lib/types.ts b/packages/managed-auth-react/src/lib/types.ts index be0cdb9..fe08275 100644 --- a/packages/managed-auth-react/src/lib/types.ts +++ b/packages/managed-auth-react/src/lib/types.ts @@ -35,6 +35,7 @@ export interface DiscoveredField { type: "text" | "email" | "password" | "tel" | "code" | "totp"; placeholder?: string; required?: boolean; + replace_existing?: boolean; hint?: string; linked_mfa_type?: MFAType; } @@ -73,6 +74,7 @@ export interface ManagedAuthField { | "text"; label?: string; required?: boolean; + replace_existing?: boolean; hint?: string; observed_selector?: string | null; } diff --git a/packages/managed-auth-react/src/localization/defaults.ts b/packages/managed-auth-react/src/localization/defaults.ts index 01ccebc..7cb4ed7 100644 --- a/packages/managed-auth-react/src/localization/defaults.ts +++ b/packages/managed-auth-react/src/localization/defaults.ts @@ -42,6 +42,8 @@ export const DEFAULT_LOCALIZATION: Localizer = { orDivider: "or", passwordShow: "Show password", passwordHide: "Hide password", + fieldReplaceExistingNotice: + "The saved value was rejected. Enter a new one to continue.", credentialSafetyNotice: "Your credentials are encrypted and sent directly from your browser. They are never shared with anyone or any LLM.", mfaTypeLabels: { diff --git a/packages/managed-auth-react/src/localization/types.ts b/packages/managed-auth-react/src/localization/types.ts index 1c01c71..d4402e7 100644 --- a/packages/managed-auth-react/src/localization/types.ts +++ b/packages/managed-auth-react/src/localization/types.ts @@ -39,6 +39,7 @@ export interface Localization { orDivider?: string; passwordShow?: string; passwordHide?: string; + fieldReplaceExistingNotice?: string; credentialSafetyNotice?: string; /** MFA type labels. */ mfaTypeLabels?: Partial>; diff --git a/packages/managed-auth-react/src/session/state.test.ts b/packages/managed-auth-react/src/session/state.test.ts index 81cbf01..a6cf31d 100644 --- a/packages/managed-auth-react/src/session/state.test.ts +++ b/packages/managed-auth-react/src/session/state.test.ts @@ -239,3 +239,94 @@ describe("normalizeManagedAuthState", () => { }); }); }); + +describe("replace_existing", () => { + test("carries the replacement requirement from canonical fields", () => { + const state = managedAuthState({ + fields: [ + { + id: "field_password", + ref: "password", + type: "password", + label: "Password", + replace_existing: true, + }, + ], + }); + + const derived = normalizeManagedAuthState(state).discovered_fields; + expect(derived?.[0].replace_existing).toBe(true); + }); + + test("leaves the replacement requirement unset when canonical omits it", () => { + const state = managedAuthState({ + fields: [ + { + id: "field_password", + ref: "password", + type: "password", + label: "Password", + }, + ], + }); + + const derived = normalizeManagedAuthState(state).discovered_fields; + expect(derived?.[0].replace_existing).toBeUndefined(); + }); + + test("passes the replacement requirement through legacy fields", () => { + const state = managedAuthState({ + discovered_fields: [ + { + name: "password", + label: "Password", + type: "password", + replace_existing: true, + }, + ], + }); + + const derived = normalizeManagedAuthState(state).discovered_fields; + expect(derived?.[0].replace_existing).toBe(true); + }); + + // Strict on the whole projected field, not a subset: a canonical property that + // the form renders but this projection forgets is invisible to every consumer. + test("projects the exact field shape the form renders", () => { + const state = managedAuthState({ + fields: [ + { + id: "field_password", + ref: "password", + type: "password", + label: "Password", + required: false, + replace_existing: true, + }, + ], + discovered_fields: [ + { + name: "password", + type: "password", + label: "Password", + placeholder: "Enter password", + hint: "Use the password for this account", + }, + ], + }); + + expect(normalizeManagedAuthState(state).discovered_fields).toStrictEqual([ + { + id: "field_password", + ref: "password", + name: "field_password", + type: "password", + label: "Password", + placeholder: "Enter password", + required: false, + replace_existing: true, + hint: "Use the password for this account", + }, + ]); + }); +}); diff --git a/packages/managed-auth-react/src/session/state.ts b/packages/managed-auth-react/src/session/state.ts index 93783fe..04f2e7d 100644 --- a/packages/managed-auth-react/src/session/state.ts +++ b/packages/managed-auth-react/src/session/state.ts @@ -56,17 +56,25 @@ function fieldTypeToDiscoveredType( function fieldsFromCanonical( fields: ManagedAuthField[], + legacyFields: DiscoveredField[] | null | undefined, ): DiscoveredField[] | null { if (!fields.length) return null; - return fields.map((field) => ({ - id: field.id, - ref: field.ref, - name: field.id, - type: fieldTypeToDiscoveredType(field), - label: field.label || field.ref, - required: field.required ?? true, - hint: field.hint, - })); + return fields.map((field) => { + const legacyField = legacyFields?.find( + (candidate) => candidate.name === field.ref, + ); + return { + id: field.id, + ref: field.ref, + name: field.id, + type: fieldTypeToDiscoveredType(field), + label: field.label || field.ref, + placeholder: legacyField?.placeholder, + required: field.required ?? true, + replace_existing: field.replace_existing, + hint: field.hint ?? legacyField?.hint, + }; + }); } function ssoButtonsFromCanonical( @@ -176,7 +184,7 @@ export function normalizeManagedAuthState( return { ...state, discovered_fields: hasCanonicalFields - ? fieldsFromCanonical(state.fields ?? []) + ? fieldsFromCanonical(state.fields ?? [], state.discovered_fields) : state.discovered_fields, pending_sso_buttons: hasCanonicalChoices ? ssoButtonsFromCanonical(state.choices ?? []) diff --git a/packages/managed-auth-react/src/styles/styles.css b/packages/managed-auth-react/src/styles/styles.css index be0412e..1cbc6e0 100644 --- a/packages/managed-auth-react/src/styles/styles.css +++ b/packages/managed-auth-react/src/styles/styles.css @@ -604,6 +604,12 @@ color: var(--kma-color-muted-foreground); } +.kma-input-replace-notice { + margin: 0; + font-size: var(--kma-font-size-sm); + color: var(--kma-color-danger); +} + .kma-password-toggle { position: absolute; right: 10px;