Skip to content

Commit 0a99efa

Browse files
committed
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.
1 parent 6565d3f commit 0a99efa

9 files changed

Lines changed: 132 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@onkernel/managed-auth-react": minor
3+
---
4+
5+
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.

‎packages/managed-auth-react/src/appearance/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ export interface AppearanceElements {
7171
input?: ElementValue;
7272
/** Small hint text under an input. */
7373
inputHint?: ElementValue;
74+
/** Notice under an input whose saved value was rejected. */
75+
inputReplaceNotice?: ElementValue;
7476
/** Password show/hide toggle button. */
7577
passwordToggle?: ElementValue;
7678
/** All buttons — base. */

‎packages/managed-auth-react/src/components/UnifiedAuthForm.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,11 @@ export function UnifiedAuthForm({
295295
}
296296
/>
297297
)}
298+
{field.replace_existing && (
299+
<p {...slot("inputReplaceNotice", "kma-input-replace-notice")}>
300+
{l.fieldReplaceExistingNotice}
301+
</p>
302+
)}
298303
{field.hint && (
299304
<p {...slot("inputHint", "kma-input-hint")}>{field.hint}</p>
300305
)}

‎packages/managed-auth-react/src/lib/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface DiscoveredField {
3535
type: "text" | "email" | "password" | "tel" | "code" | "totp";
3636
placeholder?: string;
3737
required?: boolean;
38+
replace_existing?: boolean;
3839
hint?: string;
3940
linked_mfa_type?: MFAType;
4041
}
@@ -72,6 +73,7 @@ export interface ManagedAuthField {
7273
| "text";
7374
label?: string;
7475
required?: boolean;
76+
replace_existing?: boolean;
7577
observed_selector?: string | null;
7678
}
7779

‎packages/managed-auth-react/src/localization/defaults.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export const DEFAULT_LOCALIZATION: Localizer = {
4040
orDivider: "or",
4141
passwordShow: "Show password",
4242
passwordHide: "Hide password",
43+
fieldReplaceExistingNotice:
44+
"The saved value was rejected. Enter a new one to continue.",
4345
credentialSafetyNotice:
4446
"Your credentials are encrypted and sent directly from your browser. They are never shared with anyone or any LLM.",
4547
mfaTypeLabels: {

‎packages/managed-auth-react/src/localization/types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export interface Localization {
3737
orDivider?: string;
3838
passwordShow?: string;
3939
passwordHide?: string;
40+
fieldReplaceExistingNotice?: string;
4041
credentialSafetyNotice?: string;
4142
/** MFA type labels. */
4243
mfaTypeLabels?: Partial<Record<MFAType, string>>;

‎packages/managed-auth-react/src/session/state.test.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,94 @@ describe("normalizeManagedAuthState", () => {
233233
});
234234
});
235235
});
236+
237+
describe("replace_existing", () => {
238+
test("carries the replacement requirement from canonical fields", () => {
239+
const state = managedAuthState({
240+
fields: [
241+
{
242+
id: "field_password",
243+
ref: "password",
244+
type: "password",
245+
label: "Password",
246+
replace_existing: true,
247+
},
248+
],
249+
});
250+
251+
const derived = normalizeManagedAuthState(state).discovered_fields;
252+
expect(derived?.[0].replace_existing).toBe(true);
253+
});
254+
255+
test("leaves the replacement requirement unset when canonical omits it", () => {
256+
const state = managedAuthState({
257+
fields: [
258+
{
259+
id: "field_password",
260+
ref: "password",
261+
type: "password",
262+
label: "Password",
263+
},
264+
],
265+
});
266+
267+
const derived = normalizeManagedAuthState(state).discovered_fields;
268+
expect(derived?.[0].replace_existing).toBeUndefined();
269+
});
270+
271+
test("passes the replacement requirement through legacy fields", () => {
272+
const state = managedAuthState({
273+
discovered_fields: [
274+
{
275+
name: "password",
276+
label: "Password",
277+
type: "password",
278+
replace_existing: true,
279+
},
280+
],
281+
});
282+
283+
const derived = normalizeManagedAuthState(state).discovered_fields;
284+
expect(derived?.[0].replace_existing).toBe(true);
285+
});
286+
287+
// Strict on the whole projected field, not a subset: a canonical property that
288+
// the form renders but this projection forgets is invisible to every consumer.
289+
test("projects the exact field shape the form renders", () => {
290+
const state = managedAuthState({
291+
fields: [
292+
{
293+
id: "field_password",
294+
ref: "password",
295+
type: "password",
296+
label: "Password",
297+
required: false,
298+
replace_existing: true,
299+
},
300+
],
301+
discovered_fields: [
302+
{
303+
name: "password",
304+
type: "password",
305+
label: "Password",
306+
placeholder: "Enter password",
307+
hint: "Use the password for this account",
308+
},
309+
],
310+
});
311+
312+
expect(normalizeManagedAuthState(state).discovered_fields).toStrictEqual([
313+
{
314+
id: "field_password",
315+
ref: "password",
316+
name: "field_password",
317+
type: "password",
318+
label: "Password",
319+
placeholder: "Enter password",
320+
required: false,
321+
replace_existing: true,
322+
hint: "Use the password for this account",
323+
},
324+
]);
325+
});
326+
});

‎packages/managed-auth-react/src/session/state.ts‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,25 @@ function fieldTypeToDiscoveredType(
5656

5757
function fieldsFromCanonical(
5858
fields: ManagedAuthField[],
59+
legacyFields: DiscoveredField[] | null | undefined,
5960
): DiscoveredField[] | null {
6061
if (!fields.length) return null;
61-
return fields.map((field) => ({
62-
id: field.id,
63-
ref: field.ref,
64-
name: field.id,
65-
type: fieldTypeToDiscoveredType(field),
66-
label: field.label || field.ref,
67-
required: field.required ?? true,
68-
}));
62+
return fields.map((field) => {
63+
const legacyField = legacyFields?.find(
64+
(candidate) => candidate.name === field.ref,
65+
);
66+
return {
67+
id: field.id,
68+
ref: field.ref,
69+
name: field.id,
70+
type: fieldTypeToDiscoveredType(field),
71+
label: field.label || field.ref,
72+
placeholder: legacyField?.placeholder,
73+
required: field.required ?? true,
74+
replace_existing: field.replace_existing,
75+
hint: legacyField?.hint,
76+
};
77+
});
6978
}
7079

7180
function ssoButtonsFromCanonical(
@@ -167,7 +176,7 @@ export function normalizeManagedAuthState(
167176
return {
168177
...state,
169178
discovered_fields: hasCanonicalFields
170-
? fieldsFromCanonical(state.fields ?? [])
179+
? fieldsFromCanonical(state.fields ?? [], state.discovered_fields)
171180
: state.discovered_fields,
172181
pending_sso_buttons: hasCanonicalChoices
173182
? ssoButtonsFromCanonical(state.choices ?? [])

‎packages/managed-auth-react/src/styles/styles.css‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,12 @@
604604
color: var(--kma-color-muted-foreground);
605605
}
606606

607+
.kma-input-replace-notice {
608+
margin: 0;
609+
font-size: var(--kma-font-size-sm);
610+
color: var(--kma-color-danger);
611+
}
612+
607613
.kma-password-toggle {
608614
position: absolute;
609615
right: 10px;

0 commit comments

Comments
 (0)