Skip to content

Multilingual

Schleuse ships with four bundled UI locales (English, German, French, Italian) and a foundation for localising outbound emails. Operators opt into locales via env vars; users pick theirs on the Account page or via the language switcher on any guest page.

Resolution cascade

There are two resolutions, and they can legitimately differ: the language of the page in front of you, and the language of the mail that lands in your inbox.

Outbound email

resolveLocale() picks one, in this order:

  1. users.locale — the user's stored preference, if any
  2. Accept-Language header — parsed into language-only tags (region stripped), highest q first, intersected with NUXT_SUPPORTED_LOCALES
  3. NUXT_DEFAULT_LOCALE — the configured fallback

The result is always one of NUXT_SUPPORTED_LOCALES — callers never need to re-validate.

Which sources actually feed each email depends on who triggered it:

EmailTriggered byusers.localeAccept-LanguageDefault
VerificationRegistration (/register, passkey signup)✓¹
Verification (resend)POST /api/auth/resend-verification
Password resetPOST /api/auth/password/forgot
2FA login codeLogin, or POST /api/auth/send-2fa-code
InviteAdmin panel / provisioning API✓²
New-sign-in alertA login flagged as suspicious

¹ At registration there is no stored locale yet; the locale field in the request body takes its place (the sign-up form sends the UI language). ² From the locale field of the create/import payload, not from a stored row.

Two entries have no Accept-Language on purpose:

  • Invites are created by an administrator or a server-to-server call. There is no end-user request whose headers could speak for the recipient.
  • The new-sign-in alert is a warning about the request that triggered it. Reading the language from that request's headers would let whoever is signing in choose the language of the warning the account owner has to read.

The interface

The UI locale is resolved separately, in the browser/SSR layer:

  1. ?ui_locales= on an OIDC interaction URL — the relying party's request, honoured for that request only, never persisted
  2. users.locale of the signed-in user
  3. the locale cookie, set by an explicit choice in the language picker
  4. Accept-Language — server-side render only
  5. NUXT_DEFAULT_LOCALE, when the UI has a catalogue for it
  6. English, or the first enabled locale if English is not among them

Step 6 exists because NUXT_DEFAULT_LOCALE may name a locale the interface cannot render: startup only requires it to be in NUXT_SUPPORTED_LOCALES, and that list may contain email-only locales. NUXT_DEFAULT_LOCALE=es with no app/i18n/es.ts sends Spanish mail and shows an English interface.

Configuration

NUXT_SUPPORTED_LOCALES=en,de,fr,it   # comma-separated allow-list
NUXT_DEFAULT_LOCALE=en               # must be in SUPPORTED_LOCALES

Startup fails if DEFAULT_LOCALE is not in SUPPORTED_LOCALES.

The default for NUXT_SUPPORTED_LOCALES is en — shipping a catalogue does not enable it. Listing a locale is what lets users store it and receive email in it.

The bundled UI ships all four locales regardless, so a visitor can switch the interface to German, French or Italian even if you haven't listed it. Registration tolerates this: an unsupported UI locale on the register form is ignored and falls back to NUXT_DEFAULT_LOCALE (it never fails the registration). To actually store and email in a language, add it to NUXT_SUPPORTED_LOCALES (e.g. en,de,fr,it).

UI translation

The following user-facing pages ship in all four bundled locales out of the box:

  • /login, /register, /forgot-password, /reset-password, /verify-login
  • /account (including the language picker itself)
  • / (home / setup-guide)
  • the admin panel (/admin/** + its layout) — dashboard, users, audit log, integration guide and configurator. Code samples, env-var names and OIDC identifiers stay verbatim; the admin uses the locale stored on their account.

Client-side flow

  • On first visit the plugin reads the locale cookie → the Accept-Language header (SSR only) → default. All three are constrained to UI_LOCALES ∩ NUXT_SUPPORTED_LOCALES — a cookie or Accept-Language for a locale the operator hasn't enabled falls through to the default.
  • Anonymous users can pick a locale via the language switcher on every guest page — which offers only the enabled UI locales and is hidden entirely when just one is enabled. Choice persists via the locale cookie.
  • Authenticated users' users.locale wins over the cookie. The Account page picker writes to both (PATCH /api/me {locale} + cookie) and re-renders immediately without a reload.

Email localisation

Email templates support per-locale overrides via the _<LOCALE> suffix convention — including the new-sign-in alert (NUXT_EMAIL_NEW_LOGIN_*), which is sent in the user's stored locale like the other transactional emails. See Email templates for the details.

Example: a user with user.locale=de triggering a verification email will get:

  1. NUXT_EMAIL_VERIFICATION_SUBJECT_DE if set
  2. NUXT_EMAIL_VERIFICATION_SUBJECT (unsuffixed) otherwise
  3. The built-in English default as the final fallback

Anonymous flows (forgot-password, 2FA)

For flows triggered without an active UI session, the email's locale comes from:

  • The user's users.locale if the address is known
  • The Accept-Language header of the triggering HTTP request
  • NUXT_DEFAULT_LOCALE

Adding more locales

English, German, French and Italian ship in the box — UI and emails from the same catalogue. One file per locale under app/i18n/: the UI strings, plus an email subtree holding the built-in copy for all five outbound emails. Enabling a locale therefore gives you a translated interface and translated mail, no env vars needed.

A further locale (say Spanish) requires editing the code: add app/i18n/es.ts mirroring the English catalogue's shape — email subtree included — add 'es' to the Locale union in app/i18n/types.ts, and register it in messages and UI_LOCALES in app/i18n/index.ts. That is the whole change; the server reads the built-in email copy from the same catalogue via emailCopyFor().

TypeScript enforces shape parity in your editor, and tests/i18n-parity.test.ts enforces it in CI: every locale has exactly the English key set, no empty strings, and the same placeholder tokens per key.

Mind the two placeholder syntaxes — UI strings use {single} braces (interpolated by t()), email atoms use braces (interpolated by the email loader's substitute(), matching the operator-facing env-override syntax). The parity test fails the build if one is used where the other belongs.

Register is per language, not copied from English: French uses formal vous, German and Italian informal du / tu.

Adding an email-only locale beyond those four (no UI translation) needs only env vars — operator-side, no code change:

NUXT_EMAIL_VERIFICATION_SUBJECT_ES="Confirma tu cuenta"

Users can still store user.locale=es if you list es in NUXT_SUPPORTED_LOCALES; both the UI and any email piece you haven't overridden fall back to English.

Server-side field validation errors (Zod) are code-tagged and localised on the client: the API returns { field, code, params?, message } per error, and the UI maps code to a validation.* message in the active locale (falling back to the English message for any code without a translation). Translation stays in the UI layer — no Accept-Language server plumbing.

The password-policy hint and the password-reuse rejection are localised too: the hint is rebuilt client-side from the rules (served by /api/ready) in the active locale, and the reuse 400 is code-tagged (passwordReuse) so the client shows the translated message.

An RP can request an interaction language with the OIDC ui_locales parameter on /oidc/authorize (a space-separated, priority-ordered list). The requested locale is honoured on the login / register / forgot pages for that flow — constrained to the enabled UI locales, and applied per request (it doesn't overwrite the user's locale cookie). A non-enabled value falls through to the normal resolution.

What's not included

  • RTL layouts, locale-aware date/number formatting. None of the current UI renders formatted numbers/dates.

A small but polished sidecar identity provider.