i18n Architecture — English / Uzbek / Russian
Module 15, task #348. This document describes the internationalization foundation added across the platform, and — just as importantly — what it deliberately does not cover yet.
Scope
This task builds the architecture: the mechanism by which any part of
the platform can resolve a locale and translate a string, wired end-to-end
with one real, working example per surface. It is not a full retrofit of
every hardcoded string across apps/api, apps/web, apps/desktop, and
apps/mobile. Doing that in one pass would mean rewriting the UI layer of
every previously-shipped module — explicitly out of bounds under this
project's standing rule against rewriting completed modules except where
integration or bug-fixing requires it.
What exists after this task:
- A single source of truth for supported locales (
packages/shared/src/i18n.ts). - A working backend locale-resolution + translation pattern, demonstrated end-to-end in one real notification path.
- A working, additive i18n provider in the web app, wired into the root layout and demonstrated in the Preferences settings page.
- A working, additive i18n store in the desktop app, wired into the
Appearance settings section (which already had a language selector
persisting to
localStoragebut doing nothing with it). - Locale-aware
UserPreferences.languagevalidation (the field already existed; it's now restricted toen/uz/ruinstead of any string). - Scaffolding for the mobile app's own string catalog (ARB files +
l10n.yaml+flutter_localizationswiring for Flutter's built-in localizations), plus a workinglocaleProviderthat already drivesMaterialApp.router'slocale/supportedLocales.
What does not exist after this task, on purpose:
- Every string in every screen of every app is still hardcoded English. The reference integrations above (one settings page per surface) exist to prove the mechanism works and to give the next contributor a template to copy — not to claim the platform is fully translated.
- No UI language auto-switches based on this catalog outside the settings pages touched in this task.
- Mobile's
AppLocalizations(the codegenerated string catalog) is not yet generated — see "Mobile: one step remaining" below.
Supported locales
packages/shared/src/i18n.ts:
export const SUPPORTED_LOCALES = ["en", "uz", "ru"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "en";
export const LOCALE_LABELS: Record<Locale, string>; // each locale's name for itself
export function isSupportedLocale(value: string): value is Locale;
Every surface imports from here rather than redeclaring the locale list,
so adding a fourth locale later is a one-line change plus new catalog
entries — not a hunt across four codebases for every place "en" | "uz" | "ru" was typed out by hand.
Backend (apps/api)
Two small, focused utilities under apps/api/src/common/i18n/:
locale.util.ts—resolveLocale(preferredLanguage, acceptLanguageHeader). Resolution order: a valid storedUserPreferences.languagewins first; otherwise the first supported tag in anAccept-Languageheader (region subtags stripped, e.g.en-US→en); otherwiseDEFAULT_LOCALE.message-catalog.ts—translate(key, locale, params), backed by a small hand-writtenRecord<Locale, Record<MessageKey, (params) => string>>catalog. This is for backend-generated, persisted user-facing strings (notification titles/bodies, future email subjects) — not a general replacement for everyAppExceptionmessage in the API.
Reference integration: BugBountyDeadlineNotifierService
(apps/api/src/modules/bugbounty/notifications/bugbounty-deadline-notifier.service.ts).
Deadline notification titles/bodies used to be hardcoded English template
literals (`Overdue: ${event.title}`); they're now resolved through
resolveLocale() (using the recipient's UserPreferences.language, looked
up via PrismaService directly — read-only and best-effort, so a failed
lookup falls back to DEFAULT_LOCALE rather than blocking the
notification) and rendered through translate(). Covered by
bugbounty-deadline-notifier.service.spec.ts.
UserPreferences.language validation: previously any string up to 10
characters (update-preferences.dto.ts). Now @IsIn(SUPPORTED_LOCALES) —
mirrors the existing theme field's @IsIn(THEMES) pattern. No schema
migration needed; language was already a plain String column with a
"en" default, so this tightens validation at the DTO boundary rather than
the database.
Web (apps/web)
apps/web/features/i18n/:
messages/{en,uz,ru}.json— a starter catalog (~15 keys: common actions, the Preferences settings page's own copy).i18n-context.tsx—I18nProvider+useI18n(). Plain React context, not a new npm dependency (nonext-intl/react-intl) — this pass is about the architecture, and the extra library surface isn't earning its keep yet for ~15 keys; adding a dependency here would also have needed apnpm installthis environment couldn't verify completes within its tool-call time ceiling.
Locale state lives in localStorage (key phai.language, shared with the
desktop app's convention — see below) with a navigator.language
fallback, not in the authenticated usePreferences() query — the
provider mounts at the app root (app/providers.tsx), which wraps
logged-out routes (/login, /register) too, so it can't depend on data
that only exists for signed-in users.
Reference integration: app/(dashboard)/settings/preferences/page.tsx.
Its own copy (title, description, field labels, toast messages) now reads
from t(), and a new Language <Select> sits next to the existing Theme
control, calling setLocale() (instant UI update) and
updatePreferences.mutateAsync({ language }) (persistence) together —
the same two-step pattern the Theme control already used for
setTheme()/mutateAsync({ theme }).
Desktop (apps/desktop)
apps/desktop/src/hooks/useI18n.ts — a Zustand store (useI18nStore)
mirroring useTheme.ts's exact shape: module-level initial* read at
import time, localStorage persistence under the same phai.language
key the desktop Appearance settings' language <select> was already
writing to (that control's own doc comment called this out explicitly as
"the integration point once [i18n] lands, not a no-op" — this task is
that landing).
apps/desktop/src/i18n/messages/{en,uz,ru}.json — a small starter catalog
for the Appearance settings section's own labels.
Reference integration: AppearanceSettings.tsx. Both the Theme and
Language section headers, and the three theme option labels, now read
from t(); the language <select> now drives useI18nStore instead of
raw useState + manual localStorage calls.
Verification note: apps/desktop/node_modules does not exist in this
sandbox (confirmed — the directory is entirely absent, unlike
apps/api/apps/web/packages/shared, which all have working installs).
tsc/eslint could not be run against the desktop app's changes here;
they were verified by careful manual review and by mirroring
useTheme.ts's already-working pattern field-for-field. Run
pnpm --filter @pentesthub/desktop check-types and
pnpm --filter @pentesthub/desktop lint on a normal machine before
shipping.
Mobile (apps/mobile)
Two layers, one working now and one requiring a single codegen step:
-
Working now:
flutter_localizationsadded as a dependency (ships with the Flutter SDK, nopub.devresolution needed — unlike every other dependency inpubspec.yaml, which already carries an "unverified against a live resolution" disclaimer from Module 12, since this sandbox has no network path to the Flutter/Dart artifact host). A newlocaleProvider(features/settings/application/locale_notifier.dart) mirrorsThemeModeNotifierexactly —NotifierProvider+ the same Hivesettings_boxpersistence — and now drivesMaterialApp.router'slocale/supportedLocales/localizationsDelegatesinapp.dart, giving real, immediately-working localization for Flutter's own built-in Material/Widgets/Cupertino strings (date pickers, "OK"/"Cancel", text direction). The Settings screen's "Language" row — previously a staticText('English (device default)')— is now a workingDropdownButton<Locale>wired to this provider, the same pattern the Theme row next to it already used. -
One step remaining:
lib/l10n/app_{en,uz,ru}.arb(a starter catalog) andl10n.yamlare in place, andpubspec.yamlhasgenerate: trueset. This is the standard Flutter mechanism for a codegeneratedAppLocalizationsclass — but generating it requires runningflutter gen-l10n(or anyflutter build/flutter run, which trigger it automatically), which needs the Flutter SDK. This sandbox cannot run it (no network path to the Flutter/Dart artifact host — the same constraintpubspec.yaml's own top-of-file comment already discloses forflutter pub get). No Dart file in this repo importsAppLocalizations— doing so before the class exists would break the analyzer outright, not just leave something unverified. Runflutter gen-l10nonce on a machine with the Flutter SDK, then addAppLocalizations.delegatetoapp.dart'slocalizationsDelegateslist and begin converting screens' hardcoded strings.
Adding a string to an existing catalog
- Backend (
apps/api): add the key toMessageKeyand all three locale entries inmessage-catalog.ts. - Web: add the key to all three files under
apps/web/features/i18n/messages/. TypeScript enforces the three files stay in sync (Messages = typeof en, andCATALOGS: Record<Locale, Messages>— a missing key inuz.json/ru.jsonis a compile error). - Desktop: same pattern, under
apps/desktop/src/i18n/messages/. - Mobile: add the key to all three
.arbfiles, then runflutter gen-l10nto regenerateAppLocalizations.
Adding a fourth locale
- Add it to
SUPPORTED_LOCALES/LOCALE_LABELSinpackages/shared/src/i18n.ts, rebuild the package (pnpm --filter @pentesthub/shared build). - Add a full catalog file for it in each of the four locations above.
- For mobile, add it to
supportedLocalesinlocale_notifier.dartand re-runflutter gen-l10n.
Known gaps (tracked, not silently dropped)
- Full string-extraction across every existing screen/component in all four apps — genuinely out of scope for this task, not an oversight. A natural place to pick this up incrementally: each settings page or feature area converts its own strings when it's next touched for an unrelated reason, rather than a single enormous mechanical PR.
- Mobile's
AppLocalizationsgeneration (see above) — a five-minute step on a real machine, blocked purely by this sandbox's lack of network access to the Flutter toolchain. - No RTL testing was done (none of the three current locales are RTL);
the
Locale/ICU-based approach used here supports RTL locales without further architectural changes if one is added later. - No translator/reviewer pass on the Uzbek or Russian strings in this task's catalogs — they were written directly, not run through a professional translation review. Fine for a starter catalog demonstrating the mechanism; worth a real linguistic review before any of these strings are load-bearing for real users.