Internationalisation
WIPAuthI18nMessageCatalog (zero-dep) + AuthLinguiResolver. Translate every AUTH/* error code and channel template.
Why i18n?
Two surfaces in duck-auth produce user-facing strings:
- Errors -
AuthError.codeis stable, but the message you show is localised. - Channels - the magic-link, password-reset, and email-verify templates need translation.
duck-auth ships a resolver-agnostic facet for both.
Built-in: AuthI18nMessageCatalog
Zero dependencies. Define your messages inline:
import { AuthI18nMessageCatalog } from '@gentleduck/auth/i18n'
const i18n = new AuthI18nMessageCatalog({
defaultLocale: 'en',
messages: {
en: {
'AUTH/INVALID_CREDENTIALS': 'Invalid email or password.',
'AUTH/MFA_REQUIRED': 'Enter your authenticator code.',
'AUTH/RATE_LIMITED': 'Too many attempts. Try again in {minutes} minutes.',
'magic-link.subject': 'Sign in to {appName}',
'magic-link.body': 'Click here to sign in: {url}\nThis link expires in {minutes} minutes.',
},
es: {
'AUTH/INVALID_CREDENTIALS': 'Correo electrónico o contraseña no válidos.',
'AUTH/MFA_REQUIRED': 'Ingrese su código de autenticador.',
'AUTH/RATE_LIMITED': 'Demasiados intentos. Vuelva a intentarlo en {minutes} minutos.',
'magic-link.subject': 'Inicia sesión en {appName}',
'magic-link.body': 'Haga clic aquí: {url}\nEste enlace expira en {minutes} minutos.',
},
},
})
export const auth = new AuthEngine({
i18n: { locale: 'en', catalog: i18n },
})
t():
i18n.t('AUTH/RATE_LIMITED', { locale: 'es', vars: { minutes: 5 } })
// -> 'Demasiados intentos. Vuelva a intentarlo en 5 minutos.'
Interpolation uses {var} syntax. Unknown variables stay as {var}
literally - convenient for templates you haven't filled in yet.
Resolving per-request locale
The Accept-Language header is the typical signal:
import { AuthError } from '@gentleduck/auth/core'
function handleAuthError(err: AuthError, req: Request) {
const locale = pickLocale(req.headers.get('accept-language'), ['en', 'es', 'de'])
const message = i18n.t(err.code, { locale, vars: err.meta })
return new Response(JSON.stringify({ ...err.toJSON(), message }), {
status: err.status,
headers: { 'content-type': 'application/json' },
})
}
The code stays stable on the wire (for client-side branching); the
message is per-user.
Lingui adapter
For apps already using @lingui/core, the AuthLinguiResolver plugs in:
import { i18n as lingui } from '@lingui/core'
import { AuthLinguiResolver } from '@gentleduck/auth/i18n'
lingui.load('en', enMessages)
lingui.load('es', esMessages)
lingui.activate('en')
const resolver = new AuthLinguiResolver(lingui)
export const auth = new AuthEngine({
i18n: { locale: 'en', catalog: resolver },
})
Now your duck-auth strings live in the same .po files as the rest of
your app - Crowdin / Transifex workflows just work.
Channel templates
Channels read i18n vars from the runtime:
class CustomChannel implements AuthChannel.IChannel {
async send({ templateId, identity, vars, locale }) {
const subject = this.i18n.t(`${templateId}.subject`, { locale, vars })
const body = this.i18n.t(`${templateId}.body`, { locale, vars })
await this.transport.send({ to: vars.email, subject, body })
return { ok: true, providerMessageId: '...' }
}
}
The shipped channels (AuthConsoleChannel, AuthSmtpChannel, etc.) already
call into the resolver - pass i18n to the channel constructor.
Listing supported locales
i18n.supportedLocales()
// -> ['en', 'es']
Useful for driving a "preferred language" picker in your UI.
Falling back to default locale
If a message is missing in the requested locale, the resolver falls
back to the default locale (the catalog's defaultLocale). If the
message is missing there too, it returns the message id as a literal
- easy to spot in QA.
i18n + the runtime
Per-request locale is threaded through ProviderContext.i18n so custom
providers can use it:
const provider = {
id: 'custom',
async complete(input, ctx) {
throw new AuthError('AUTH/INVALID_CREDENTIALS', {
// attach a pre-localised message; the framework adapter passes it through.
message: ctx.i18n?.t('AUTH/INVALID_CREDENTIALS', { locale: ctx.locale }),
})
},
}