diff --git a/.changeset/replace-existing-field-notice.md b/.changeset/replace-existing-field-notice.md new file mode 100644 index 0000000..60f1153 --- /dev/null +++ b/.changeset/replace-existing-field-notice.md @@ -0,0 +1,5 @@ +--- +"@onkernel/managed-auth-react": minor +--- + +Show a notice when a canonical field has `reason: "rejected"`. Adds the `fieldRejectedNotice` label and the `inputRejectedNotice` appearance slot. diff --git a/bun.lock b/bun.lock index 3cfc67f..d5ab46c 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "devDependencies": { "@changesets/changelog-github": "0.7.0", "@changesets/cli": "^2.27.0", + "@onkernel/sdk": "0.93.0", "@types/bun": "1.2.21", "@types/node": "^20", "@types/react-test-renderer": "18.3.1", @@ -208,6 +209,8 @@ "@onkernel/managed-auth-react-demo": ["@onkernel/managed-auth-react-demo@workspace:packages/demo"], + "@onkernel/sdk": ["@onkernel/sdk@0.93.0", "", {}, "sha512-Hb28fq/XGoDbJGOZXHX7MPdFbBs9OtVER44Xo1p6VT4123xpQMVHCGdR4AdW2KuEEwJpXd1yxuTn0GPvUG8dXw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], diff --git a/package.json b/package.json index 12947d0..405b379 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "devDependencies": { "@changesets/changelog-github": "0.7.0", "@changesets/cli": "^2.27.0", + "@onkernel/sdk": "0.93.0", "@types/bun": "1.2.21", "@types/node": "^20", "@types/react-test-renderer": "18.3.1", diff --git a/packages/managed-auth-react/README.md b/packages/managed-auth-react/README.md index a1750c2..aca3144 100644 --- a/packages/managed-auth-react/README.md +++ b/packages/managed-auth-react/README.md @@ -3,8 +3,8 @@ One drop-in React component for [Kernel](https://onkernel.com) managed auth. Plug in your session, customize every element, ship. ```bash -bun add @onkernel/managed-auth-react -# or: npm install @onkernel/managed-auth-react +bun add @onkernel/managed-auth-react @onkernel/sdk@latest +# or: npm install @onkernel/managed-auth-react @onkernel/sdk@latest ``` ## Quick start diff --git a/packages/managed-auth-react/src/appearance/types.ts b/packages/managed-auth-react/src/appearance/types.ts index 16eb621..4db0796 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. */ + inputRejectedNotice?: ElementValue; /** Password show/hide toggle button. */ passwordToggle?: ElementValue; /** All buttons — base. */ diff --git a/packages/managed-auth-react/src/components/UnifiedAuthForm.test.ts b/packages/managed-auth-react/src/components/UnifiedAuthForm.test.ts index 1c62d2a..bae2faa 100644 --- a/packages/managed-auth-react/src/components/UnifiedAuthForm.test.ts +++ b/packages/managed-auth-react/src/components/UnifiedAuthForm.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { getAutocomplete } from "./UnifiedAuthForm"; +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { AppearanceProvider } from "../appearance/context"; +import { LocalizationProvider } from "../localization/context"; +import { getAutocomplete, UnifiedAuthForm } from "./UnifiedAuthForm"; describe("getAutocomplete", () => { test("uses the canonical field ref for username autocomplete", () => { @@ -14,3 +18,62 @@ describe("getAutocomplete", () => { ).toBe("username"); }); }); + +describe("UnifiedAuthForm", () => { + test("renders an accessible, customizable rejection notice", () => { + const fieldName = "password-field"; + const rejectedNoticeId = `${fieldName}-rejected-notice`; + const hintId = `${fieldName}-hint`; + let renderer!: ReturnType; + + act(() => { + renderer = create( + createElement(AppearanceProvider, { + appearance: { + elements: { inputRejectedNotice: "custom-rejection" }, + }, + children: createElement(LocalizationProvider, { + localization: { + fieldRejectedNotice: "That value was rejected", + }, + children: createElement(UnifiedAuthForm, { + targetDomain: "example.com", + fields: [ + { + name: fieldName, + label: "Password", + type: "password", + reason: "rejected", + hint: "Use your current password", + }, + ], + onSubmitFields: () => {}, + onSSOClick: () => {}, + onMFASelect: () => {}, + onSignInOptionSelect: () => {}, + }), + }), + }), + ); + }); + + const input = renderer.root.findByType("input"); + const notice = renderer.root.findByProps({ + "data-kma-element": "inputRejectedNotice", + }); + const hint = renderer.root.findByProps({ + "data-kma-element": "inputHint", + }); + + expect(input.props["aria-describedby"]).toBe( + `${rejectedNoticeId} ${hintId}`, + ); + expect(notice.props.id).toBe(rejectedNoticeId); + expect(notice.props.role).toBe("status"); + expect(notice.props.className).toContain("custom-rejection"); + expect(notice.children).toEqual(["That value was rejected"]); + expect(hint.props.id).toBe(hintId); + + act(() => renderer.unmount()); + }); +}); diff --git a/packages/managed-auth-react/src/components/UnifiedAuthForm.tsx b/packages/managed-auth-react/src/components/UnifiedAuthForm.tsx index 49395f0..1f23bbd 100644 --- a/packages/managed-auth-react/src/components/UnifiedAuthForm.tsx +++ b/packages/managed-auth-react/src/components/UnifiedAuthForm.tsx @@ -95,6 +95,17 @@ export function getAutocomplete(field: DiscoveredField): string | undefined { } } +function getDescriptionIds(field: DiscoveredField): string | undefined { + const ids: string[] = []; + if (field.reason === "rejected") { + ids.push(`${field.name}-rejected-notice`); + } + if (field.hint) { + ids.push(`${field.name}-hint`); + } + return ids.length > 0 ? ids.join(" ") : undefined; +} + export function UnifiedAuthForm({ targetDomain, ssoProvider, @@ -257,6 +268,7 @@ export function UnifiedAuthForm({ placeholder={field.placeholder} required={field.required} autoComplete={getAutocomplete(field)} + aria-describedby={getDescriptionIds(field)} value={formData[field.name] || ""} onChange={(e) => setFormData((prev) => ({ @@ -291,6 +303,7 @@ export function UnifiedAuthForm({ placeholder={field.placeholder} required={field.required} autoComplete={getAutocomplete(field)} + aria-describedby={getDescriptionIds(field)} value={formData[field.name] || ""} onChange={(e) => setFormData((prev) => ({ @@ -300,8 +313,22 @@ export function UnifiedAuthForm({ } /> )} + {field.reason === "rejected" && ( +

+ {l.fieldRejectedNotice} +

+ )} {field.hint && ( -

{field.hint}

+

+ {field.hint} +

)} ))} diff --git a/packages/managed-auth-react/src/lib/sdk-contract.test.ts b/packages/managed-auth-react/src/lib/sdk-contract.test.ts new file mode 100644 index 0000000..fe74208 --- /dev/null +++ b/packages/managed-auth-react/src/lib/sdk-contract.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test"; +import type { + ConnectionFollowResponse, + ConnectionSubmitParams, + ManagedAuth as SDKManagedAuth, +} from "@onkernel/sdk/resources/auth/connections"; +import type { ManagedAuthSubmitBody } from "./api"; +import type { ManagedAuthChoice, ManagedAuthField } from "./types"; + +type Equal = [Left] extends [Right] + ? [Right] extends [Left] + ? true + : false + : false; + +test("canonical protocol types match @onkernel/sdk", () => { + type SDKStateEvent = Extract< + ConnectionFollowResponse, + { event: "managed_auth_state" } + >; + type SDKEventField = NonNullable[number]; + type SDKEventChoice = NonNullable[number]; + + const fieldMatches: Equal = true; + const choiceMatches: Equal = true; + const eventFieldMatches: Equal = true; + const eventChoiceMatches: Equal = true; + const submitMatches: ManagedAuthSubmitBody extends ConnectionSubmitParams + ? true + : false = true; + + expect([ + fieldMatches, + choiceMatches, + eventFieldMatches, + eventChoiceMatches, + submitMatches, + ]).toEqual([true, true, true, true, true]); +}); diff --git a/packages/managed-auth-react/src/lib/types.ts b/packages/managed-auth-react/src/lib/types.ts index c78e79d..01fb154 100644 --- a/packages/managed-auth-react/src/lib/types.ts +++ b/packages/managed-auth-react/src/lib/types.ts @@ -1,5 +1,5 @@ // Protocol types for Kernel managed auth. -// Vendored from @onkernel/sdk to keep this package runtime-free. +// Synchronized with @onkernel/sdk v0.93.0 and vendored to keep this package runtime-free. export type FlowStatus = | "PENDING" @@ -35,6 +35,7 @@ export interface DiscoveredField { type: "text" | "email" | "password" | "tel" | "code" | "totp"; placeholder?: string; required?: boolean; + reason?: "missing" | "rejected"; hint?: string; linked_mfa_type?: MFAType; } @@ -73,6 +74,7 @@ export interface ManagedAuthField { | "text"; label?: string; required?: boolean; + reason: "missing" | "rejected"; 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..9dddebc 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", + fieldRejectedNotice: + "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..73d28a7 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; + fieldRejectedNotice?: 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 338f3fa..a05e717 100644 --- a/packages/managed-auth-react/src/session/state.test.ts +++ b/packages/managed-auth-react/src/session/state.test.ts @@ -28,6 +28,7 @@ describe("mergeStateEvent", () => { ref: "email", type: "identifier", label: "Email", + reason: "missing", }, ], choices: [{ id: "google", type: "sso_provider", label: "Google" }], @@ -195,18 +196,21 @@ describe("normalizeManagedAuthState", () => { ref: "username", type: "identifier", label: "Username", + reason: "missing", }, { id: "field_email", ref: "email", type: "identifier", label: "Email", + reason: "missing", }, { id: "field_phone", ref: "phone_number", type: "identifier", label: "Phone", + reason: "missing", }, ], choices: [ @@ -259,3 +263,60 @@ describe("normalizeManagedAuthState", () => { }); }); }); + +describe("field reason", () => { + test("carries the canonical field reason into the rendered field", () => { + const state = managedAuthState({ + fields: [ + { + id: "field_password", + ref: "password", + type: "password", + label: "Password", + reason: "rejected", + }, + ], + }); + + const derived = normalizeManagedAuthState(state).discovered_fields; + expect(derived?.[0].reason).toBe("rejected"); + }); + + test("projects the exact field shape the form renders", () => { + const state = managedAuthState({ + fields: [ + { + id: "field_password", + ref: "password", + type: "password", + label: "Password", + required: false, + reason: "missing", + }, + ], + 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, + reason: "missing", + 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 43e3501..19f4e77 100644 --- a/packages/managed-auth-react/src/session/state.ts +++ b/packages/managed-auth-react/src/session/state.ts @@ -57,17 +57,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, + reason: field.reason, + hint: field.hint ?? legacyField?.hint, + }; + }); } function ssoButtonsFromCanonical( @@ -177,7 +185,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/session/useManagedAuthSession.test.ts b/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts index a5821de..def4b85 100644 --- a/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts +++ b/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts @@ -28,6 +28,7 @@ function awaitingInputState(): ManagedAuthResponse { ref: "email", type: "identifier", label: "Email", + reason: "missing", }, ], choices: [], diff --git a/packages/managed-auth-react/src/styles/styles.css b/packages/managed-auth-react/src/styles/styles.css index be0412e..38203cf 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-rejected-notice { + margin: 0; + font-size: var(--kma-font-size-sm); + color: var(--kma-color-danger); +} + .kma-password-toggle { position: absolute; right: 10px;