Skip to content
Merged
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
12 changes: 12 additions & 0 deletions src/app/(dashboard)/dashboard/providers/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4134,6 +4134,18 @@ export default function ProviderDetailPage() {
Experimental OAuth
</Button>
)}
{providerId === "codex" && (
<Button
size="sm"
variant="secondary"
icon="upload_file"
onClick={() => gateConnectionFlow(() => setImportCodexModalOpen(true))}
>
{typeof t.has === "function" && t.has("importCodexAuth")
? t("importCodexAuth")
: "Import auth"}
Comment on lines +4144 to +4146
</Button>
)}
{providerId === "claude" && (
<Button
size="sm"
Expand Down
3 changes: 2 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ThemeProvider } from "@/shared/components/ThemeProvider";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, getLocale } from "next-intl/server";
import { RTL_LOCALES } from "@/i18n/config";
import { normalizeComplianceEventTypes } from "@/i18n/request";
import { getSettings } from "@/lib/db/settings";
import type { Viewport } from "next";
import { PwaRegister } from "@/shared/components/PwaRegister";
Expand Down Expand Up @@ -52,7 +53,7 @@ export async function generateMetadata() {

export default async function RootLayout({ children }) {
const locale = await getLocale();
const messages = await getMessages();
const messages = normalizeComplianceEventTypes((await getMessages()) as Record<string, unknown>);
const isRtl = RTL_LOCALES.includes(locale as (typeof RTL_LOCALES)[number]);

return (
Expand Down
75 changes: 70 additions & 5 deletions src/i18n/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,67 @@ export function deepMergeFallback(
return target;
}

function setNestedValue(target: Record<string, unknown>, dottedKey: string, value: unknown): void {
const segments = dottedKey.split(".");
let cursor: Record<string, unknown> = target;

for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index];
if (!segment || segment === "__proto__" || segment === "constructor" || segment === "prototype") {
return;
}

if (index === segments.length - 1) {
cursor[segment] = value;
return;
}

const next = cursor[segment];
if (next && typeof next === "object" && !Array.isArray(next)) {
cursor = next as Record<string, unknown>;
continue;
}

const created: Record<string, unknown> = {};
cursor[segment] = created;
cursor = created;
Comment on lines +52 to +60
}
}

export function normalizeComplianceEventTypes(
messages: Record<string, unknown>
): Record<string, unknown> {
Comment on lines +64 to +66
Comment on lines +64 to +66
const compliance =
messages.compliance && typeof messages.compliance === "object" && !Array.isArray(messages.compliance)
? (messages.compliance as Record<string, unknown>)
: null;
const eventTypes =
compliance?.eventTypes &&
typeof compliance.eventTypes === "object" &&
!Array.isArray(compliance.eventTypes)
? (compliance.eventTypes as Record<string, unknown>)
: null;

if (!compliance || !eventTypes) return messages;

const normalizedEventTypes: Record<string, unknown> = {};
for (const [key, value] of Object.entries(eventTypes)) {
if (key.includes(".")) {
setNestedValue(normalizedEventTypes, key, value);
} else {
normalizedEventTypes[key] = value;
}
}

return {
...messages,
compliance: {
...compliance,
eventTypes: normalizedEventTypes,
},
};
}

export default getRequestConfig(async () => {
const cookieStore = await cookies();
let locale: string = cookieStore.get(LOCALE_COOKIE)?.value || "";
Expand All @@ -47,13 +108,17 @@ export default getRequestConfig(async () => {
locale = DEFAULT_LOCALE;
}

const localeMessages = (await import(`./messages/${locale}.json`)).default;
const localeMessages = normalizeComplianceEventTypes(
(await import(`./messages/${locale}.json`)).default as Record<string, unknown>
);

// G1: fall back to EN for any missing key. EN is loaded only once per request
// and only when the active locale is not EN itself (no-op).
let messages = localeMessages as Record<string, unknown>;
if (locale !== FALLBACK_LOCALE) {
const fallbackMessages = (await import(`./messages/${FALLBACK_LOCALE}.json`)).default as Record<string, unknown>;
const fallbackMessages = normalizeComplianceEventTypes(
(await import(`./messages/${FALLBACK_LOCALE}.json`)).default as Record<string, unknown>
);
messages = deepMergeFallback({ ...localeMessages }, fallbackMessages);
}

Expand All @@ -66,9 +131,9 @@ export default getRequestConfig(async () => {
// translations are shipped.
let mergedMessages: Record<string, unknown> = messages as Record<string, unknown>;
if (locale !== DEFAULT_LOCALE) {
const enMessages = (
await import(`./messages/${DEFAULT_LOCALE}.json`)
).default as Record<string, unknown>;
const enMessages = normalizeComplianceEventTypes(
(await import(`./messages/${DEFAULT_LOCALE}.json`)).default as Record<string, unknown>
);
mergedMessages = { ...enMessages, ...mergedMessages };
}

Expand Down
26 changes: 18 additions & 8 deletions tests/unit/audit-eventtype-i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,30 @@ import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { normalizeComplianceEventTypes } from "../../src/i18n/request";

const root = join(import.meta.dirname, "../..");
const read = (p: string) => readFileSync(join(root, p), "utf8");
const en = JSON.parse(read("src/i18n/messages/en.json"));
const pt = JSON.parse(read("src/i18n/messages/pt-BR.json"));
const rawEn = JSON.parse(read("src/i18n/messages/en.json"));
const rawPt = JSON.parse(read("src/i18n/messages/pt-BR.json"));
const en = normalizeComplianceEventTypes(rawEn);
const pt = normalizeComplianceEventTypes(rawPt);

function getNestedValue(record: Record<string, unknown>, dottedKey: string): unknown {
return dottedKey.split(".").reduce<unknown>((cursor, segment) => {
if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) return undefined;
return (cursor as Record<string, unknown>)[segment];
}, record);
}

test("audit: compliance.eventTypes exists with en/pt-BR parity and key coverage", () => {
const enKeys = Object.keys(en.compliance?.eventTypes ?? {});
const ptKeys = Object.keys(pt.compliance?.eventTypes ?? {});
assert.ok(enKeys.length >= 30, `expected >=30 event-type labels, got ${enKeys.length}`);
assert.deepEqual(enKeys.sort(), ptKeys.sort(), "en/pt-BR eventTypes keys must match");
const rawEnKeys = Object.keys(rawEn.compliance?.eventTypes ?? {});
const rawPtKeys = Object.keys(rawPt.compliance?.eventTypes ?? {});
assert.ok(rawEnKeys.length >= 30, `expected >=30 event-type labels, got ${rawEnKeys.length}`);
assert.deepEqual(rawEnKeys.sort(), rawPtKeys.sort(), "en/pt-BR eventTypes keys must match");
for (const k of ["provider.credentials.created", "auth.login.success", "quota.pool.created", "sync.token.revoked"]) {
assert.ok(en.compliance.eventTypes[k], `en missing eventTypes.${k}`);
assert.ok(pt.compliance.eventTypes[k], `pt-BR missing eventTypes.${k}`);
assert.ok(getNestedValue(en.compliance.eventTypes as Record<string, unknown>, k), `en missing eventTypes.${k}`);
assert.ok(getNestedValue(pt.compliance.eventTypes as Record<string, unknown>, k), `pt-BR missing eventTypes.${k}`);
Comment on lines +27 to +28
}
});

Expand Down