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:
users.locale— the user's stored preference, if anyAccept-Languageheader — parsed into language-only tags (region stripped), highestqfirst, intersected withNUXT_SUPPORTED_LOCALESNUXT_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:
| Triggered by | users.locale | Accept-Language | Default | |
|---|---|---|---|---|
| Verification | Registration (/register, passkey signup) | ✓¹ | ✓ | ✓ |
| Verification (resend) | POST /api/auth/resend-verification | ✓ | ✓ | ✓ |
| Password reset | POST /api/auth/password/forgot | ✓ | ✓ | ✓ |
| 2FA login code | Login, or POST /api/auth/send-2fa-code | ✓ | ✓ | ✓ |
| Invite | Admin panel / provisioning API | ✓² | — | ✓ |
| New-sign-in alert | A 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:
?ui_locales=on an OIDC interaction URL — the relying party's request, honoured for that request only, never persistedusers.localeof the signed-in user- the
localecookie, set by an explicit choice in the language picker Accept-Language— server-side render onlyNUXT_DEFAULT_LOCALE, when the UI has a catalogue for it- 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_LOCALESStartup 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
localecookie → theAccept-Languageheader (SSR only) → default. All three are constrained toUI_LOCALES ∩ NUXT_SUPPORTED_LOCALES— a cookie orAccept-Languagefor 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
localecookie. - Authenticated users'
users.localewins 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:
NUXT_EMAIL_VERIFICATION_SUBJECT_DEif setNUXT_EMAIL_VERIFICATION_SUBJECT(unsuffixed) otherwise- 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.localeif the address is known - The
Accept-Languageheader 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.