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;