Skip to content
Draft
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/built-in-localizations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@onkernel/managed-auth-react": minor
---

Ship built-in localizations for Danish, German, Spanish, Finnish, French, Italian, Norwegian Bokmål, Dutch, Polish, and Swedish, exported as `LOCALIZATIONS` with a `matchLocale` helper that picks one from the user's preferred languages. Error-code titles and descriptions and the "Powered by" footer are now part of `Localization` (`errorCodeMessages`, `poweredBy`), so they can be translated like the rest of the flow. `Localizer` now requires complete `mfaTypeLabels` and `errorCodeMessages` maps.
20 changes: 18 additions & 2 deletions packages/managed-auth-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,23 @@ Supported keys: `:hover`, `:focus`, `:focus-visible`, `:active`, `:disabled`, `:

## Localization

Pass a partial map; everything else falls back to English.
The package ships complete localizations for English (`en`), Danish (`da`), German (`de`), Spanish (`es`), Finnish (`fi`), French (`fr`), Italian (`it`), Norwegian Bokmål (`nb`), Dutch (`nl`), Polish (`pl`), and Swedish (`sv`). `matchLocale` takes language tags in order of preference, such as `navigator.languages`, and returns the first one with a built-in localization:

```tsx
import {
KernelManagedAuth,
LOCALIZATIONS,
matchLocale,
} from "@onkernel/managed-auth-react";

const locale = matchLocale(navigator.languages) ?? "en";

<KernelManagedAuth localization={LOCALIZATIONS[locale]} {...rest} />;
```

Text that comes from the website being signed into, such as field labels and options, is shown as the website provides it.

To write your own strings, pass a partial map; everything else falls back to English.

```tsx
<KernelManagedAuth
Expand All @@ -241,7 +257,7 @@ See [`Localization`](./src/localization/types.ts) for every supported key.
| `sessionId` | `string` | yes | — | Managed auth connection ID from your backend. |
| `handoffCode` | `string` | yes | — | Single-use handoff code, exchanged for a JWT. |
| `appearance` | `Appearance` | no | — | Styling — variables, elements, layout, theme. |
| `localization` | `Localization` | no | English | Partial string overrides. |
| `localization` | `Localization` | no | English | Strings: a built-in `LOCALIZATIONS[locale]` or partial overrides. |
| `onSuccess` | `(p: AuthSuccessPayload) => void` | no | — | Fires on `SUCCESS`. Payload: `{ profileName: string; domain: string }`. |
| `onError` | `(p: AuthErrorPayload) => void` | no | — | Fires on `FAILED`, `CANCELED`, `EXPIRED`. Payload: `{ code?: string; message: string }`. |
| `baseUrl` | `string` | no | `"https://api.onkernel.com"` | Override the Kernel API origin. Use `""` for same-origin proxying via your own rewrites. |
Expand Down
21 changes: 21 additions & 0 deletions packages/managed-auth-react/src/KernelManagedAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
import { createElement } from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { KernelManagedAuth } from "./KernelManagedAuth";
import { LOCALIZATIONS } from "./localization/locales";

let renderer: ReactTestRenderer | null = null;

Expand Down Expand Up @@ -128,3 +129,23 @@ describe("KernelManagedAuth human verification", () => {
expect(continueButton().props.disabled).toBe(true);
});
});

describe("KernelManagedAuth localization", () => {
test("renders the step and footer in the given localization", () => {
act(() => {
renderer = create(
createElement(KernelManagedAuth, {
sessionId: "session-id",
handoffCode: "handoff-code",
fetch: pendingExchangeFetch(),
localization: LOCALIZATIONS.sv,
}),
);
});

const output = JSON.stringify(renderer!.toJSON());
expect(output).toContain("Förbereder säker inloggning");
expect(output).toContain("Drivs av");
expect(output).not.toContain("Powered by");
});
});
4 changes: 3 additions & 1 deletion packages/managed-auth-react/src/components/Shell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CSSProperties, ReactNode } from "react";
import clsx from "clsx";
import { useSlot } from "../appearance/context";
import { useLocalization } from "../localization/context";
import { variablesToStyle } from "../appearance/variables";
import type { Appearance, ColorScheme } from "../appearance/types";
import { KernelLogo } from "./KernelLogo";
Expand All @@ -18,6 +19,7 @@ function themeClass(theme: ColorScheme | undefined): string {

export function Shell({ appearance, children }: ShellProps) {
const slot = useSlot();
const l = useLocalization();
const root = slot("root", clsx("kma-root", themeClass(appearance?.theme)));
const shell = slot("shell", "kma-shell");
const card = slot("card", "kma-card");
Expand All @@ -37,7 +39,7 @@ export function Shell({ appearance, children }: ShellProps) {
<div {...card}>{children}</div>
{showPoweredBy && (
<div {...poweredBy}>
<span className="kma-powered-by__prefix">Powered by</span>
<span className="kma-powered-by__prefix">{l.poweredBy}</span>
<a
{...poweredByLink}
href="https://kernel.sh"
Expand Down
75 changes: 60 additions & 15 deletions packages/managed-auth-react/src/components/StepError.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { act, create } from "react-test-renderer";
import { LocalizationProvider } from "../localization/context";
import { DEFAULT_LOCALIZATION } from "../localization/defaults";
import { LOCALIZATIONS } from "../localization/locales";
import type { Localization } from "../localization/types";
import { errorDisplayForCode, StepError } from "./StepError";

describe("errorDisplayForCode", () => {
Expand All @@ -15,27 +19,34 @@ describe("errorDisplayForCode", () => {
];

for (const [code, title] of cases) {
const display = errorDisplayForCode(code);
const display = errorDisplayForCode(DEFAULT_LOCALIZATION, code);
expect(display?.title).toBe(title);
expect(display?.description?.toLowerCase()).not.toContain("reauth");
}
});

test("does not infer TOTP secret availability", () => {
expect(errorDisplayForCode("totp_required")?.description).toBe(
"An authenticator code is required.",
);
expect(
errorDisplayForCode(DEFAULT_LOCALIZATION, "totp_required")?.description,
).toBe("An authenticator code is required.");
});

test("uses customer-facing copy for additional input", () => {
expect(errorDisplayForCode("customer_input_required")?.description).toBe(
"Additional information is required before login can continue.",
);
expect(
errorDisplayForCode(DEFAULT_LOCALIZATION, "customer_input_required")
?.description,
).toBe("Additional information is required before login can continue.");
});

test("distinguishes a rejected authenticator code from invalid credentials", () => {
const display = errorDisplayForCode("totp_code_rejected");
const invalidCredentials = errorDisplayForCode("credentials_invalid");
const display = errorDisplayForCode(
DEFAULT_LOCALIZATION,
"totp_code_rejected",
);
const invalidCredentials = errorDisplayForCode(
DEFAULT_LOCALIZATION,
"credentials_invalid",
);

expect(display?.title).toBe("Authenticator code rejected");
expect(display?.description).toContain("Try a fresh code");
Expand All @@ -48,7 +59,11 @@ describe("StepError description", () => {
const timeout =
"A CAPTCHA blocked the login. A person was asked to complete it but didn't finish in time. Please try again.";

function paragraphs(errorMessage?: string) {
function paragraphs(
errorMessage?: string,
errorCode = "captcha_blocked",
localization?: Localization,
) {
const hadWindow = "window" in globalThis;
if (!hadWindow) {
Object.assign(globalThis, {
Expand All @@ -59,15 +74,18 @@ describe("StepError description", () => {
let renderer!: ReturnType<typeof create>;
act(() => {
renderer = create(
createElement(StepError, {
targetDomain: "example.com",
errorCode: "captcha_blocked",
errorMessage,
createElement(LocalizationProvider, {
localization,
children: createElement(StepError, {
targetDomain: "example.com",
errorCode,
errorMessage,
}),
}),
);
});
const texts = renderer.root
.findAll((node) => node.type === "p")
.findAll((node) => node.type === "p" || node.type === "h1")
.map((node) => node.children.join(""));
const buttonCount = renderer.root.findAll(
(node) => node.type === "button",
Expand All @@ -90,4 +108,31 @@ describe("StepError description", () => {
const { texts } = paragraphs();
expect(texts).toContain("A CAPTCHA blocked the login.");
});

test("uses the localization's copy for a known error code", () => {
const { texts } = paragraphs(
undefined,
"credentials_invalid",
LOCALIZATIONS.de,
);
expect(texts).toContain("Ungültige Zugangsdaten");
expect(texts).toContain(
"Die Website hat den Benutzernamen oder das Passwort nicht akzeptiert.",
);
});

test("keeps default copy for error codes a partial localization omits", () => {
const { texts } = paragraphs(undefined, "website_error", {
errorCodeMessages: {
credentials_invalid: { title: "Custom", description: "Custom" },
},
});
expect(texts).toContain("Website error");
});

test("falls back to the generic message for an unknown error code", () => {
const { texts } = paragraphs(undefined, "toString");
expect(texts).toContain(DEFAULT_LOCALIZATION.errorTitle);
expect(texts).toContain(DEFAULT_LOCALIZATION.errorGenericMessage);
});
});
112 changes: 16 additions & 96 deletions packages/managed-auth-react/src/components/StepError.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useSlot } from "../appearance/context";
import { useLocalization } from "../localization/context";
import type { ErrorCode, Localizer } from "../localization/types";
import { extractDomainName } from "../lib/profile-name";
import { SiteIcon } from "./SiteIcon";
import { ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "./icons";
Expand All @@ -11,101 +12,18 @@ interface StepErrorProps {
errorCode?: string;
}

// preferServerMessage shows the server's error_message when there is one, since
// it says how the flow ended (e.g. a person was asked and didn't finish in time).
const ERROR_DISPLAY: Record<
string,
{ title: string; description: string; preferServerMessage?: boolean }
> = {
domain_not_allowed: {
title: "Domain not allowed",
description:
"The login flow redirected to a domain that isn't configured. Update the allowed_domains setting to include it.",
},
login_form_not_found: {
title: "Login form not found",
description:
"We couldn't find a login form on this page. The website may have changed its layout, or the login URL might be incorrect.",
},
credentials_invalid: {
title: "Invalid credentials",
description: "The username or password was not accepted by the website.",
},
totp_required: {
title: "Authenticator code required",
description: "An authenticator code is required.",
},
sms_code_required: {
title: "SMS code required",
description: "A code sent by SMS is required.",
},
email_code_required: {
title: "Email code required",
description: "A code sent by email is required.",
},
account_choice_required: {
title: "Account selection required",
description:
"An account or identity must be selected before login can continue.",
},
customer_input_required: {
title: "Additional input required",
description:
"Additional information is required before login can continue.",
},
external_action_required: {
title: "External action required",
description:
"An external action is required to continue. Check your authenticator app, email, or phone for a verification request.",
},
totp_code_rejected: {
title: "Authenticator code rejected",
description:
"The website rejected the authenticator code. Try a fresh code, or reconnect the account if generated codes keep failing across new code windows.",
},
bot_detected: {
title: "Verification required",
description:
"This website detected automated access and blocked the login.",
},
captcha_blocked: {
title: "Verification required",
description: "A CAPTCHA blocked the login.",
preferServerMessage: true,
},
stuck_in_loop: {
title: "Login couldn't complete",
description:
"We got stuck trying to complete the login. This can happen with unusual login flows.",
},
max_attempts_reached: {
title: "Login couldn't complete",
description:
"We weren't able to complete the login after several attempts.",
},
website_error: {
title: "Website error",
description:
"The website returned an error during login. The site may be experiencing issues.",
},
navigation_confused: {
title: "Login couldn't complete",
description:
"We got confused navigating this website. This sometimes happens with complex login flows.",
},
browser_error: {
title: "Browser error",
description: "There was an issue with the browser. Please try again.",
},
session_expired: {
title: "Session expired",
description:
"The login session has expired. Please start a new login flow.",
},
};
// These codes show the server's error_message when there is one, since it says
// how the flow ended (e.g. a person was asked and didn't finish in time).
const PREFER_SERVER_MESSAGE = new Set<string>(["captcha_blocked"]);

export function errorDisplayForCode(errorCode?: string) {
return errorCode ? ERROR_DISPLAY[errorCode] : undefined;
export function errorDisplayForCode(l: Localizer, errorCode?: string) {
if (
!errorCode ||
!Object.prototype.hasOwnProperty.call(l.errorCodeMessages, errorCode)
) {
return undefined;
}
return l.errorCodeMessages[errorCode as ErrorCode];
}

function extractErrorText(raw: string): string {
Expand Down Expand Up @@ -134,13 +52,15 @@ export function StepError({
const siteName = extractDomainName(targetDomain);
const [showDetails, setShowDetails] = useState(false);

const display = errorDisplayForCode(errorCode);
const display = errorDisplayForCode(l, errorCode);
const preferServerMessage =
errorCode !== undefined && PREFER_SERVER_MESSAGE.has(errorCode);
const title = display?.title ?? l.errorTitle;
const rawDetails = errorMessage?.trim()
? extractErrorText(errorMessage)
: undefined;
const description =
(display?.preferServerMessage ? rawDetails : undefined) ??
(preferServerMessage ? rawDetails : undefined) ??
display?.description ??
l.errorGenericMessage;
const hasDetails = rawDetails && rawDetails !== description;
Expand Down
15 changes: 13 additions & 2 deletions packages/managed-auth-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,19 @@ export type {
KernelLogoColor,
} from "./appearance/types";

export type { Localization, Localizer } from "./localization";
export { DEFAULT_LOCALIZATION, LocalizationProvider } from "./localization";
export type {
ErrorCode,
ErrorCodeMessage,
Localization,
Localizer,
SupportedLocale,
} from "./localization";
export {
DEFAULT_LOCALIZATION,
LOCALIZATIONS,
LocalizationProvider,
matchLocale,
} from "./localization";
export { AppearanceProvider } from "./appearance/context";

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/managed-auth-react/src/localization/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export function LocalizationProvider({
...DEFAULT_LOCALIZATION.mfaTypeLabels,
...(localization.mfaTypeLabels ?? {}),
},
errorCodeMessages: {
...DEFAULT_LOCALIZATION.errorCodeMessages,
...(localization.errorCodeMessages ?? {}),
},
};
}, [localization]);
return (
Expand Down
Loading
Loading