Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/replace-existing-field-notice.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/managed-auth-react/src/appearance/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,11 @@ export function UnifiedAuthForm({
}
/>
)}
{field.replace_existing && (
<p {...slot("inputReplaceNotice", "kma-input-replace-notice")}>
{l.fieldReplaceExistingNotice}
</p>
)}
{field.hint && (
<p {...slot("inputHint", "kma-input-hint")}>{field.hint}</p>
)}
Expand Down
2 changes: 2 additions & 0 deletions packages/managed-auth-react/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -73,6 +74,7 @@ export interface ManagedAuthField {
| "text";
label?: string;
required?: boolean;
replace_existing?: boolean;
hint?: string;
observed_selector?: string | null;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/managed-auth-react/src/localization/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions packages/managed-auth-react/src/localization/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface Localization {
orDivider?: string;
passwordShow?: string;
passwordHide?: string;
fieldReplaceExistingNotice?: string;
credentialSafetyNotice?: string;
/** MFA type labels. */
mfaTypeLabels?: Partial<Record<MFAType, string>>;
Expand Down
91 changes: 91 additions & 0 deletions packages/managed-auth-react/src/session/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
]);
});
});
28 changes: 18 additions & 10 deletions packages/managed-auth-react/src/session/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 ?? [])
Expand Down
6 changes: 6 additions & 0 deletions packages/managed-auth-react/src/styles/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading