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.tsx b/packages/managed-auth-react/src/components/UnifiedAuthForm.tsx index 49395f0..0306f7f 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.reason === "rejected" && ( +

+ {l.fieldRejectedNotice} +

+ )} {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;