Skip to main content

Internationalisation

WIP

AuthI18nMessageCatalog (zero-dep) + AuthLinguiResolver. Translate every AUTH/* error code and channel template.

Why i18n?

Two surfaces in duck-auth produce user-facing strings:

  1. Errors - AuthError.code is stable, but the message you show is localised.
  2. 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 }),
    })
  },
}