diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 09161d09..0da91b9a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,5 +1,6 @@ import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router"; import { Button } from "@/components/ui/button"; +import { useTranslation } from "@/i18n"; import { Layout } from "./components/Layout"; import { OnboardingWizard } from "./components/OnboardingWizard"; import { CloudAccessGate } from "./components/CloudAccessGate"; @@ -245,16 +246,21 @@ function UnprefixedBoardRedirect() { function NoCompaniesStartPage() { const { openOnboarding } = useDialogActions(); + const { t } = useTranslation(); return (
-

Create your first company

+

+ {t("app.noCompanies.title", { defaultValue: "Create your first company" })} +

- Get started by creating a company. + {t("app.noCompanies.description", { defaultValue: "Get started by creating a company." })}

- +
diff --git a/ui/src/i18n/index.ts b/ui/src/i18n/index.ts new file mode 100644 index 00000000..fb0bacd8 --- /dev/null +++ b/ui/src/i18n/index.ts @@ -0,0 +1,26 @@ +import i18n, { type InitOptions, type TOptions } from "i18next"; +import { initReactI18next, useTranslation as useReactI18nextTranslation } from "react-i18next"; + +import { DEFAULT_LOCALE, i18nextResources, supportedLocales } from "./locales"; + +const i18nextOptions: InitOptions = { + resources: i18nextResources, + lng: DEFAULT_LOCALE, + fallbackLng: DEFAULT_LOCALE, + supportedLngs: supportedLocales, + defaultNS: "translation", + interpolation: { escapeValue: false }, + returnObjects: false, + initAsync: false, +}; + +void i18n.use(initReactI18next).init(i18nextOptions).catch((error: unknown) => { + console.error("Failed to initialize i18next", error); +}); + +export function t(key: string, options: TOptions = {}) { + return i18n.t(key, options); +} + +export const useTranslation = useReactI18nextTranslation; +export { i18n }; diff --git a/ui/src/i18n/locale-validation.test.ts b/ui/src/i18n/locale-validation.test.ts new file mode 100644 index 00000000..ab0e4178 --- /dev/null +++ b/ui/src/i18n/locale-validation.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { t } from "."; +import en from "./locales/en.json"; +import { localeMessages } from "./locales"; +import { validateLocaleMessages } from "./locale-validation"; + +describe("locale validation", () => { + it("resolves English messages with key and default fallbacks", () => { + expect(t("app.noCompanies.title")).toBe(en.app.noCompanies.title); + expect(t("app.missing", { defaultValue: "Fallback" })).toBe("Fallback"); + expect(t("app.missing")).toBe("app.missing"); + }); + + it("accepts registered locale files", () => { + expect(Object.keys(localeMessages)).toContain("en"); + for (const [locale, messages] of Object.entries(localeMessages)) { + expect(validateLocaleMessages(messages), locale).toEqual([]); + } + }); + + it("rejects missing and extra nested keys", () => { + expect( + validateLocaleMessages({ + app: { + noCompanies: { + title: en.app.noCompanies.title, + description: en.app.noCompanies.description, + unexpected: "Unexpected", + }, + }, + }), + ).toEqual( + expect.arrayContaining([ + "app.noCompanies.newCompany is missing", + "app.noCompanies.unexpected is not defined in English", + ]), + ); + }); + + it("rejects non-string leaves", () => { + expect( + validateLocaleMessages({ + app: { + noCompanies: { + ...en.app.noCompanies, + title: ["Create your first company"], + }, + }, + }), + ).toEqual(expect.arrayContaining(["app.noCompanies.title must be a string"])); + }); + + it("requires interpolation placeholders to match English", () => { + const reference = { + message: "Invite {{name}} to {{company}}", + }; + + expect(validateLocaleMessages({ message: "Invite {{name}}" }, reference)).toEqual([ + 'message interpolation placeholders must match English exactly: expected ["company","name"], received ["name"]', + ]); + }); + + it("rejects executable, raw HTML, and unexpected link payloads not present in English", () => { + const reference = { + script: "Create company", + handler: "Create company", + js: "Create company", + data: "Create company", + url: "Create company", + html: "Create company", + }; + + expect( + validateLocaleMessages( + { + script: "", + handler: 'Create', + js: "javascript:alert(1)", + data: "data:text/html,hello", + url: "https://example.test", + html: "Create company", + }, + reference, + ), + ).toEqual( + expect.arrayContaining([ + "script contains disallowed { + expect(validateLocaleMessages({ message: "x".repeat(200) }, { message: "Short" })).toEqual([ + "message is too long: 200 characters exceeds 133", + ]); + }); +}); diff --git a/ui/src/i18n/locale-validation.ts b/ui/src/i18n/locale-validation.ts new file mode 100644 index 00000000..8450677b --- /dev/null +++ b/ui/src/i18n/locale-validation.ts @@ -0,0 +1,121 @@ +import en from "./locales/en.json"; + +const MAX_STRING_LENGTH = 2_000; + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function formatPath(path: string[]) { + return path.length > 0 ? path.join(".") : ""; +} + +function interpolationPlaceholders(value: string) { + return Array.from(value.matchAll(/{{\s*([A-Za-z0-9_.-]+)\s*}}/g), (match) => match[1]).sort(); +} + +function hasRawHtml(value: string) { + return /<\/?[A-Za-z][A-Za-z0-9:-]*(?:\s[^>]*)?>/.test(value); +} + +function hasEventHandlerAttribute(value: string) { + return /\son[A-Za-z]+\s*=/i.test(value); +} + +function urlsIn(value: string) { + return Array.from(value.matchAll(/\bhttps?:\/\/[^\s<>"')]+/gi), (match) => match[0]).sort(); +} + +function hasBlockedData(value: string, englishValue: string) { + const checks: Array<[boolean, boolean, string]> = [ + [/ candidateHas && !englishHas) + .map(([, , blockedPayload]) => blockedPayload); + + const englishUrls = new Set(urlsIn(englishValue)); + const unexpectedUrl = urlsIn(value).find((url) => !englishUrls.has(url)); + if (unexpectedUrl) blocked.push("unexpected URL"); + + return blocked; +} + +function validateString(path: string[], candidateValue: string, englishValue: string, errors: string[]) { + const candidatePlaceholders = interpolationPlaceholders(candidateValue); + const englishPlaceholders = interpolationPlaceholders(englishValue); + if (candidatePlaceholders.join("\u0000") !== englishPlaceholders.join("\u0000")) { + errors.push( + `${formatPath(path)} interpolation placeholders must match English exactly: expected ${JSON.stringify( + englishPlaceholders, + )}, received ${JSON.stringify(candidatePlaceholders)}`, + ); + } + + for (const blockedPayload of hasBlockedData(candidateValue, englishValue)) { + errors.push(`${formatPath(path)} contains disallowed ${blockedPayload}`); + } + + const relativeLimit = Math.max(englishValue.length * 4 + 64, englishValue.length + 128); + const lengthLimit = Math.min(MAX_STRING_LENGTH, relativeLimit); + if (candidateValue.length > lengthLimit) { + errors.push(`${formatPath(path)} is too long: ${candidateValue.length} characters exceeds ${lengthLimit}`); + } +} + +function validateNode(path: string[], candidate: unknown, englishReference: unknown, errors: string[]) { + if (typeof englishReference === "string") { + if (typeof candidate !== "string") { + errors.push(`${formatPath(path)} must be a string`); + return; + } + validateString(path, candidate, englishReference, errors); + return; + } + + if (!isPlainObject(englishReference)) { + errors.push(`${formatPath(path)} has unsupported English reference type`); + return; + } + + if (!isPlainObject(candidate)) { + errors.push(`${formatPath(path)} must be an object`); + return; + } + + const englishKeys = Object.keys(englishReference).sort(); + const candidateKeys = Object.keys(candidate).sort(); + const missingKeys = englishKeys.filter((key) => !candidateKeys.includes(key)); + const extraKeys = candidateKeys.filter((key) => !englishKeys.includes(key)); + + for (const key of missingKeys) { + errors.push(`${formatPath([...path, key])} is missing`); + } + for (const key of extraKeys) { + errors.push(`${formatPath([...path, key])} is not defined in English`); + } + + for (const key of englishKeys) { + if (key in candidate) { + validateNode([...path, key], candidate[key], englishReference[key], errors); + } + } +} + +export function validateLocaleMessages(candidate: unknown, englishReference: unknown = en) { + const errors: string[] = []; + validateNode([], candidate, englishReference, errors); + return errors; +} + +export function assertValidLocaleMessages(candidate: unknown, englishReference: unknown = en) { + const errors = validateLocaleMessages(candidate, englishReference); + if (errors.length > 0) { + throw new Error(`Invalid locale messages:\n${errors.join("\n")}`); + } +} diff --git a/ui/src/i18n/locales.ts b/ui/src/i18n/locales.ts new file mode 100644 index 00000000..188ee2b1 --- /dev/null +++ b/ui/src/i18n/locales.ts @@ -0,0 +1,41 @@ +import type { Resource } from "i18next"; + +import { assertValidLocaleMessages } from "./locale-validation"; + +export const DEFAULT_LOCALE = "en" as const; + +const localeModules = import.meta.glob("./locales/*.json", { + eager: true, + import: "default", +}) as Record; + +export const localeMessages = Object.fromEntries( + Object.entries(localeModules).map(([path, messages]) => { + const locale = path.match(/\/([A-Za-z0-9_-]+)\.json$/)?.[1]; + if (!locale) { + throw new Error(`Invalid locale file path: ${path}`); + } + return [locale, messages]; + }), +); + +if (!(DEFAULT_LOCALE in localeMessages)) { + throw new Error(`Missing default locale messages for ${DEFAULT_LOCALE}`); +} + +for (const [locale, messages] of Object.entries(localeMessages)) { + try { + assertValidLocaleMessages(messages); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid ${locale} locale messages: ${message}`); + } +} + +export const supportedLocales = Object.keys(localeMessages); + +export const i18nextResources: Resource = Object.fromEntries( + Object.entries(localeMessages).map(([locale, messages]) => [locale, { translation: messages }]), +) as Resource; + +export type SupportedLocale = keyof typeof localeMessages; diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json new file mode 100644 index 00000000..9e50199b --- /dev/null +++ b/ui/src/i18n/locales/en.json @@ -0,0 +1,9 @@ +{ + "app": { + "noCompanies": { + "title": "Create your first company", + "description": "Get started by creating a company.", + "newCompany": "New Company" + } + } +}