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