diff --git a/.changeset/gentle-input-modes.md b/.changeset/gentle-input-modes.md new file mode 100644 index 0000000..9a150ee --- /dev/null +++ b/.changeset/gentle-input-modes.md @@ -0,0 +1,5 @@ +--- +"@onkernel/managed-auth-react": patch +--- + +Apply canonical field input modes as virtual keyboard hints without enabling browser format validation. diff --git a/.changeset/quiet-identifiers.md b/.changeset/quiet-identifiers.md new file mode 100644 index 0000000..763ab79 --- /dev/null +++ b/.changeset/quiet-identifiers.md @@ -0,0 +1,5 @@ +--- +"@onkernel/managed-auth-react": patch +--- + +Render canonical identifier fields as text inputs so combined username, email, and phone login fields accept every supported identifier format. diff --git a/packages/managed-auth-react/src/components/UnifiedAuthForm.test.ts b/packages/managed-auth-react/src/components/UnifiedAuthForm.test.ts index bae2faa..d6c576c 100644 --- a/packages/managed-auth-react/src/components/UnifiedAuthForm.test.ts +++ b/packages/managed-auth-react/src/components/UnifiedAuthForm.test.ts @@ -17,9 +17,86 @@ describe("getAutocomplete", () => { }), ).toBe("username"); }); + + test("uses input modes for dedicated email and telephone autofill", () => { + expect( + getAutocomplete({ + name: "field_email", + label: "Email address", + type: "text", + input_mode: "email", + }), + ).toBe("email"); + expect( + getAutocomplete({ + name: "field_phone", + label: "Phone number", + type: "text", + input_mode: "tel", + }), + ).toBe("tel"); + }); + + test("keeps one-time-code autocomplete ahead of keyboard hints", () => { + expect( + getAutocomplete({ + name: "field_code", + label: "Verification code", + type: "code", + input_mode: "tel", + }), + ).toBe("one-time-code"); + }); + + test("does not infer email autofill for mixed identifier inputs", () => { + expect( + getAutocomplete({ + ref: "email", + name: "field_identifier", + label: "Mobile number, username, or email", + type: "text", + input_mode: "text", + }), + ).toBeUndefined(); + }); }); describe("UnifiedAuthForm", () => { + test("applies a keyboard hint without enabling native format validation", () => { + let renderer!: ReturnType; + + act(() => { + renderer = create( + createElement(AppearanceProvider, { + children: createElement(LocalizationProvider, { + children: createElement(UnifiedAuthForm, { + targetDomain: "example.com", + fields: [ + { + name: "field_email", + label: "Email address", + type: "text", + input_mode: "email", + }, + ], + onSubmitFields: () => {}, + onSSOClick: () => {}, + onMFASelect: () => {}, + onSignInOptionSelect: () => {}, + }), + }), + }), + ); + }); + + const input = renderer.root.findByType("input"); + expect(input.props.type).toBe("text"); + expect(input.props.inputMode).toBe("email"); + expect(input.props.autoComplete).toBe("email"); + + act(() => renderer.unmount()); + }); + test("renders an accessible, customizable rejection notice", () => { const fieldName = "password-field"; const rejectedNoticeId = `${fieldName}-rejected-notice`; diff --git a/packages/managed-auth-react/src/components/UnifiedAuthForm.tsx b/packages/managed-auth-react/src/components/UnifiedAuthForm.tsx index 1f23bbd..60103dc 100644 --- a/packages/managed-auth-react/src/components/UnifiedAuthForm.tsx +++ b/packages/managed-auth-react/src/components/UnifiedAuthForm.tsx @@ -88,11 +88,12 @@ export function getAutocomplete(field: DiscoveredField): string | undefined { case "code": case "totp": return "one-time-code"; - default: - if (identity.includes("user") || identity.includes("identifier")) - return "username"; - return undefined; } + if (field.input_mode === "email") return "email"; + if (field.input_mode === "tel") return "tel"; + if (identity.includes("user") || identity.includes("identifier")) + return "username"; + return undefined; } function getDescriptionIds(field: DiscoveredField): string | undefined { @@ -265,6 +266,7 @@ export function UnifiedAuthForm({ id={field.name} name={field.name} type={showPassword[field.name] ? "text" : "password"} + inputMode={field.input_mode} placeholder={field.placeholder} required={field.required} autoComplete={getAutocomplete(field)} @@ -300,6 +302,7 @@ export function UnifiedAuthForm({ id={field.name} name={field.name} type={getInputType(field)} + inputMode={field.input_mode} placeholder={field.placeholder} required={field.required} autoComplete={getAutocomplete(field)} diff --git a/packages/managed-auth-react/src/index.ts b/packages/managed-auth-react/src/index.ts index 9a62bde..a18e9a8 100644 --- a/packages/managed-auth-react/src/index.ts +++ b/packages/managed-auth-react/src/index.ts @@ -37,6 +37,7 @@ export type { AuthSuccessPayload, AuthErrorPayload, MFAType, + InputMode, DiscoveredField, SSOButton, MFAOption, diff --git a/packages/managed-auth-react/src/lib/types.ts b/packages/managed-auth-react/src/lib/types.ts index 01fb154..99e0af5 100644 --- a/packages/managed-auth-react/src/lib/types.ts +++ b/packages/managed-auth-react/src/lib/types.ts @@ -27,12 +27,15 @@ export type MFAType = | "switch" | "other"; +export type InputMode = "text" | "email" | "tel" | "numeric"; + export interface DiscoveredField { id?: string; ref?: string; name: string; label: string; type: "text" | "email" | "password" | "tel" | "code" | "totp"; + input_mode?: InputMode; placeholder?: string; required?: boolean; reason?: "missing" | "rejected"; @@ -72,6 +75,7 @@ export interface ManagedAuthField { | "totp_code" | "totp_secret" | "text"; + input_mode?: InputMode; label?: string; required?: boolean; reason: "missing" | "rejected"; diff --git a/packages/managed-auth-react/src/session/state.test.ts b/packages/managed-auth-react/src/session/state.test.ts index a05e717..9b78fe7 100644 --- a/packages/managed-auth-react/src/session/state.test.ts +++ b/packages/managed-auth-react/src/session/state.test.ts @@ -195,6 +195,7 @@ describe("normalizeManagedAuthState", () => { id: "field_username", ref: "username", type: "identifier", + input_mode: "text", label: "Username", reason: "missing", }, @@ -202,13 +203,15 @@ describe("normalizeManagedAuthState", () => { id: "field_email", ref: "email", type: "identifier", - label: "Email", + input_mode: "text", + label: "Mobile number, username, or email", reason: "missing", }, { id: "field_phone", ref: "phone_number", type: "identifier", + input_mode: "tel", label: "Phone", reason: "missing", }, @@ -232,6 +235,7 @@ describe("normalizeManagedAuthState", () => { ref: "username", name: "field_username", type: "text", + input_mode: "text", label: "Username", required: true, }, @@ -239,15 +243,17 @@ describe("normalizeManagedAuthState", () => { id: "field_email", ref: "email", name: "field_email", - type: "email", - label: "Email", + type: "text", + input_mode: "text", + label: "Mobile number, username, or email", required: true, }, { id: "field_phone", ref: "phone_number", name: "field_phone", - type: "tel", + type: "text", + input_mode: "tel", label: "Phone", required: true, }, diff --git a/packages/managed-auth-react/src/session/state.ts b/packages/managed-auth-react/src/session/state.ts index 19f4e77..07840af 100644 --- a/packages/managed-auth-react/src/session/state.ts +++ b/packages/managed-auth-react/src/session/state.ts @@ -40,12 +40,8 @@ function fieldTypeToDiscoveredType( field: ManagedAuthField, ): DiscoveredField["type"] { switch (field.type) { - case "identifier": { - const ref = field.ref.toLowerCase(); - if (ref.includes("email")) return "email"; - if (ref.includes("phone") || ref.includes("tel")) return "tel"; + case "identifier": return "text"; - } case "totp_code": return "totp"; case "totp_secret": @@ -69,6 +65,7 @@ function fieldsFromCanonical( ref: field.ref, name: field.id, type: fieldTypeToDiscoveredType(field), + ...(field.input_mode ? { input_mode: field.input_mode } : {}), label: field.label || field.ref, placeholder: legacyField?.placeholder, required: field.required ?? true,