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 when a canonical field has `reason: "rejected"`. Adds the `fieldRejectedNotice` label and the `inputRejectedNotice` appearance slot.
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/managed-auth-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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. */
inputRejectedNotice?: 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.reason === "rejected" && (
<p {...slot("inputRejectedNotice", "kma-input-rejected-notice")}>
{l.fieldRejectedNotice}
</p>
)}
{field.hint && (
<p {...slot("inputHint", "kma-input-hint")}>{field.hint}</p>
)}
Expand Down
39 changes: 39 additions & 0 deletions packages/managed-auth-react/src/lib/sdk-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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, Right> = [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<SDKStateEvent["fields"]>[number];
type SDKEventChoice = NonNullable<SDKStateEvent["choices"]>[number];

const fieldMatches: Equal<ManagedAuthField, SDKManagedAuth.Field> = true;
const choiceMatches: Equal<ManagedAuthChoice, SDKManagedAuth.Choice> = true;
const eventFieldMatches: Equal<ManagedAuthField, SDKEventField> = true;
const eventChoiceMatches: Equal<ManagedAuthChoice, SDKEventChoice> = true;
const submitMatches: ManagedAuthSubmitBody extends ConnectionSubmitParams
? true
: false = true;

expect([
fieldMatches,
choiceMatches,
eventFieldMatches,
eventChoiceMatches,
submitMatches,
]).toEqual([true, true, true, true, true]);
});
4 changes: 3 additions & 1 deletion packages/managed-auth-react/src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -73,6 +74,7 @@ export interface ManagedAuthField {
| "text";
label?: string;
required?: boolean;
reason: "missing" | "rejected";
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",
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: {
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;
fieldRejectedNotice?: string;
credentialSafetyNotice?: string;
/** MFA type labels. */
mfaTypeLabels?: Partial<Record<MFAType, string>>;
Expand Down
61 changes: 61 additions & 0 deletions packages/managed-auth-react/src/session/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe("mergeStateEvent", () => {
ref: "email",
type: "identifier",
label: "Email",
reason: "missing",
},
],
choices: [{ id: "google", type: "sso_provider", label: "Google" }],
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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",
},
]);
});
});
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 @@ -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(
Expand Down Expand Up @@ -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 ?? [])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function awaitingInputState(): ManagedAuthResponse {
ref: "email",
type: "identifier",
label: "Email",
reason: "missing",
},
],
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-rejected-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