diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aa8bc27..afc0059 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,7 +21,15 @@ jobs:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
- django: ["4.2", "5.2"]
+ django: ["4.2", "5.2", "6.1"]
+ exclude:
+ # Django 6.x requires Python >=3.12. Without these, uv cannot
+ # resolve the combination and the leg fails on dependency
+ # resolution rather than on anything about this package.
+ - python-version: "3.10"
+ django: "6.1"
+ - python-version: "3.11"
+ django: "6.1"
steps:
- uses: actions/checkout@v7
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 9b29a5c..7989ba6 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -65,7 +65,7 @@ jobs:
- python-version: "3.10"
django: "4.2"
- python-version: "3.13"
- django: "5.2"
+ django: "6.1"
steps:
- uses: actions/checkout@v7
diff --git a/.gitignore b/.gitignore
index 29370c6..6a906d1 100755
--- a/.gitignore
+++ b/.gitignore
@@ -49,6 +49,20 @@ coverage.xml
*.mo
*.pot
+# ...except this package's own catalogs, which are source, not build output.
+# This negation is load-bearing twice over: hatchling honours .gitignore when
+# it builds, so an ignored catalog is also an UNSHIPPED one -- Django then
+# finds no locale directory in the installed package and every string falls
+# back to English with nothing in the logs to say why. django.pot hit exactly
+# that and was caught by test_packaging.py.
+#
+# .mo is un-ignored for the same reason, ahead of need: no compiled catalog
+# ships today (every translation is still a fuzzy machine draft), and
+# test_i18n.py fails if one appears. When a language is reviewed and its
+# fuzzy flags come off, its .mo has to be committed to reach users.
+!django_mfa/locale/django.pot
+!django_mfa/locale/*/LC_MESSAGES/*.mo
+
# Django stuff:
*.log
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6280a72..bcc6942 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,47 @@ Versions follow [PEP 440](https://peps.python.org/pep-0440/). The version in
`pyproject.toml` is the only place it is written; the git tag and the GitHub
Release are derived from it (see [docs/contributing.md](docs/contributing.md)).
+## 4.3.0
+
+### Added
+
+- **Translation catalogs.** `django_mfa/locale/` now ships `django.pot` (75
+ entries) and machine-drafted `.po` files for `de`, `es`, `fr`, `pt_BR`,
+ `ja` and `zh_Hans`. Every entry is marked `fuzzy`, so gettext ignores it
+ and users still see English: **no language is live yet**, and a draft only
+ starts appearing after a human reviews it and removes the flags. See
+ [docs/translations.md](docs/translations.md). No compiled `.mo` files
+ ship, because a fully fuzzy catalog compiles to an empty one.
+- The Python side is now translatable, matching the templates (which already
+ were): the verification error, the passkey sign-in error, each adapter's
+ `verbose_name`, and `Authenticator.Type`'s labels. Wrapping the `Type`
+ labels needs **no migration** — a `gettext_lazy` proxy compares equal to
+ the string it wraps, so the autodetector sees no change to `choices`
+ (verified on Django 4.2, 5.2 and 6.1).
+
+- **Django 6.1 support**, now claimed in the classifiers and exercised in CI
+ on Python 3.12 and 3.13. Django 6.x requires Python 3.12+, so the matrix
+ excludes it on 3.10/3.11; those interpreters keep Django 4.2 and 5.2, both
+ still LTS. No source change was needed — the suite already passed on 6.x.
+ (Django 6.0 passes too, but is not claimed or tested.)
+
+### Changed
+
+- The PyPI classifier is now `Development Status :: 5 - Production/Stable`,
+ up from `4 - Beta`.
+- `publish.yml`'s pre-release smoke matrix now tests the newest supported
+ corner as Python 3.13 + Django 6.1, up from 3.13 + 5.2. The oldest corner
+ (3.10 + 4.2) is unchanged.
+
+### Fixed
+
+- `.gitignore`'s blanket `*.pot`/`*.mo` rules excluded the package's own
+ catalogs. Because hatchling honours `.gitignore` at build time, an ignored
+ catalog is also an unshipped one — Django would find no locale directory
+ in the installed package and silently fall back to English. Negations now
+ keep `django_mfa/locale/` tracked, and `test_packaging.py` asserts the
+ catalogs are in the built wheel.
+
## 4.2.0
### Added
diff --git a/README.md b/README.md
index 81521f3..39f7d5c 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -209,13 +209,14 @@ this same API — there's no privileged path.
| | |
|---|---|
| **Python** | 3.10 · 3.11 · 3.12 · 3.13 |
-| **Django** | 4.2 LTS · 5.2 LTS |
+| **Django** | 4.2 LTS · 5.2 LTS · 6.1 |
| **Database** | Anything Django supports (state is a `JSONField`) |
| **Dependencies** | `fido2`, `qrcode`. TOTP is implemented in-package, not pulled in. |
-Every combination in that grid runs the full suite in CI, along with a job that builds
-the wheel, installs it into a clean environment, and starts Django against it from
-outside the source tree.
+Every combination runs the full suite in CI — except Django 6.1 on Python 3.10 or
+3.11, which Django itself doesn't support (6.x requires Python 3.12+). Alongside it,
+a job builds the wheel, installs it into a clean environment, and starts Django
+against it from outside the source tree.
## Documentation
diff --git a/django_mfa/adapters/email.py b/django_mfa/adapters/email.py
index 73ad293..edb03ef 100644
--- a/django_mfa/adapters/email.py
+++ b/django_mfa/adapters/email.py
@@ -17,6 +17,7 @@
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.crypto import salted_hmac
+from django.utils.translation import gettext_lazy as _
from django_mfa import ratelimit
from django_mfa.conf import settings as mfa_settings
@@ -100,7 +101,7 @@ def _is_fresh(state):
class EmailAdapter(Adapter):
type = Authenticator.Type.EMAIL
- verbose_name = "Emailed code"
+ verbose_name = _("Emailed code")
supports_multiple = False
supports_enroll = True
counts_as_primary_factor = True
diff --git a/django_mfa/adapters/recovery_codes.py b/django_mfa/adapters/recovery_codes.py
index ac7f27c..2f7ec1e 100644
--- a/django_mfa/adapters/recovery_codes.py
+++ b/django_mfa/adapters/recovery_codes.py
@@ -3,6 +3,7 @@
import string
from django.contrib.auth.hashers import check_password, make_password
+from django.utils.translation import gettext_lazy as _
from django_mfa import events
from django_mfa.atomic import update_data
@@ -17,7 +18,7 @@
class RecoveryCodesAdapter(Adapter):
type = Authenticator.Type.RECOVERY_CODES
- verbose_name = "Recovery codes"
+ verbose_name = _("Recovery codes")
# Recovery codes are exhaustible and must never be a user's sole second
# factor. They still appear in the verification picker (you can verify with
# one), but must not make primary_enabled_for() non-empty.
diff --git a/django_mfa/adapters/totp.py b/django_mfa/adapters/totp.py
index 6dcac0c..0fe6a09 100644
--- a/django_mfa/adapters/totp.py
+++ b/django_mfa/adapters/totp.py
@@ -2,6 +2,8 @@
import re
import secrets
+from django.utils.translation import gettext_lazy as _
+
from django_mfa import totp as totp_mod
from django_mfa.atomic import update_data
from django_mfa.conf import settings as mfa_settings
@@ -42,7 +44,7 @@ def generate_secret():
class TOTPAdapter(Adapter):
type = Authenticator.Type.TOTP
- verbose_name = "Authenticator app"
+ verbose_name = _("Authenticator app")
def begin_enroll(self, request):
secret = generate_secret()
diff --git a/django_mfa/adapters/webauthn.py b/django_mfa/adapters/webauthn.py
index 1872e23..784a705 100644
--- a/django_mfa/adapters/webauthn.py
+++ b/django_mfa/adapters/webauthn.py
@@ -11,6 +11,7 @@
# every place this deviates from the brief's hypothesised calls.
import json
+from django.utils.translation import gettext_lazy as _
from fido2.server import Fido2Server
from fido2.webauthn import (
AttestedCredentialData,
@@ -68,7 +69,7 @@ def user_entity(user):
class WebAuthnAdapter(Adapter):
type = Authenticator.Type.WEBAUTHN
- verbose_name = "Security key or passkey"
+ verbose_name = _("Security key or passkey")
supports_multiple = True
def _existing_credentials(self, user):
diff --git a/django_mfa/locale/de/LC_MESSAGES/django.po b/django_mfa/locale/de/LC_MESSAGES/django.po
new file mode 100644
index 0000000..0e3d11f
--- /dev/null
+++ b/django_mfa/locale/de/LC_MESSAGES/django.po
@@ -0,0 +1,419 @@
+# German translation for django-mfa.
+# Copyright (C) MicroPyramid
+# This file is distributed under the same licence as the django-mfa package.
+#
+# MACHINE-DRAFTED, NOT REVIEWED. Every entry below is marked "fuzzy", which
+# means gettext ignores it and users see the English source instead. Nothing
+# here reaches a user until a human reviews an entry and removes its fuzzy
+# flag. See docs/translations.md.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: django-mfa\n"
+"Report-Msgid-Bugs-To: https://github.com/MicroPyramid/django-mfa/issues\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: de\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:14
+#: django_mfa/templates/django_mfa/verify_email.html:15
+#, fuzzy
+msgid "%(code_length)s-digit code"
+msgstr "%(code_length)s-stelliger Code"
+
+#: django_mfa/templates/django_mfa/security.html:78
+#, fuzzy
+msgid "%(remaining)s code remaining."
+msgid_plural "%(remaining)s codes remaining."
+msgstr[0] "Noch %(remaining)s Code übrig."
+msgstr[1] "Noch %(remaining)s Codes übrig."
+
+#: django_mfa/templates/django_mfa/email/factor_added_subject.txt:1
+#, fuzzy
+msgid "A new two-factor method was added to your account"
+msgstr "Ihrem Konto wurde eine neue Zwei-Faktor-Methode hinzugefügt"
+
+#: django_mfa/templates/django_mfa/email/factor_added.txt:1
+#, fuzzy
+msgid "A new two-factor method was added to your account: %(factor)s"
+msgstr "Ihrem Konto wurde eine neue Zwei-Faktor-Methode hinzugefügt: %(factor)s"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:1
+#, fuzzy
+msgid "A recovery code was just used to sign in to your account."
+msgstr "Soeben wurde ein Wiederherstellungscode zur Anmeldung bei Ihrem Konto verwendet."
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used_subject.txt:1
+#, fuzzy
+msgid "A recovery code was used to sign in"
+msgstr "Ein Wiederherstellungscode wurde zur Anmeldung verwendet"
+
+#: django_mfa/templates/django_mfa/email/factor_removed_subject.txt:1
+#, fuzzy
+msgid "A two-factor method was removed from your account"
+msgstr "Eine Zwei-Faktor-Methode wurde von Ihrem Konto entfernt"
+
+#: django_mfa/templates/django_mfa/security.html:59
+#, fuzzy
+msgid "Add a method"
+msgstr "Methode hinzufügen"
+
+#: django_mfa/templates/django_mfa/security.html:11
+#, fuzzy
+msgid "Add a method below to continue. Until you do, the rest of the site is unavailable."
+msgstr "Fügen Sie unten eine Methode hinzu, um fortzufahren. Bis dahin ist der Rest der Website nicht verfügbar."
+
+#: django_mfa/templates/django_mfa/security.html:7
+#, fuzzy
+msgid "Add a second step to your sign-in so a stolen password isn't enough on its own."
+msgstr "Fügen Sie Ihrer Anmeldung einen zweiten Schritt hinzu, damit ein gestohlenes Passwort allein nicht ausreicht."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:5
+#, fuzzy
+msgid "Add a security key or passkey"
+msgstr "Sicherheitsschlüssel oder Passkey hinzufügen"
+
+#: django_mfa/templates/django_mfa/security.html:30
+#, fuzzy
+msgid "Added %(created)s"
+msgstr "Hinzugefügt am %(created)s"
+
+#: django_mfa/adapters/totp.py:47
+#: django_mfa/models.py:34
+#, fuzzy
+msgid "Authenticator app"
+msgstr "Authenticator-App"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:31
+#, fuzzy
+msgid "Back"
+msgstr "Zurück"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:11
+#, fuzzy
+msgid "Can't scan it?"
+msgstr "Können Sie ihn nicht scannen?"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:25
+#: django_mfa/templates/django_mfa/enroll_totp.html:31
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:42
+#, fuzzy
+msgid "Cancel"
+msgstr "Abbrechen"
+
+#: django_mfa/templates/django_mfa/picker.html:6
+#, fuzzy
+msgid "Choose how you'd like to confirm your identity."
+msgstr "Wählen Sie, wie Sie Ihre Identität bestätigen möchten."
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:23
+#: django_mfa/templates/django_mfa/recovery_codes.html:41
+#, fuzzy
+msgid "Done"
+msgstr "Fertig"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:22
+#, fuzzy
+msgid "Download"
+msgstr "Herunterladen"
+
+#: django_mfa/adapters/email.py:104
+#: django_mfa/models.py:37
+#, fuzzy
+msgid "Emailed code"
+msgstr "Per E-Mail gesendeter Code"
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:5
+#, fuzzy
+msgid "Enter a recovery code"
+msgstr "Wiederherstellungscode eingeben"
+
+#: django_mfa/templates/django_mfa/verify_email.html:5
+#: django_mfa/templates/django_mfa/verify_totp.html:5
+#, fuzzy
+msgid "Enter your code"
+msgstr "Geben Sie Ihren Code ein"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:5
+#, fuzzy
+msgid "If this wasn't you, change your password and generate a fresh set of codes."
+msgstr "Falls Sie das nicht waren, ändern Sie Ihr Passwort und erzeugen Sie neue Codes."
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled.txt:3
+#, fuzzy
+msgid "If this wasn't you, change your password and set up two-factor authentication again immediately."
+msgstr "Falls Sie das nicht waren, ändern Sie sofort Ihr Passwort und richten Sie die Zwei-Faktor-Authentifizierung erneut ein."
+
+#: django_mfa/templates/django_mfa/email/factor_removed.txt:3
+#, fuzzy
+msgid "If this wasn't you, change your password immediately."
+msgstr "Falls Sie das nicht waren, ändern Sie sofort Ihr Passwort."
+
+#: django_mfa/templates/django_mfa/email/factor_added.txt:3
+#, fuzzy
+msgid "If this wasn't you, remove it and change your password immediately."
+msgstr "Falls Sie das nicht waren, entfernen Sie sie und ändern Sie sofort Ihr Passwort."
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:5
+#, fuzzy
+msgid "If you didn't try to sign in, someone may know your password. Change it."
+msgstr "Wenn Sie sich nicht anmelden wollten, kennt möglicherweise jemand Ihr Passwort. Ändern Sie es."
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:3
+#, fuzzy
+msgid "It expires in %(validity_minutes)s minutes and can be used once."
+msgstr "Er läuft in %(validity_minutes)s Minuten ab und kann einmal verwendet werden."
+
+#: django_mfa/templates/django_mfa/security.html:46
+#, fuzzy
+msgid "Managed by your organization"
+msgstr "Von Ihrer Organisation verwaltet"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:29
+#, fuzzy
+msgid "Name this key"
+msgstr "Diesen Schlüssel benennen"
+
+#: django_mfa/templates/django_mfa/verify_totp.html:6
+#, fuzzy
+msgid "Open your authenticator app and enter the six-digit code it shows."
+msgstr "Öffnen Sie Ihre Authenticator-App und geben Sie den angezeigten sechsstelligen Code ein."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:36
+#, fuzzy
+msgid "Optional. Helps you tell your keys apart later."
+msgstr "Optional. Hilft Ihnen später, Ihre Schlüssel zu unterscheiden."
+
+#: django_mfa/views/verify.py:54
+#, fuzzy
+msgid "Passkey sign-in failed."
+msgstr "Passkey-Anmeldung fehlgeschlagen."
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:14
+#, fuzzy
+msgid "Recovery code"
+msgstr "Wiederherstellungscode"
+
+#: django_mfa/adapters/recovery_codes.py:21
+#: django_mfa/models.py:36
+#: django_mfa/templates/django_mfa/recovery_codes.html:5
+#: django_mfa/templates/django_mfa/security.html:75
+#, fuzzy
+msgid "Recovery codes"
+msgstr "Wiederherstellungscodes"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:41
+#, fuzzy
+msgid "Register"
+msgstr "Registrieren"
+
+#: django_mfa/templates/django_mfa/security.html:42
+#, fuzzy
+msgid "Remove"
+msgstr "Entfernen"
+
+#: django_mfa/templates/django_mfa/security.html:41
+#, fuzzy
+msgid "Remove this method?"
+msgstr "Diese Methode entfernen?"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:8
+#, fuzzy
+msgid "Save these somewhere safe. Each code works once, and this is the only time they'll be shown."
+msgstr "Bewahren Sie diese an einem sicheren Ort auf. Jeder Code funktioniert einmal, und sie werden nur dieses eine Mal angezeigt."
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:6
+#, fuzzy
+msgid "Scan this code with an authenticator app, then enter the six-digit code it shows."
+msgstr "Scannen Sie diesen Code mit einer Authenticator-App und geben Sie dann den angezeigten sechsstelligen Code ein."
+
+#: django_mfa/adapters/webauthn.py:72
+#: django_mfa/models.py:35
+#, fuzzy
+msgid "Security key or passkey"
+msgstr "Sicherheitsschlüssel oder Passkey"
+
+#: django_mfa/templates/django_mfa/verify_webauthn.html:6
+#, fuzzy
+msgid "Select the button below, then follow your browser's prompt to use your security key or passkey."
+msgstr "Wählen Sie die Schaltfläche unten und folgen Sie der Aufforderung Ihres Browsers, um Ihren Sicherheitsschlüssel oder Passkey zu verwenden."
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:5
+#, fuzzy
+msgid "Set up an authenticator app"
+msgstr "Authenticator-App einrichten"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:5
+#, fuzzy
+msgid "Set up email codes"
+msgstr "E-Mail-Codes einrichten"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:20
+#: django_mfa/templates/django_mfa/verify_totp.html:12
+#, fuzzy
+msgid "Six-digit code"
+msgstr "Sechsstelliger Code"
+
+#: django_mfa/templates/django_mfa/security.html:69
+#, fuzzy
+msgid "There is nothing left to add."
+msgstr "Es gibt nichts mehr hinzuzufügen."
+
+#: django_mfa/templates/django_mfa/verify_email.html:30
+#, fuzzy
+msgid "This account has no email code to verify. Use another method, or contact support if you believe this is a mistake."
+msgstr "Für dieses Konto gibt es keinen zu prüfenden E-Mail-Code. Verwenden Sie eine andere Methode oder wenden Sie sich an den Support, wenn Sie glauben, dass dies ein Fehler ist."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:12
+#: django_mfa/templates/django_mfa/verify_webauthn.html:12
+#, fuzzy
+msgid "This browser doesn't support security keys or passkeys. Try another browser, or choose a different method."
+msgstr "Dieser Browser unterstützt keine Sicherheitsschlüssel oder Passkeys. Versuchen Sie einen anderen Browser oder wählen Sie eine andere Methode."
+
+#: django_mfa/templates/django_mfa/email/factor_removed.txt:1
+#, fuzzy
+msgid "This two-factor method was removed from your account: %(factor)s"
+msgstr "Diese Zwei-Faktor-Methode wurde von Ihrem Konto entfernt: %(factor)s"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:24
+#, fuzzy
+msgid "Turn on email codes"
+msgstr "E-Mail-Codes aktivieren"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:30
+#, fuzzy
+msgid "Turn on two-factor authentication"
+msgstr "Zwei-Faktor-Authentifizierung aktivieren"
+
+#: django_mfa/templates/django_mfa/base.html:19
+#: django_mfa/templates/django_mfa/security.html:6
+#, fuzzy
+msgid "Two-factor authentication"
+msgstr "Zwei-Faktor-Authentifizierung"
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled_subject.txt:1
+#, fuzzy
+msgid "Two-factor authentication is off for your account"
+msgstr "Die Zwei-Faktor-Authentifizierung ist für Ihr Konto deaktiviert"
+
+#: django_mfa/templates/django_mfa/security.html:10
+#, fuzzy
+msgid "Two-factor authentication is required for your account."
+msgstr "Für Ihr Konto ist die Zwei-Faktor-Authentifizierung erforderlich."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:6
+#, fuzzy
+msgid "Use a hardware security key, or a passkey built into this device such as Touch ID or Windows Hello."
+msgstr "Verwenden Sie einen Hardware-Sicherheitsschlüssel oder einen in dieses Gerät integrierten Passkey wie Touch ID oder Windows Hello."
+
+#: django_mfa/templates/django_mfa/verify_email.html:26
+#: django_mfa/templates/django_mfa/verify_email.html:32
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:25
+#: django_mfa/templates/django_mfa/verify_totp.html:23
+#: django_mfa/templates/django_mfa/verify_webauthn.html:29
+#, fuzzy
+msgid "Use another method"
+msgstr "Andere Methode verwenden"
+
+#: django_mfa/templates/django_mfa/security.html:79
+#, fuzzy
+msgid "Use one to sign in if you lose access to your other methods."
+msgstr "Verwenden Sie einen davon zur Anmeldung, wenn Sie den Zugriff auf Ihre anderen Methoden verlieren."
+
+#: django_mfa/templates/django_mfa/verify_webauthn.html:5
+#, fuzzy
+msgid "Use your security key"
+msgstr "Verwenden Sie Ihren Sicherheitsschlüssel"
+
+#: django_mfa/templates/django_mfa/verify_email.html:25
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:24
+#: django_mfa/templates/django_mfa/verify_totp.html:22
+#: django_mfa/templates/django_mfa/verify_webauthn.html:28
+#, fuzzy
+msgid "Verify"
+msgstr "Bestätigen"
+
+#: django_mfa/templates/django_mfa/picker.html:5
+#, fuzzy
+msgid "Verify it's you"
+msgstr "Bestätigen Sie, dass Sie es sind"
+
+#: django_mfa/templates/django_mfa/security.html:82
+#, fuzzy
+msgid "View recovery codes"
+msgstr "Wiederherstellungscodes anzeigen"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:8
+#, fuzzy
+msgid "We've emailed a code to %(address)s. Enter it below to turn on email codes."
+msgstr "Wir haben einen Code an %(address)s gesendet. Geben Sie ihn unten ein, um E-Mail-Codes zu aktivieren."
+
+#: django_mfa/templates/django_mfa/verify_email.html:8
+#, fuzzy
+msgid "We've emailed a code to %(address)s. It expires shortly."
+msgstr "Wir haben einen Code an %(address)s gesendet. Er läuft in Kürze ab."
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:7
+#, fuzzy
+msgid "You have %(remaining)s recovery code left. Each one works only once."
+msgid_plural "You have %(remaining)s recovery codes left. Each one works only once."
+msgstr[0] "Sie haben noch %(remaining)s Wiederherstellungscode. Jeder funktioniert nur einmal."
+msgstr[1] "Sie haben noch %(remaining)s Wiederherstellungscodes. Jeder funktioniert nur einmal."
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:3
+#, fuzzy
+msgid "You have %(remaining)s recovery codes left."
+msgstr "Sie haben noch %(remaining)s Wiederherstellungscodes."
+
+#: django_mfa/templates/django_mfa/security.html:53
+#, fuzzy
+msgid "You haven't set up two-factor authentication yet."
+msgstr "Sie haben die Zwei-Faktor-Authentifizierung noch nicht eingerichtet."
+
+#: django_mfa/templates/django_mfa/enroll_email.html:29
+#, fuzzy
+msgid "Your account has no email address, so codes can't be delivered. Add one to your profile first."
+msgstr "Ihr Konto hat keine E-Mail-Adresse, daher können keine Codes zugestellt werden. Fügen Sie zuerst eine zu Ihrem Profil hinzu."
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled.txt:1
+#, fuzzy
+msgid "Your account is no longer protected by two-factor authentication. A password is now all that's needed to sign in."
+msgstr "Ihr Konto ist nicht mehr durch Zwei-Faktor-Authentifizierung geschützt. Zum Anmelden genügt jetzt ein Passwort."
+
+#: django_mfa/views/verify.py:20
+#, fuzzy
+msgid "Your code is expired or invalid."
+msgstr "Ihr Code ist abgelaufen oder ungültig."
+
+#: django_mfa/templates/django_mfa/security.html:17
+#, fuzzy
+msgid "Your methods"
+msgstr "Ihre Methoden"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:38
+#, fuzzy
+msgid "Your recovery codes were generated earlier and can't be shown again. If you've lost them, remove and re-add two-factor authentication to get a fresh set."
+msgstr "Ihre Wiederherstellungscodes wurden bereits erzeugt und können nicht erneut angezeigt werden. Wenn Sie sie verloren haben, entfernen Sie die Zwei-Faktor-Authentifizierung und richten Sie sie erneut ein, um neue Codes zu erhalten."
+
+#: django_mfa/templates/django_mfa/email/otp_code_subject.txt:1
+#, fuzzy
+msgid "Your sign-in code"
+msgstr "Ihr Anmeldecode"
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:1
+#, fuzzy
+msgid "Your sign-in code is %(code)s."
+msgstr "Ihr Anmeldecode lautet %(code)s."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:32
+#, fuzzy
+msgid "e.g. YubiKey, work laptop"
+msgstr "z. B. YubiKey, Arbeitslaptop"
+
+#: django_mfa/templates/django_mfa/security.html:32
+#, fuzzy
+msgid "last used %(used)s"
+msgstr "zuletzt verwendet %(used)s"
diff --git a/django_mfa/locale/django.pot b/django_mfa/locale/django.pot
new file mode 100644
index 0000000..4aa7cbf
--- /dev/null
+++ b/django_mfa/locale/django.pot
@@ -0,0 +1,338 @@
+# Translation template for django-mfa.
+# Copyright (C) MicroPyramid
+# This file is distributed under the same licence as the django-mfa package.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: django-mfa\n"
+"Report-Msgid-Bugs-To: https://github.com/MicroPyramid/django-mfa/issues\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:14
+#: django_mfa/templates/django_mfa/verify_email.html:15
+msgid "%(code_length)s-digit code"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:78
+msgid "%(remaining)s code remaining."
+msgid_plural "%(remaining)s codes remaining."
+msgstr[0] ""
+msgstr[1] ""
+
+#: django_mfa/templates/django_mfa/email/factor_added_subject.txt:1
+msgid "A new two-factor method was added to your account"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/factor_added.txt:1
+msgid "A new two-factor method was added to your account: %(factor)s"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:1
+msgid "A recovery code was just used to sign in to your account."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used_subject.txt:1
+msgid "A recovery code was used to sign in"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/factor_removed_subject.txt:1
+msgid "A two-factor method was removed from your account"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:59
+msgid "Add a method"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:11
+msgid "Add a method below to continue. Until you do, the rest of the site is unavailable."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:7
+msgid "Add a second step to your sign-in so a stolen password isn't enough on its own."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:5
+msgid "Add a security key or passkey"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:30
+msgid "Added %(created)s"
+msgstr ""
+
+#: django_mfa/adapters/totp.py:47
+#: django_mfa/models.py:34
+msgid "Authenticator app"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_email.html:31
+msgid "Back"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:11
+msgid "Can't scan it?"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_email.html:25
+#: django_mfa/templates/django_mfa/enroll_totp.html:31
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:42
+msgid "Cancel"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/picker.html:6
+msgid "Choose how you'd like to confirm your identity."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:23
+#: django_mfa/templates/django_mfa/recovery_codes.html:41
+msgid "Done"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:22
+msgid "Download"
+msgstr ""
+
+#: django_mfa/adapters/email.py:104
+#: django_mfa/models.py:37
+msgid "Emailed code"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:5
+msgid "Enter a recovery code"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/verify_email.html:5
+#: django_mfa/templates/django_mfa/verify_totp.html:5
+msgid "Enter your code"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:5
+msgid "If this wasn't you, change your password and generate a fresh set of codes."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled.txt:3
+msgid "If this wasn't you, change your password and set up two-factor authentication again immediately."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/factor_removed.txt:3
+msgid "If this wasn't you, change your password immediately."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/factor_added.txt:3
+msgid "If this wasn't you, remove it and change your password immediately."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:5
+msgid "If you didn't try to sign in, someone may know your password. Change it."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:3
+msgid "It expires in %(validity_minutes)s minutes and can be used once."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:46
+msgid "Managed by your organization"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:29
+msgid "Name this key"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/verify_totp.html:6
+msgid "Open your authenticator app and enter the six-digit code it shows."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:36
+msgid "Optional. Helps you tell your keys apart later."
+msgstr ""
+
+#: django_mfa/views/verify.py:54
+msgid "Passkey sign-in failed."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:14
+msgid "Recovery code"
+msgstr ""
+
+#: django_mfa/adapters/recovery_codes.py:21
+#: django_mfa/models.py:36
+#: django_mfa/templates/django_mfa/recovery_codes.html:5
+#: django_mfa/templates/django_mfa/security.html:75
+msgid "Recovery codes"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:41
+msgid "Register"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:42
+msgid "Remove"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:41
+msgid "Remove this method?"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:8
+msgid "Save these somewhere safe. Each code works once, and this is the only time they'll be shown."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:6
+msgid "Scan this code with an authenticator app, then enter the six-digit code it shows."
+msgstr ""
+
+#: django_mfa/adapters/webauthn.py:72
+#: django_mfa/models.py:35
+msgid "Security key or passkey"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/verify_webauthn.html:6
+msgid "Select the button below, then follow your browser's prompt to use your security key or passkey."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:5
+msgid "Set up an authenticator app"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_email.html:5
+msgid "Set up email codes"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:20
+#: django_mfa/templates/django_mfa/verify_totp.html:12
+msgid "Six-digit code"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:69
+msgid "There is nothing left to add."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/verify_email.html:30
+msgid "This account has no email code to verify. Use another method, or contact support if you believe this is a mistake."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:12
+#: django_mfa/templates/django_mfa/verify_webauthn.html:12
+msgid "This browser doesn't support security keys or passkeys. Try another browser, or choose a different method."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/factor_removed.txt:1
+msgid "This two-factor method was removed from your account: %(factor)s"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_email.html:24
+msgid "Turn on email codes"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:30
+msgid "Turn on two-factor authentication"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/base.html:19
+#: django_mfa/templates/django_mfa/security.html:6
+msgid "Two-factor authentication"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled_subject.txt:1
+msgid "Two-factor authentication is off for your account"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:10
+msgid "Two-factor authentication is required for your account."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:6
+msgid "Use a hardware security key, or a passkey built into this device such as Touch ID or Windows Hello."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/verify_email.html:26
+#: django_mfa/templates/django_mfa/verify_email.html:32
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:25
+#: django_mfa/templates/django_mfa/verify_totp.html:23
+#: django_mfa/templates/django_mfa/verify_webauthn.html:29
+msgid "Use another method"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:79
+msgid "Use one to sign in if you lose access to your other methods."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/verify_webauthn.html:5
+msgid "Use your security key"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/verify_email.html:25
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:24
+#: django_mfa/templates/django_mfa/verify_totp.html:22
+#: django_mfa/templates/django_mfa/verify_webauthn.html:28
+msgid "Verify"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/picker.html:5
+msgid "Verify it's you"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:82
+msgid "View recovery codes"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_email.html:8
+msgid "We've emailed a code to %(address)s. Enter it below to turn on email codes."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/verify_email.html:8
+msgid "We've emailed a code to %(address)s. It expires shortly."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:7
+msgid "You have %(remaining)s recovery code left. Each one works only once."
+msgid_plural "You have %(remaining)s recovery codes left. Each one works only once."
+msgstr[0] ""
+msgstr[1] ""
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:3
+msgid "You have %(remaining)s recovery codes left."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:53
+msgid "You haven't set up two-factor authentication yet."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_email.html:29
+msgid "Your account has no email address, so codes can't be delivered. Add one to your profile first."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled.txt:1
+msgid "Your account is no longer protected by two-factor authentication. A password is now all that's needed to sign in."
+msgstr ""
+
+#: django_mfa/views/verify.py:20
+msgid "Your code is expired or invalid."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:17
+msgid "Your methods"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:38
+msgid "Your recovery codes were generated earlier and can't be shown again. If you've lost them, remove and re-add two-factor authentication to get a fresh set."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/otp_code_subject.txt:1
+msgid "Your sign-in code"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:1
+msgid "Your sign-in code is %(code)s."
+msgstr ""
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:32
+msgid "e.g. YubiKey, work laptop"
+msgstr ""
+
+#: django_mfa/templates/django_mfa/security.html:32
+msgid "last used %(used)s"
+msgstr ""
diff --git a/django_mfa/locale/es/LC_MESSAGES/django.po b/django_mfa/locale/es/LC_MESSAGES/django.po
new file mode 100644
index 0000000..80c4ae0
--- /dev/null
+++ b/django_mfa/locale/es/LC_MESSAGES/django.po
@@ -0,0 +1,419 @@
+# Spanish translation for django-mfa.
+# Copyright (C) MicroPyramid
+# This file is distributed under the same licence as the django-mfa package.
+#
+# MACHINE-DRAFTED, NOT REVIEWED. Every entry below is marked "fuzzy", which
+# means gettext ignores it and users see the English source instead. Nothing
+# here reaches a user until a human reviews an entry and removes its fuzzy
+# flag. See docs/translations.md.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: django-mfa\n"
+"Report-Msgid-Bugs-To: https://github.com/MicroPyramid/django-mfa/issues\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: es\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:14
+#: django_mfa/templates/django_mfa/verify_email.html:15
+#, fuzzy
+msgid "%(code_length)s-digit code"
+msgstr "Código de %(code_length)s dígitos"
+
+#: django_mfa/templates/django_mfa/security.html:78
+#, fuzzy
+msgid "%(remaining)s code remaining."
+msgid_plural "%(remaining)s codes remaining."
+msgstr[0] "Queda %(remaining)s código."
+msgstr[1] "Quedan %(remaining)s códigos."
+
+#: django_mfa/templates/django_mfa/email/factor_added_subject.txt:1
+#, fuzzy
+msgid "A new two-factor method was added to your account"
+msgstr "Se añadió un nuevo método de doble factor a tu cuenta"
+
+#: django_mfa/templates/django_mfa/email/factor_added.txt:1
+#, fuzzy
+msgid "A new two-factor method was added to your account: %(factor)s"
+msgstr "Se añadió un nuevo método de doble factor a tu cuenta: %(factor)s"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:1
+#, fuzzy
+msgid "A recovery code was just used to sign in to your account."
+msgstr "Se acaba de usar un código de recuperación para iniciar sesión en tu cuenta."
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used_subject.txt:1
+#, fuzzy
+msgid "A recovery code was used to sign in"
+msgstr "Se usó un código de recuperación para iniciar sesión"
+
+#: django_mfa/templates/django_mfa/email/factor_removed_subject.txt:1
+#, fuzzy
+msgid "A two-factor method was removed from your account"
+msgstr "Se eliminó un método de doble factor de tu cuenta"
+
+#: django_mfa/templates/django_mfa/security.html:59
+#, fuzzy
+msgid "Add a method"
+msgstr "Añadir un método"
+
+#: django_mfa/templates/django_mfa/security.html:11
+#, fuzzy
+msgid "Add a method below to continue. Until you do, the rest of the site is unavailable."
+msgstr "Añade un método abajo para continuar. Hasta entonces, el resto del sitio no está disponible."
+
+#: django_mfa/templates/django_mfa/security.html:7
+#, fuzzy
+msgid "Add a second step to your sign-in so a stolen password isn't enough on its own."
+msgstr "Añade un segundo paso al inicio de sesión para que una contraseña robada no baste por sí sola."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:5
+#, fuzzy
+msgid "Add a security key or passkey"
+msgstr "Añadir una llave de seguridad o passkey"
+
+#: django_mfa/templates/django_mfa/security.html:30
+#, fuzzy
+msgid "Added %(created)s"
+msgstr "Añadido el %(created)s"
+
+#: django_mfa/adapters/totp.py:47
+#: django_mfa/models.py:34
+#, fuzzy
+msgid "Authenticator app"
+msgstr "Aplicación de autenticación"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:31
+#, fuzzy
+msgid "Back"
+msgstr "Atrás"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:11
+#, fuzzy
+msgid "Can't scan it?"
+msgstr "¿No puedes escanearlo?"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:25
+#: django_mfa/templates/django_mfa/enroll_totp.html:31
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:42
+#, fuzzy
+msgid "Cancel"
+msgstr "Cancelar"
+
+#: django_mfa/templates/django_mfa/picker.html:6
+#, fuzzy
+msgid "Choose how you'd like to confirm your identity."
+msgstr "Elige cómo quieres confirmar tu identidad."
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:23
+#: django_mfa/templates/django_mfa/recovery_codes.html:41
+#, fuzzy
+msgid "Done"
+msgstr "Listo"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:22
+#, fuzzy
+msgid "Download"
+msgstr "Descargar"
+
+#: django_mfa/adapters/email.py:104
+#: django_mfa/models.py:37
+#, fuzzy
+msgid "Emailed code"
+msgstr "Código por correo electrónico"
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:5
+#, fuzzy
+msgid "Enter a recovery code"
+msgstr "Introduce un código de recuperación"
+
+#: django_mfa/templates/django_mfa/verify_email.html:5
+#: django_mfa/templates/django_mfa/verify_totp.html:5
+#, fuzzy
+msgid "Enter your code"
+msgstr "Introduce tu código"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:5
+#, fuzzy
+msgid "If this wasn't you, change your password and generate a fresh set of codes."
+msgstr "Si no fuiste tú, cambia tu contraseña y genera un nuevo conjunto de códigos."
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled.txt:3
+#, fuzzy
+msgid "If this wasn't you, change your password and set up two-factor authentication again immediately."
+msgstr "Si no fuiste tú, cambia tu contraseña y vuelve a configurar la autenticación de doble factor de inmediato."
+
+#: django_mfa/templates/django_mfa/email/factor_removed.txt:3
+#, fuzzy
+msgid "If this wasn't you, change your password immediately."
+msgstr "Si no fuiste tú, cambia tu contraseña de inmediato."
+
+#: django_mfa/templates/django_mfa/email/factor_added.txt:3
+#, fuzzy
+msgid "If this wasn't you, remove it and change your password immediately."
+msgstr "Si no fuiste tú, elimínalo y cambia tu contraseña de inmediato."
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:5
+#, fuzzy
+msgid "If you didn't try to sign in, someone may know your password. Change it."
+msgstr "Si no intentaste iniciar sesión, puede que alguien conozca tu contraseña. Cámbiala."
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:3
+#, fuzzy
+msgid "It expires in %(validity_minutes)s minutes and can be used once."
+msgstr "Caduca en %(validity_minutes)s minutos y solo se puede usar una vez."
+
+#: django_mfa/templates/django_mfa/security.html:46
+#, fuzzy
+msgid "Managed by your organization"
+msgstr "Gestionado por tu organización"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:29
+#, fuzzy
+msgid "Name this key"
+msgstr "Nombra esta llave"
+
+#: django_mfa/templates/django_mfa/verify_totp.html:6
+#, fuzzy
+msgid "Open your authenticator app and enter the six-digit code it shows."
+msgstr "Abre tu aplicación de autenticación e introduce el código de seis dígitos que muestra."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:36
+#, fuzzy
+msgid "Optional. Helps you tell your keys apart later."
+msgstr "Opcional. Te ayuda a distinguir tus llaves más adelante."
+
+#: django_mfa/views/verify.py:54
+#, fuzzy
+msgid "Passkey sign-in failed."
+msgstr "Error al iniciar sesión con passkey."
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:14
+#, fuzzy
+msgid "Recovery code"
+msgstr "Código de recuperación"
+
+#: django_mfa/adapters/recovery_codes.py:21
+#: django_mfa/models.py:36
+#: django_mfa/templates/django_mfa/recovery_codes.html:5
+#: django_mfa/templates/django_mfa/security.html:75
+#, fuzzy
+msgid "Recovery codes"
+msgstr "Códigos de recuperación"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:41
+#, fuzzy
+msgid "Register"
+msgstr "Registrar"
+
+#: django_mfa/templates/django_mfa/security.html:42
+#, fuzzy
+msgid "Remove"
+msgstr "Eliminar"
+
+#: django_mfa/templates/django_mfa/security.html:41
+#, fuzzy
+msgid "Remove this method?"
+msgstr "¿Eliminar este método?"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:8
+#, fuzzy
+msgid "Save these somewhere safe. Each code works once, and this is the only time they'll be shown."
+msgstr "Guárdalos en un lugar seguro. Cada código funciona una vez y esta es la única vez que se mostrarán."
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:6
+#, fuzzy
+msgid "Scan this code with an authenticator app, then enter the six-digit code it shows."
+msgstr "Escanea este código con una aplicación de autenticación y luego introduce el código de seis dígitos que muestra."
+
+#: django_mfa/adapters/webauthn.py:72
+#: django_mfa/models.py:35
+#, fuzzy
+msgid "Security key or passkey"
+msgstr "Llave de seguridad o passkey"
+
+#: django_mfa/templates/django_mfa/verify_webauthn.html:6
+#, fuzzy
+msgid "Select the button below, then follow your browser's prompt to use your security key or passkey."
+msgstr "Selecciona el botón de abajo y sigue las indicaciones de tu navegador para usar tu llave de seguridad o passkey."
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:5
+#, fuzzy
+msgid "Set up an authenticator app"
+msgstr "Configurar una aplicación de autenticación"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:5
+#, fuzzy
+msgid "Set up email codes"
+msgstr "Configurar códigos por correo electrónico"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:20
+#: django_mfa/templates/django_mfa/verify_totp.html:12
+#, fuzzy
+msgid "Six-digit code"
+msgstr "Código de seis dígitos"
+
+#: django_mfa/templates/django_mfa/security.html:69
+#, fuzzy
+msgid "There is nothing left to add."
+msgstr "No queda nada por añadir."
+
+#: django_mfa/templates/django_mfa/verify_email.html:30
+#, fuzzy
+msgid "This account has no email code to verify. Use another method, or contact support if you believe this is a mistake."
+msgstr "Esta cuenta no tiene ningún código por correo electrónico que verificar. Usa otro método o contacta con soporte si crees que es un error."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:12
+#: django_mfa/templates/django_mfa/verify_webauthn.html:12
+#, fuzzy
+msgid "This browser doesn't support security keys or passkeys. Try another browser, or choose a different method."
+msgstr "Este navegador no admite llaves de seguridad ni passkeys. Prueba con otro navegador o elige un método distinto."
+
+#: django_mfa/templates/django_mfa/email/factor_removed.txt:1
+#, fuzzy
+msgid "This two-factor method was removed from your account: %(factor)s"
+msgstr "Se eliminó este método de doble factor de tu cuenta: %(factor)s"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:24
+#, fuzzy
+msgid "Turn on email codes"
+msgstr "Activar códigos por correo electrónico"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:30
+#, fuzzy
+msgid "Turn on two-factor authentication"
+msgstr "Activar la autenticación de doble factor"
+
+#: django_mfa/templates/django_mfa/base.html:19
+#: django_mfa/templates/django_mfa/security.html:6
+#, fuzzy
+msgid "Two-factor authentication"
+msgstr "Autenticación de doble factor"
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled_subject.txt:1
+#, fuzzy
+msgid "Two-factor authentication is off for your account"
+msgstr "La autenticación de doble factor está desactivada en tu cuenta"
+
+#: django_mfa/templates/django_mfa/security.html:10
+#, fuzzy
+msgid "Two-factor authentication is required for your account."
+msgstr "Tu cuenta requiere autenticación de doble factor."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:6
+#, fuzzy
+msgid "Use a hardware security key, or a passkey built into this device such as Touch ID or Windows Hello."
+msgstr "Usa una llave de seguridad física o una passkey integrada en este dispositivo, como Touch ID o Windows Hello."
+
+#: django_mfa/templates/django_mfa/verify_email.html:26
+#: django_mfa/templates/django_mfa/verify_email.html:32
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:25
+#: django_mfa/templates/django_mfa/verify_totp.html:23
+#: django_mfa/templates/django_mfa/verify_webauthn.html:29
+#, fuzzy
+msgid "Use another method"
+msgstr "Usar otro método"
+
+#: django_mfa/templates/django_mfa/security.html:79
+#, fuzzy
+msgid "Use one to sign in if you lose access to your other methods."
+msgstr "Usa uno para iniciar sesión si pierdes el acceso a tus otros métodos."
+
+#: django_mfa/templates/django_mfa/verify_webauthn.html:5
+#, fuzzy
+msgid "Use your security key"
+msgstr "Usa tu llave de seguridad"
+
+#: django_mfa/templates/django_mfa/verify_email.html:25
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:24
+#: django_mfa/templates/django_mfa/verify_totp.html:22
+#: django_mfa/templates/django_mfa/verify_webauthn.html:28
+#, fuzzy
+msgid "Verify"
+msgstr "Verificar"
+
+#: django_mfa/templates/django_mfa/picker.html:5
+#, fuzzy
+msgid "Verify it's you"
+msgstr "Verifica tu identidad"
+
+#: django_mfa/templates/django_mfa/security.html:82
+#, fuzzy
+msgid "View recovery codes"
+msgstr "Ver códigos de recuperación"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:8
+#, fuzzy
+msgid "We've emailed a code to %(address)s. Enter it below to turn on email codes."
+msgstr "Hemos enviado un código a %(address)s. Introdúcelo abajo para activar los códigos por correo electrónico."
+
+#: django_mfa/templates/django_mfa/verify_email.html:8
+#, fuzzy
+msgid "We've emailed a code to %(address)s. It expires shortly."
+msgstr "Hemos enviado un código a %(address)s. Caduca en breve."
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:7
+#, fuzzy
+msgid "You have %(remaining)s recovery code left. Each one works only once."
+msgid_plural "You have %(remaining)s recovery codes left. Each one works only once."
+msgstr[0] "Te queda %(remaining)s código de recuperación. Cada uno funciona una sola vez."
+msgstr[1] "Te quedan %(remaining)s códigos de recuperación. Cada uno funciona una sola vez."
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:3
+#, fuzzy
+msgid "You have %(remaining)s recovery codes left."
+msgstr "Te quedan %(remaining)s códigos de recuperación."
+
+#: django_mfa/templates/django_mfa/security.html:53
+#, fuzzy
+msgid "You haven't set up two-factor authentication yet."
+msgstr "Aún no has configurado la autenticación de doble factor."
+
+#: django_mfa/templates/django_mfa/enroll_email.html:29
+#, fuzzy
+msgid "Your account has no email address, so codes can't be delivered. Add one to your profile first."
+msgstr "Tu cuenta no tiene dirección de correo electrónico, así que no se pueden entregar los códigos. Añade una a tu perfil primero."
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled.txt:1
+#, fuzzy
+msgid "Your account is no longer protected by two-factor authentication. A password is now all that's needed to sign in."
+msgstr "Tu cuenta ya no está protegida con autenticación de doble factor. Ahora basta una contraseña para iniciar sesión."
+
+#: django_mfa/views/verify.py:20
+#, fuzzy
+msgid "Your code is expired or invalid."
+msgstr "Tu código ha caducado o no es válido."
+
+#: django_mfa/templates/django_mfa/security.html:17
+#, fuzzy
+msgid "Your methods"
+msgstr "Tus métodos"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:38
+#, fuzzy
+msgid "Your recovery codes were generated earlier and can't be shown again. If you've lost them, remove and re-add two-factor authentication to get a fresh set."
+msgstr "Tus códigos de recuperación se generaron antes y no se pueden volver a mostrar. Si los has perdido, elimina y vuelve a añadir la autenticación de doble factor para obtener un conjunto nuevo."
+
+#: django_mfa/templates/django_mfa/email/otp_code_subject.txt:1
+#, fuzzy
+msgid "Your sign-in code"
+msgstr "Tu código de inicio de sesión"
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:1
+#, fuzzy
+msgid "Your sign-in code is %(code)s."
+msgstr "Tu código de inicio de sesión es %(code)s."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:32
+#, fuzzy
+msgid "e.g. YubiKey, work laptop"
+msgstr "p. ej., YubiKey, portátil del trabajo"
+
+#: django_mfa/templates/django_mfa/security.html:32
+#, fuzzy
+msgid "last used %(used)s"
+msgstr "usado por última vez el %(used)s"
diff --git a/django_mfa/locale/fr/LC_MESSAGES/django.po b/django_mfa/locale/fr/LC_MESSAGES/django.po
new file mode 100644
index 0000000..00542ba
--- /dev/null
+++ b/django_mfa/locale/fr/LC_MESSAGES/django.po
@@ -0,0 +1,419 @@
+# French translation for django-mfa.
+# Copyright (C) MicroPyramid
+# This file is distributed under the same licence as the django-mfa package.
+#
+# MACHINE-DRAFTED, NOT REVIEWED. Every entry below is marked "fuzzy", which
+# means gettext ignores it and users see the English source instead. Nothing
+# here reaches a user until a human reviews an entry and removes its fuzzy
+# flag. See docs/translations.md.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: django-mfa\n"
+"Report-Msgid-Bugs-To: https://github.com/MicroPyramid/django-mfa/issues\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: fr\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:14
+#: django_mfa/templates/django_mfa/verify_email.html:15
+#, fuzzy
+msgid "%(code_length)s-digit code"
+msgstr "Code à %(code_length)s chiffres"
+
+#: django_mfa/templates/django_mfa/security.html:78
+#, fuzzy
+msgid "%(remaining)s code remaining."
+msgid_plural "%(remaining)s codes remaining."
+msgstr[0] "Il reste %(remaining)s code."
+msgstr[1] "Il reste %(remaining)s codes."
+
+#: django_mfa/templates/django_mfa/email/factor_added_subject.txt:1
+#, fuzzy
+msgid "A new two-factor method was added to your account"
+msgstr "Une nouvelle méthode à deux facteurs a été ajoutée à votre compte"
+
+#: django_mfa/templates/django_mfa/email/factor_added.txt:1
+#, fuzzy
+msgid "A new two-factor method was added to your account: %(factor)s"
+msgstr "Une nouvelle méthode à deux facteurs a été ajoutée à votre compte : %(factor)s"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:1
+#, fuzzy
+msgid "A recovery code was just used to sign in to your account."
+msgstr "Un code de récupération vient d’être utilisé pour se connecter à votre compte."
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used_subject.txt:1
+#, fuzzy
+msgid "A recovery code was used to sign in"
+msgstr "Un code de récupération a été utilisé pour se connecter"
+
+#: django_mfa/templates/django_mfa/email/factor_removed_subject.txt:1
+#, fuzzy
+msgid "A two-factor method was removed from your account"
+msgstr "Une méthode à deux facteurs a été supprimée de votre compte"
+
+#: django_mfa/templates/django_mfa/security.html:59
+#, fuzzy
+msgid "Add a method"
+msgstr "Ajouter une méthode"
+
+#: django_mfa/templates/django_mfa/security.html:11
+#, fuzzy
+msgid "Add a method below to continue. Until you do, the rest of the site is unavailable."
+msgstr "Ajoutez une méthode ci-dessous pour continuer. En attendant, le reste du site est indisponible."
+
+#: django_mfa/templates/django_mfa/security.html:7
+#, fuzzy
+msgid "Add a second step to your sign-in so a stolen password isn't enough on its own."
+msgstr "Ajoutez une deuxième étape à votre connexion pour qu’un mot de passe volé ne suffise pas à lui seul."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:5
+#, fuzzy
+msgid "Add a security key or passkey"
+msgstr "Ajouter une clé de sécurité ou une passkey"
+
+#: django_mfa/templates/django_mfa/security.html:30
+#, fuzzy
+msgid "Added %(created)s"
+msgstr "Ajouté le %(created)s"
+
+#: django_mfa/adapters/totp.py:47
+#: django_mfa/models.py:34
+#, fuzzy
+msgid "Authenticator app"
+msgstr "Application d’authentification"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:31
+#, fuzzy
+msgid "Back"
+msgstr "Retour"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:11
+#, fuzzy
+msgid "Can't scan it?"
+msgstr "Impossible de le scanner ?"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:25
+#: django_mfa/templates/django_mfa/enroll_totp.html:31
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:42
+#, fuzzy
+msgid "Cancel"
+msgstr "Annuler"
+
+#: django_mfa/templates/django_mfa/picker.html:6
+#, fuzzy
+msgid "Choose how you'd like to confirm your identity."
+msgstr "Choisissez comment confirmer votre identité."
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:23
+#: django_mfa/templates/django_mfa/recovery_codes.html:41
+#, fuzzy
+msgid "Done"
+msgstr "Terminé"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:22
+#, fuzzy
+msgid "Download"
+msgstr "Télécharger"
+
+#: django_mfa/adapters/email.py:104
+#: django_mfa/models.py:37
+#, fuzzy
+msgid "Emailed code"
+msgstr "Code envoyé par e-mail"
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:5
+#, fuzzy
+msgid "Enter a recovery code"
+msgstr "Saisissez un code de récupération"
+
+#: django_mfa/templates/django_mfa/verify_email.html:5
+#: django_mfa/templates/django_mfa/verify_totp.html:5
+#, fuzzy
+msgid "Enter your code"
+msgstr "Saisissez votre code"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:5
+#, fuzzy
+msgid "If this wasn't you, change your password and generate a fresh set of codes."
+msgstr "Si ce n’était pas vous, changez votre mot de passe et générez de nouveaux codes."
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled.txt:3
+#, fuzzy
+msgid "If this wasn't you, change your password and set up two-factor authentication again immediately."
+msgstr "Si ce n’était pas vous, changez immédiatement votre mot de passe et reconfigurez l’authentification à deux facteurs."
+
+#: django_mfa/templates/django_mfa/email/factor_removed.txt:3
+#, fuzzy
+msgid "If this wasn't you, change your password immediately."
+msgstr "Si ce n’était pas vous, changez immédiatement votre mot de passe."
+
+#: django_mfa/templates/django_mfa/email/factor_added.txt:3
+#, fuzzy
+msgid "If this wasn't you, remove it and change your password immediately."
+msgstr "Si ce n’était pas vous, supprimez-la et changez immédiatement votre mot de passe."
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:5
+#, fuzzy
+msgid "If you didn't try to sign in, someone may know your password. Change it."
+msgstr "Si vous n’avez pas tenté de vous connecter, quelqu’un connaît peut-être votre mot de passe. Changez-le."
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:3
+#, fuzzy
+msgid "It expires in %(validity_minutes)s minutes and can be used once."
+msgstr "Il expire dans %(validity_minutes)s minutes et ne peut être utilisé qu’une fois."
+
+#: django_mfa/templates/django_mfa/security.html:46
+#, fuzzy
+msgid "Managed by your organization"
+msgstr "Géré par votre organisation"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:29
+#, fuzzy
+msgid "Name this key"
+msgstr "Nommez cette clé"
+
+#: django_mfa/templates/django_mfa/verify_totp.html:6
+#, fuzzy
+msgid "Open your authenticator app and enter the six-digit code it shows."
+msgstr "Ouvrez votre application d’authentification et saisissez le code à six chiffres affiché."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:36
+#, fuzzy
+msgid "Optional. Helps you tell your keys apart later."
+msgstr "Facultatif. Vous aide à distinguer vos clés plus tard."
+
+#: django_mfa/views/verify.py:54
+#, fuzzy
+msgid "Passkey sign-in failed."
+msgstr "Échec de la connexion par passkey."
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:14
+#, fuzzy
+msgid "Recovery code"
+msgstr "Code de récupération"
+
+#: django_mfa/adapters/recovery_codes.py:21
+#: django_mfa/models.py:36
+#: django_mfa/templates/django_mfa/recovery_codes.html:5
+#: django_mfa/templates/django_mfa/security.html:75
+#, fuzzy
+msgid "Recovery codes"
+msgstr "Codes de récupération"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:41
+#, fuzzy
+msgid "Register"
+msgstr "Enregistrer"
+
+#: django_mfa/templates/django_mfa/security.html:42
+#, fuzzy
+msgid "Remove"
+msgstr "Supprimer"
+
+#: django_mfa/templates/django_mfa/security.html:41
+#, fuzzy
+msgid "Remove this method?"
+msgstr "Supprimer cette méthode ?"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:8
+#, fuzzy
+msgid "Save these somewhere safe. Each code works once, and this is the only time they'll be shown."
+msgstr "Conservez-les en lieu sûr. Chaque code ne fonctionne qu’une fois, et c’est la seule fois qu’ils seront affichés."
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:6
+#, fuzzy
+msgid "Scan this code with an authenticator app, then enter the six-digit code it shows."
+msgstr "Scannez ce code avec une application d’authentification, puis saisissez le code à six chiffres affiché."
+
+#: django_mfa/adapters/webauthn.py:72
+#: django_mfa/models.py:35
+#, fuzzy
+msgid "Security key or passkey"
+msgstr "Clé de sécurité ou passkey"
+
+#: django_mfa/templates/django_mfa/verify_webauthn.html:6
+#, fuzzy
+msgid "Select the button below, then follow your browser's prompt to use your security key or passkey."
+msgstr "Sélectionnez le bouton ci-dessous, puis suivez l’invite de votre navigateur pour utiliser votre clé de sécurité ou votre passkey."
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:5
+#, fuzzy
+msgid "Set up an authenticator app"
+msgstr "Configurer une application d’authentification"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:5
+#, fuzzy
+msgid "Set up email codes"
+msgstr "Configurer les codes par e-mail"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:20
+#: django_mfa/templates/django_mfa/verify_totp.html:12
+#, fuzzy
+msgid "Six-digit code"
+msgstr "Code à six chiffres"
+
+#: django_mfa/templates/django_mfa/security.html:69
+#, fuzzy
+msgid "There is nothing left to add."
+msgstr "Il n’y a plus rien à ajouter."
+
+#: django_mfa/templates/django_mfa/verify_email.html:30
+#, fuzzy
+msgid "This account has no email code to verify. Use another method, or contact support if you believe this is a mistake."
+msgstr "Ce compte n’a aucun code par e-mail à vérifier. Utilisez une autre méthode ou contactez l’assistance si vous pensez qu’il s’agit d’une erreur."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:12
+#: django_mfa/templates/django_mfa/verify_webauthn.html:12
+#, fuzzy
+msgid "This browser doesn't support security keys or passkeys. Try another browser, or choose a different method."
+msgstr "Ce navigateur ne prend pas en charge les clés de sécurité ni les passkeys. Essayez un autre navigateur ou choisissez une autre méthode."
+
+#: django_mfa/templates/django_mfa/email/factor_removed.txt:1
+#, fuzzy
+msgid "This two-factor method was removed from your account: %(factor)s"
+msgstr "Cette méthode à deux facteurs a été supprimée de votre compte : %(factor)s"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:24
+#, fuzzy
+msgid "Turn on email codes"
+msgstr "Activer les codes par e-mail"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:30
+#, fuzzy
+msgid "Turn on two-factor authentication"
+msgstr "Activer l’authentification à deux facteurs"
+
+#: django_mfa/templates/django_mfa/base.html:19
+#: django_mfa/templates/django_mfa/security.html:6
+#, fuzzy
+msgid "Two-factor authentication"
+msgstr "Authentification à deux facteurs"
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled_subject.txt:1
+#, fuzzy
+msgid "Two-factor authentication is off for your account"
+msgstr "L’authentification à deux facteurs est désactivée pour votre compte"
+
+#: django_mfa/templates/django_mfa/security.html:10
+#, fuzzy
+msgid "Two-factor authentication is required for your account."
+msgstr "L’authentification à deux facteurs est requise pour votre compte."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:6
+#, fuzzy
+msgid "Use a hardware security key, or a passkey built into this device such as Touch ID or Windows Hello."
+msgstr "Utilisez une clé de sécurité matérielle ou une passkey intégrée à cet appareil, comme Touch ID ou Windows Hello."
+
+#: django_mfa/templates/django_mfa/verify_email.html:26
+#: django_mfa/templates/django_mfa/verify_email.html:32
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:25
+#: django_mfa/templates/django_mfa/verify_totp.html:23
+#: django_mfa/templates/django_mfa/verify_webauthn.html:29
+#, fuzzy
+msgid "Use another method"
+msgstr "Utiliser une autre méthode"
+
+#: django_mfa/templates/django_mfa/security.html:79
+#, fuzzy
+msgid "Use one to sign in if you lose access to your other methods."
+msgstr "Utilisez-en un pour vous connecter si vous perdez l’accès à vos autres méthodes."
+
+#: django_mfa/templates/django_mfa/verify_webauthn.html:5
+#, fuzzy
+msgid "Use your security key"
+msgstr "Utilisez votre clé de sécurité"
+
+#: django_mfa/templates/django_mfa/verify_email.html:25
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:24
+#: django_mfa/templates/django_mfa/verify_totp.html:22
+#: django_mfa/templates/django_mfa/verify_webauthn.html:28
+#, fuzzy
+msgid "Verify"
+msgstr "Vérifier"
+
+#: django_mfa/templates/django_mfa/picker.html:5
+#, fuzzy
+msgid "Verify it's you"
+msgstr "Confirmez votre identité"
+
+#: django_mfa/templates/django_mfa/security.html:82
+#, fuzzy
+msgid "View recovery codes"
+msgstr "Afficher les codes de récupération"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:8
+#, fuzzy
+msgid "We've emailed a code to %(address)s. Enter it below to turn on email codes."
+msgstr "Nous avons envoyé un code à %(address)s. Saisissez-le ci-dessous pour activer les codes par e-mail."
+
+#: django_mfa/templates/django_mfa/verify_email.html:8
+#, fuzzy
+msgid "We've emailed a code to %(address)s. It expires shortly."
+msgstr "Nous avons envoyé un code à %(address)s. Il expire bientôt."
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:7
+#, fuzzy
+msgid "You have %(remaining)s recovery code left. Each one works only once."
+msgid_plural "You have %(remaining)s recovery codes left. Each one works only once."
+msgstr[0] "Il vous reste %(remaining)s code de récupération. Chacun ne fonctionne qu’une seule fois."
+msgstr[1] "Il vous reste %(remaining)s codes de récupération. Chacun ne fonctionne qu’une seule fois."
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:3
+#, fuzzy
+msgid "You have %(remaining)s recovery codes left."
+msgstr "Il vous reste %(remaining)s codes de récupération."
+
+#: django_mfa/templates/django_mfa/security.html:53
+#, fuzzy
+msgid "You haven't set up two-factor authentication yet."
+msgstr "Vous n’avez pas encore configuré l’authentification à deux facteurs."
+
+#: django_mfa/templates/django_mfa/enroll_email.html:29
+#, fuzzy
+msgid "Your account has no email address, so codes can't be delivered. Add one to your profile first."
+msgstr "Votre compte n’a pas d’adresse e-mail, les codes ne peuvent donc pas être envoyés. Ajoutez-en une à votre profil d’abord."
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled.txt:1
+#, fuzzy
+msgid "Your account is no longer protected by two-factor authentication. A password is now all that's needed to sign in."
+msgstr "Votre compte n’est plus protégé par l’authentification à deux facteurs. Un mot de passe suffit désormais pour se connecter."
+
+#: django_mfa/views/verify.py:20
+#, fuzzy
+msgid "Your code is expired or invalid."
+msgstr "Votre code est expiré ou invalide."
+
+#: django_mfa/templates/django_mfa/security.html:17
+#, fuzzy
+msgid "Your methods"
+msgstr "Vos méthodes"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:38
+#, fuzzy
+msgid "Your recovery codes were generated earlier and can't be shown again. If you've lost them, remove and re-add two-factor authentication to get a fresh set."
+msgstr "Vos codes de récupération ont été générés précédemment et ne peuvent plus être affichés. Si vous les avez perdus, supprimez puis reconfigurez l’authentification à deux facteurs pour en obtenir de nouveaux."
+
+#: django_mfa/templates/django_mfa/email/otp_code_subject.txt:1
+#, fuzzy
+msgid "Your sign-in code"
+msgstr "Votre code de connexion"
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:1
+#, fuzzy
+msgid "Your sign-in code is %(code)s."
+msgstr "Votre code de connexion est %(code)s."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:32
+#, fuzzy
+msgid "e.g. YubiKey, work laptop"
+msgstr "p. ex. YubiKey, ordinateur portable professionnel"
+
+#: django_mfa/templates/django_mfa/security.html:32
+#, fuzzy
+msgid "last used %(used)s"
+msgstr "dernière utilisation %(used)s"
diff --git a/django_mfa/locale/ja/LC_MESSAGES/django.po b/django_mfa/locale/ja/LC_MESSAGES/django.po
new file mode 100644
index 0000000..5cb3341
--- /dev/null
+++ b/django_mfa/locale/ja/LC_MESSAGES/django.po
@@ -0,0 +1,417 @@
+# Japanese translation for django-mfa.
+# Copyright (C) MicroPyramid
+# This file is distributed under the same licence as the django-mfa package.
+#
+# MACHINE-DRAFTED, NOT REVIEWED. Every entry below is marked "fuzzy", which
+# means gettext ignores it and users see the English source instead. Nothing
+# here reaches a user until a human reviews an entry and removes its fuzzy
+# flag. See docs/translations.md.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: django-mfa\n"
+"Report-Msgid-Bugs-To: https://github.com/MicroPyramid/django-mfa/issues\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: ja\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:14
+#: django_mfa/templates/django_mfa/verify_email.html:15
+#, fuzzy
+msgid "%(code_length)s-digit code"
+msgstr "%(code_length)s桁のコード"
+
+#: django_mfa/templates/django_mfa/security.html:78
+#, fuzzy
+msgid "%(remaining)s code remaining."
+msgid_plural "%(remaining)s codes remaining."
+msgstr[0] "残り %(remaining)s 個のコードがあります。"
+
+#: django_mfa/templates/django_mfa/email/factor_added_subject.txt:1
+#, fuzzy
+msgid "A new two-factor method was added to your account"
+msgstr "アカウントに新しい二要素認証の方法が追加されました"
+
+#: django_mfa/templates/django_mfa/email/factor_added.txt:1
+#, fuzzy
+msgid "A new two-factor method was added to your account: %(factor)s"
+msgstr "アカウントに新しい二要素認証の方法が追加されました: %(factor)s"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:1
+#, fuzzy
+msgid "A recovery code was just used to sign in to your account."
+msgstr "アカウントへのサインインに復旧コードが使用されました。"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used_subject.txt:1
+#, fuzzy
+msgid "A recovery code was used to sign in"
+msgstr "復旧コードでサインインしました"
+
+#: django_mfa/templates/django_mfa/email/factor_removed_subject.txt:1
+#, fuzzy
+msgid "A two-factor method was removed from your account"
+msgstr "アカウントから二要素認証の方法が削除されました"
+
+#: django_mfa/templates/django_mfa/security.html:59
+#, fuzzy
+msgid "Add a method"
+msgstr "方法を追加"
+
+#: django_mfa/templates/django_mfa/security.html:11
+#, fuzzy
+msgid "Add a method below to continue. Until you do, the rest of the site is unavailable."
+msgstr "続行するには、以下から方法を追加してください。追加するまでサイトの他の部分は利用できません。"
+
+#: django_mfa/templates/django_mfa/security.html:7
+#, fuzzy
+msgid "Add a second step to your sign-in so a stolen password isn't enough on its own."
+msgstr "サインインに 2 段階目を追加すると、パスワードが盗まれただけでは不十分になります。"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:5
+#, fuzzy
+msgid "Add a security key or passkey"
+msgstr "セキュリティキーまたはパスキーを追加"
+
+#: django_mfa/templates/django_mfa/security.html:30
+#, fuzzy
+msgid "Added %(created)s"
+msgstr "%(created)s に追加"
+
+#: django_mfa/adapters/totp.py:47
+#: django_mfa/models.py:34
+#, fuzzy
+msgid "Authenticator app"
+msgstr "認証アプリ"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:31
+#, fuzzy
+msgid "Back"
+msgstr "戻る"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:11
+#, fuzzy
+msgid "Can't scan it?"
+msgstr "スキャンできませんか?"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:25
+#: django_mfa/templates/django_mfa/enroll_totp.html:31
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:42
+#, fuzzy
+msgid "Cancel"
+msgstr "キャンセル"
+
+#: django_mfa/templates/django_mfa/picker.html:6
+#, fuzzy
+msgid "Choose how you'd like to confirm your identity."
+msgstr "本人確認の方法を選択してください。"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:23
+#: django_mfa/templates/django_mfa/recovery_codes.html:41
+#, fuzzy
+msgid "Done"
+msgstr "完了"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:22
+#, fuzzy
+msgid "Download"
+msgstr "ダウンロード"
+
+#: django_mfa/adapters/email.py:104
+#: django_mfa/models.py:37
+#, fuzzy
+msgid "Emailed code"
+msgstr "メールで届くコード"
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:5
+#, fuzzy
+msgid "Enter a recovery code"
+msgstr "復旧コードを入力"
+
+#: django_mfa/templates/django_mfa/verify_email.html:5
+#: django_mfa/templates/django_mfa/verify_totp.html:5
+#, fuzzy
+msgid "Enter your code"
+msgstr "コードを入力"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:5
+#, fuzzy
+msgid "If this wasn't you, change your password and generate a fresh set of codes."
+msgstr "心当たりがない場合は、パスワードを変更し、新しいコードを生成してください。"
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled.txt:3
+#, fuzzy
+msgid "If this wasn't you, change your password and set up two-factor authentication again immediately."
+msgstr "心当たりがない場合は、直ちにパスワードを変更し、二要素認証を再設定してください。"
+
+#: django_mfa/templates/django_mfa/email/factor_removed.txt:3
+#, fuzzy
+msgid "If this wasn't you, change your password immediately."
+msgstr "心当たりがない場合は、直ちにパスワードを変更してください。"
+
+#: django_mfa/templates/django_mfa/email/factor_added.txt:3
+#, fuzzy
+msgid "If this wasn't you, remove it and change your password immediately."
+msgstr "心当たりがない場合は、それを削除し、直ちにパスワードを変更してください。"
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:5
+#, fuzzy
+msgid "If you didn't try to sign in, someone may know your password. Change it."
+msgstr "サインインを試みていない場合、誰かがパスワードを知っている可能性があります。変更してください。"
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:3
+#, fuzzy
+msgid "It expires in %(validity_minutes)s minutes and can be used once."
+msgstr "%(validity_minutes)s 分で期限切れになり、一度だけ使用できます。"
+
+#: django_mfa/templates/django_mfa/security.html:46
+#, fuzzy
+msgid "Managed by your organization"
+msgstr "組織によって管理されています"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:29
+#, fuzzy
+msgid "Name this key"
+msgstr "このキーに名前を付ける"
+
+#: django_mfa/templates/django_mfa/verify_totp.html:6
+#, fuzzy
+msgid "Open your authenticator app and enter the six-digit code it shows."
+msgstr "認証アプリを開き、表示された 6 桁のコードを入力してください。"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:36
+#, fuzzy
+msgid "Optional. Helps you tell your keys apart later."
+msgstr "任意です。後でキーを見分けるのに役立ちます。"
+
+#: django_mfa/views/verify.py:54
+#, fuzzy
+msgid "Passkey sign-in failed."
+msgstr "パスキーでのサインインに失敗しました。"
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:14
+#, fuzzy
+msgid "Recovery code"
+msgstr "復旧コード"
+
+#: django_mfa/adapters/recovery_codes.py:21
+#: django_mfa/models.py:36
+#: django_mfa/templates/django_mfa/recovery_codes.html:5
+#: django_mfa/templates/django_mfa/security.html:75
+#, fuzzy
+msgid "Recovery codes"
+msgstr "復旧コード"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:41
+#, fuzzy
+msgid "Register"
+msgstr "登録"
+
+#: django_mfa/templates/django_mfa/security.html:42
+#, fuzzy
+msgid "Remove"
+msgstr "削除"
+
+#: django_mfa/templates/django_mfa/security.html:41
+#, fuzzy
+msgid "Remove this method?"
+msgstr "この方法を削除しますか?"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:8
+#, fuzzy
+msgid "Save these somewhere safe. Each code works once, and this is the only time they'll be shown."
+msgstr "安全な場所に保管してください。各コードは一度だけ使用でき、表示されるのはこの一度きりです。"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:6
+#, fuzzy
+msgid "Scan this code with an authenticator app, then enter the six-digit code it shows."
+msgstr "このコードを認証アプリでスキャンし、表示された 6 桁のコードを入力してください。"
+
+#: django_mfa/adapters/webauthn.py:72
+#: django_mfa/models.py:35
+#, fuzzy
+msgid "Security key or passkey"
+msgstr "セキュリティキーまたはパスキー"
+
+#: django_mfa/templates/django_mfa/verify_webauthn.html:6
+#, fuzzy
+msgid "Select the button below, then follow your browser's prompt to use your security key or passkey."
+msgstr "下のボタンを選択し、ブラウザの指示に従ってセキュリティキーまたはパスキーを使用してください。"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:5
+#, fuzzy
+msgid "Set up an authenticator app"
+msgstr "認証アプリを設定"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:5
+#, fuzzy
+msgid "Set up email codes"
+msgstr "メールコードを設定"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:20
+#: django_mfa/templates/django_mfa/verify_totp.html:12
+#, fuzzy
+msgid "Six-digit code"
+msgstr "6 桁のコード"
+
+#: django_mfa/templates/django_mfa/security.html:69
+#, fuzzy
+msgid "There is nothing left to add."
+msgstr "追加できるものはありません。"
+
+#: django_mfa/templates/django_mfa/verify_email.html:30
+#, fuzzy
+msgid "This account has no email code to verify. Use another method, or contact support if you believe this is a mistake."
+msgstr "このアカウントには確認できるメールコードがありません。別の方法を使用するか、誤りだと思われる場合はサポートにお問い合わせください。"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:12
+#: django_mfa/templates/django_mfa/verify_webauthn.html:12
+#, fuzzy
+msgid "This browser doesn't support security keys or passkeys. Try another browser, or choose a different method."
+msgstr "このブラウザはセキュリティキーやパスキーに対応していません。別のブラウザを試すか、他の方法を選択してください。"
+
+#: django_mfa/templates/django_mfa/email/factor_removed.txt:1
+#, fuzzy
+msgid "This two-factor method was removed from your account: %(factor)s"
+msgstr "この二要素認証の方法がアカウントから削除されました: %(factor)s"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:24
+#, fuzzy
+msgid "Turn on email codes"
+msgstr "メールコードを有効にする"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:30
+#, fuzzy
+msgid "Turn on two-factor authentication"
+msgstr "二要素認証を有効にする"
+
+#: django_mfa/templates/django_mfa/base.html:19
+#: django_mfa/templates/django_mfa/security.html:6
+#, fuzzy
+msgid "Two-factor authentication"
+msgstr "二要素認証"
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled_subject.txt:1
+#, fuzzy
+msgid "Two-factor authentication is off for your account"
+msgstr "アカウントの二要素認証が無効になっています"
+
+#: django_mfa/templates/django_mfa/security.html:10
+#, fuzzy
+msgid "Two-factor authentication is required for your account."
+msgstr "このアカウントには二要素認証が必要です。"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:6
+#, fuzzy
+msgid "Use a hardware security key, or a passkey built into this device such as Touch ID or Windows Hello."
+msgstr "ハードウェアセキュリティキー、または Touch ID や Windows Hello などこの端末に組み込まれたパスキーを使用してください。"
+
+#: django_mfa/templates/django_mfa/verify_email.html:26
+#: django_mfa/templates/django_mfa/verify_email.html:32
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:25
+#: django_mfa/templates/django_mfa/verify_totp.html:23
+#: django_mfa/templates/django_mfa/verify_webauthn.html:29
+#, fuzzy
+msgid "Use another method"
+msgstr "別の方法を使う"
+
+#: django_mfa/templates/django_mfa/security.html:79
+#, fuzzy
+msgid "Use one to sign in if you lose access to your other methods."
+msgstr "他の方法が使えなくなった場合、いずれか 1 つを使ってサインインできます。"
+
+#: django_mfa/templates/django_mfa/verify_webauthn.html:5
+#, fuzzy
+msgid "Use your security key"
+msgstr "セキュリティキーを使用"
+
+#: django_mfa/templates/django_mfa/verify_email.html:25
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:24
+#: django_mfa/templates/django_mfa/verify_totp.html:22
+#: django_mfa/templates/django_mfa/verify_webauthn.html:28
+#, fuzzy
+msgid "Verify"
+msgstr "確認"
+
+#: django_mfa/templates/django_mfa/picker.html:5
+#, fuzzy
+msgid "Verify it's you"
+msgstr "本人確認"
+
+#: django_mfa/templates/django_mfa/security.html:82
+#, fuzzy
+msgid "View recovery codes"
+msgstr "復旧コードを表示"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:8
+#, fuzzy
+msgid "We've emailed a code to %(address)s. Enter it below to turn on email codes."
+msgstr "%(address)s にコードを送信しました。以下に入力してメールコードを有効にしてください。"
+
+#: django_mfa/templates/django_mfa/verify_email.html:8
+#, fuzzy
+msgid "We've emailed a code to %(address)s. It expires shortly."
+msgstr "%(address)s にコードを送信しました。まもなく期限切れになります。"
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:7
+#, fuzzy
+msgid "You have %(remaining)s recovery code left. Each one works only once."
+msgid_plural "You have %(remaining)s recovery codes left. Each one works only once."
+msgstr[0] "復旧コードが残り %(remaining)s 個あります。各コードは一度だけ使用できます。"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:3
+#, fuzzy
+msgid "You have %(remaining)s recovery codes left."
+msgstr "復旧コードが残り %(remaining)s 個あります。"
+
+#: django_mfa/templates/django_mfa/security.html:53
+#, fuzzy
+msgid "You haven't set up two-factor authentication yet."
+msgstr "まだ二要素認証を設定していません。"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:29
+#, fuzzy
+msgid "Your account has no email address, so codes can't be delivered. Add one to your profile first."
+msgstr "アカウントにメールアドレスが登録されていないため、コードを配信できません。先にプロフィールに追加してください。"
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled.txt:1
+#, fuzzy
+msgid "Your account is no longer protected by two-factor authentication. A password is now all that's needed to sign in."
+msgstr "アカウントは二要素認証で保護されなくなりました。今はパスワードだけでサインインできます。"
+
+#: django_mfa/views/verify.py:20
+#, fuzzy
+msgid "Your code is expired or invalid."
+msgstr "コードの有効期限が切れているか、無効です。"
+
+#: django_mfa/templates/django_mfa/security.html:17
+#, fuzzy
+msgid "Your methods"
+msgstr "あなたの方法"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:38
+#, fuzzy
+msgid "Your recovery codes were generated earlier and can't be shown again. If you've lost them, remove and re-add two-factor authentication to get a fresh set."
+msgstr "復旧コードは以前に生成されたもので、再表示はできません。紛失した場合は、二要素認証を削除して再度追加すると新しいコードを取得できます。"
+
+#: django_mfa/templates/django_mfa/email/otp_code_subject.txt:1
+#, fuzzy
+msgid "Your sign-in code"
+msgstr "サインインコード"
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:1
+#, fuzzy
+msgid "Your sign-in code is %(code)s."
+msgstr "サインインコードは %(code)s です。"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:32
+#, fuzzy
+msgid "e.g. YubiKey, work laptop"
+msgstr "例: YubiKey、仕事用ノート PC"
+
+#: django_mfa/templates/django_mfa/security.html:32
+#, fuzzy
+msgid "last used %(used)s"
+msgstr "最終使用 %(used)s"
diff --git a/django_mfa/locale/pt_BR/LC_MESSAGES/django.po b/django_mfa/locale/pt_BR/LC_MESSAGES/django.po
new file mode 100644
index 0000000..14d4cad
--- /dev/null
+++ b/django_mfa/locale/pt_BR/LC_MESSAGES/django.po
@@ -0,0 +1,419 @@
+# Brazilian Portuguese translation for django-mfa.
+# Copyright (C) MicroPyramid
+# This file is distributed under the same licence as the django-mfa package.
+#
+# MACHINE-DRAFTED, NOT REVIEWED. Every entry below is marked "fuzzy", which
+# means gettext ignores it and users see the English source instead. Nothing
+# here reaches a user until a human reviews an entry and removes its fuzzy
+# flag. See docs/translations.md.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: django-mfa\n"
+"Report-Msgid-Bugs-To: https://github.com/MicroPyramid/django-mfa/issues\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: pt_BR\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:14
+#: django_mfa/templates/django_mfa/verify_email.html:15
+#, fuzzy
+msgid "%(code_length)s-digit code"
+msgstr "Código de %(code_length)s dígitos"
+
+#: django_mfa/templates/django_mfa/security.html:78
+#, fuzzy
+msgid "%(remaining)s code remaining."
+msgid_plural "%(remaining)s codes remaining."
+msgstr[0] "Resta %(remaining)s código."
+msgstr[1] "Restam %(remaining)s códigos."
+
+#: django_mfa/templates/django_mfa/email/factor_added_subject.txt:1
+#, fuzzy
+msgid "A new two-factor method was added to your account"
+msgstr "Um novo método de dois fatores foi adicionado à sua conta"
+
+#: django_mfa/templates/django_mfa/email/factor_added.txt:1
+#, fuzzy
+msgid "A new two-factor method was added to your account: %(factor)s"
+msgstr "Um novo método de dois fatores foi adicionado à sua conta: %(factor)s"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:1
+#, fuzzy
+msgid "A recovery code was just used to sign in to your account."
+msgstr "Um código de recuperação acabou de ser usado para entrar na sua conta."
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used_subject.txt:1
+#, fuzzy
+msgid "A recovery code was used to sign in"
+msgstr "Um código de recuperação foi usado para entrar"
+
+#: django_mfa/templates/django_mfa/email/factor_removed_subject.txt:1
+#, fuzzy
+msgid "A two-factor method was removed from your account"
+msgstr "Um método de dois fatores foi removido da sua conta"
+
+#: django_mfa/templates/django_mfa/security.html:59
+#, fuzzy
+msgid "Add a method"
+msgstr "Adicionar um método"
+
+#: django_mfa/templates/django_mfa/security.html:11
+#, fuzzy
+msgid "Add a method below to continue. Until you do, the rest of the site is unavailable."
+msgstr "Adicione um método abaixo para continuar. Até lá, o restante do site fica indisponível."
+
+#: django_mfa/templates/django_mfa/security.html:7
+#, fuzzy
+msgid "Add a second step to your sign-in so a stolen password isn't enough on its own."
+msgstr "Adicione uma segunda etapa ao seu login para que uma senha roubada não baste sozinha."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:5
+#, fuzzy
+msgid "Add a security key or passkey"
+msgstr "Adicionar uma chave de segurança ou passkey"
+
+#: django_mfa/templates/django_mfa/security.html:30
+#, fuzzy
+msgid "Added %(created)s"
+msgstr "Adicionado em %(created)s"
+
+#: django_mfa/adapters/totp.py:47
+#: django_mfa/models.py:34
+#, fuzzy
+msgid "Authenticator app"
+msgstr "Aplicativo autenticador"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:31
+#, fuzzy
+msgid "Back"
+msgstr "Voltar"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:11
+#, fuzzy
+msgid "Can't scan it?"
+msgstr "Não consegue escanear?"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:25
+#: django_mfa/templates/django_mfa/enroll_totp.html:31
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:42
+#, fuzzy
+msgid "Cancel"
+msgstr "Cancelar"
+
+#: django_mfa/templates/django_mfa/picker.html:6
+#, fuzzy
+msgid "Choose how you'd like to confirm your identity."
+msgstr "Escolha como deseja confirmar sua identidade."
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:23
+#: django_mfa/templates/django_mfa/recovery_codes.html:41
+#, fuzzy
+msgid "Done"
+msgstr "Concluído"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:22
+#, fuzzy
+msgid "Download"
+msgstr "Baixar"
+
+#: django_mfa/adapters/email.py:104
+#: django_mfa/models.py:37
+#, fuzzy
+msgid "Emailed code"
+msgstr "Código por e-mail"
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:5
+#, fuzzy
+msgid "Enter a recovery code"
+msgstr "Digite um código de recuperação"
+
+#: django_mfa/templates/django_mfa/verify_email.html:5
+#: django_mfa/templates/django_mfa/verify_totp.html:5
+#, fuzzy
+msgid "Enter your code"
+msgstr "Digite seu código"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:5
+#, fuzzy
+msgid "If this wasn't you, change your password and generate a fresh set of codes."
+msgstr "Se não foi você, troque sua senha e gere um novo conjunto de códigos."
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled.txt:3
+#, fuzzy
+msgid "If this wasn't you, change your password and set up two-factor authentication again immediately."
+msgstr "Se não foi você, troque sua senha e configure a autenticação de dois fatores novamente imediatamente."
+
+#: django_mfa/templates/django_mfa/email/factor_removed.txt:3
+#, fuzzy
+msgid "If this wasn't you, change your password immediately."
+msgstr "Se não foi você, troque sua senha imediatamente."
+
+#: django_mfa/templates/django_mfa/email/factor_added.txt:3
+#, fuzzy
+msgid "If this wasn't you, remove it and change your password immediately."
+msgstr "Se não foi você, remova-o e troque sua senha imediatamente."
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:5
+#, fuzzy
+msgid "If you didn't try to sign in, someone may know your password. Change it."
+msgstr "Se você não tentou entrar, alguém pode saber sua senha. Troque-a."
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:3
+#, fuzzy
+msgid "It expires in %(validity_minutes)s minutes and can be used once."
+msgstr "Ele expira em %(validity_minutes)s minutos e pode ser usado uma vez."
+
+#: django_mfa/templates/django_mfa/security.html:46
+#, fuzzy
+msgid "Managed by your organization"
+msgstr "Gerenciado pela sua organização"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:29
+#, fuzzy
+msgid "Name this key"
+msgstr "Dê um nome a esta chave"
+
+#: django_mfa/templates/django_mfa/verify_totp.html:6
+#, fuzzy
+msgid "Open your authenticator app and enter the six-digit code it shows."
+msgstr "Abra seu aplicativo autenticador e digite o código de seis dígitos exibido."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:36
+#, fuzzy
+msgid "Optional. Helps you tell your keys apart later."
+msgstr "Opcional. Ajuda você a diferenciar suas chaves depois."
+
+#: django_mfa/views/verify.py:54
+#, fuzzy
+msgid "Passkey sign-in failed."
+msgstr "Falha ao entrar com passkey."
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:14
+#, fuzzy
+msgid "Recovery code"
+msgstr "Código de recuperação"
+
+#: django_mfa/adapters/recovery_codes.py:21
+#: django_mfa/models.py:36
+#: django_mfa/templates/django_mfa/recovery_codes.html:5
+#: django_mfa/templates/django_mfa/security.html:75
+#, fuzzy
+msgid "Recovery codes"
+msgstr "Códigos de recuperação"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:41
+#, fuzzy
+msgid "Register"
+msgstr "Registrar"
+
+#: django_mfa/templates/django_mfa/security.html:42
+#, fuzzy
+msgid "Remove"
+msgstr "Remover"
+
+#: django_mfa/templates/django_mfa/security.html:41
+#, fuzzy
+msgid "Remove this method?"
+msgstr "Remover este método?"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:8
+#, fuzzy
+msgid "Save these somewhere safe. Each code works once, and this is the only time they'll be shown."
+msgstr "Guarde-os em um lugar seguro. Cada código funciona uma vez, e esta é a única vez que serão exibidos."
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:6
+#, fuzzy
+msgid "Scan this code with an authenticator app, then enter the six-digit code it shows."
+msgstr "Escaneie este código com um aplicativo autenticador e digite o código de seis dígitos exibido."
+
+#: django_mfa/adapters/webauthn.py:72
+#: django_mfa/models.py:35
+#, fuzzy
+msgid "Security key or passkey"
+msgstr "Chave de segurança ou passkey"
+
+#: django_mfa/templates/django_mfa/verify_webauthn.html:6
+#, fuzzy
+msgid "Select the button below, then follow your browser's prompt to use your security key or passkey."
+msgstr "Selecione o botão abaixo e siga as instruções do navegador para usar sua chave de segurança ou passkey."
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:5
+#, fuzzy
+msgid "Set up an authenticator app"
+msgstr "Configurar um aplicativo autenticador"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:5
+#, fuzzy
+msgid "Set up email codes"
+msgstr "Configurar códigos por e-mail"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:20
+#: django_mfa/templates/django_mfa/verify_totp.html:12
+#, fuzzy
+msgid "Six-digit code"
+msgstr "Código de seis dígitos"
+
+#: django_mfa/templates/django_mfa/security.html:69
+#, fuzzy
+msgid "There is nothing left to add."
+msgstr "Não há mais nada a adicionar."
+
+#: django_mfa/templates/django_mfa/verify_email.html:30
+#, fuzzy
+msgid "This account has no email code to verify. Use another method, or contact support if you believe this is a mistake."
+msgstr "Esta conta não tem código por e-mail para verificar. Use outro método ou contate o suporte se achar que isso é um engano."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:12
+#: django_mfa/templates/django_mfa/verify_webauthn.html:12
+#, fuzzy
+msgid "This browser doesn't support security keys or passkeys. Try another browser, or choose a different method."
+msgstr "Este navegador não oferece suporte a chaves de segurança ou passkeys. Tente outro navegador ou escolha um método diferente."
+
+#: django_mfa/templates/django_mfa/email/factor_removed.txt:1
+#, fuzzy
+msgid "This two-factor method was removed from your account: %(factor)s"
+msgstr "Este método de dois fatores foi removido da sua conta: %(factor)s"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:24
+#, fuzzy
+msgid "Turn on email codes"
+msgstr "Ativar códigos por e-mail"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:30
+#, fuzzy
+msgid "Turn on two-factor authentication"
+msgstr "Ativar a autenticação de dois fatores"
+
+#: django_mfa/templates/django_mfa/base.html:19
+#: django_mfa/templates/django_mfa/security.html:6
+#, fuzzy
+msgid "Two-factor authentication"
+msgstr "Autenticação de dois fatores"
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled_subject.txt:1
+#, fuzzy
+msgid "Two-factor authentication is off for your account"
+msgstr "A autenticação de dois fatores está desativada na sua conta"
+
+#: django_mfa/templates/django_mfa/security.html:10
+#, fuzzy
+msgid "Two-factor authentication is required for your account."
+msgstr "A autenticação de dois fatores é obrigatória para sua conta."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:6
+#, fuzzy
+msgid "Use a hardware security key, or a passkey built into this device such as Touch ID or Windows Hello."
+msgstr "Use uma chave de segurança física ou uma passkey integrada a este dispositivo, como Touch ID ou Windows Hello."
+
+#: django_mfa/templates/django_mfa/verify_email.html:26
+#: django_mfa/templates/django_mfa/verify_email.html:32
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:25
+#: django_mfa/templates/django_mfa/verify_totp.html:23
+#: django_mfa/templates/django_mfa/verify_webauthn.html:29
+#, fuzzy
+msgid "Use another method"
+msgstr "Usar outro método"
+
+#: django_mfa/templates/django_mfa/security.html:79
+#, fuzzy
+msgid "Use one to sign in if you lose access to your other methods."
+msgstr "Use um deles para entrar caso perca o acesso aos seus outros métodos."
+
+#: django_mfa/templates/django_mfa/verify_webauthn.html:5
+#, fuzzy
+msgid "Use your security key"
+msgstr "Use sua chave de segurança"
+
+#: django_mfa/templates/django_mfa/verify_email.html:25
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:24
+#: django_mfa/templates/django_mfa/verify_totp.html:22
+#: django_mfa/templates/django_mfa/verify_webauthn.html:28
+#, fuzzy
+msgid "Verify"
+msgstr "Verificar"
+
+#: django_mfa/templates/django_mfa/picker.html:5
+#, fuzzy
+msgid "Verify it's you"
+msgstr "Confirme que é você"
+
+#: django_mfa/templates/django_mfa/security.html:82
+#, fuzzy
+msgid "View recovery codes"
+msgstr "Ver códigos de recuperação"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:8
+#, fuzzy
+msgid "We've emailed a code to %(address)s. Enter it below to turn on email codes."
+msgstr "Enviamos um código para %(address)s. Digite-o abaixo para ativar os códigos por e-mail."
+
+#: django_mfa/templates/django_mfa/verify_email.html:8
+#, fuzzy
+msgid "We've emailed a code to %(address)s. It expires shortly."
+msgstr "Enviamos um código para %(address)s. Ele expira em breve."
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:7
+#, fuzzy
+msgid "You have %(remaining)s recovery code left. Each one works only once."
+msgid_plural "You have %(remaining)s recovery codes left. Each one works only once."
+msgstr[0] "Resta %(remaining)s código de recuperação. Cada um funciona apenas uma vez."
+msgstr[1] "Restam %(remaining)s códigos de recuperação. Cada um funciona apenas uma vez."
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:3
+#, fuzzy
+msgid "You have %(remaining)s recovery codes left."
+msgstr "Restam %(remaining)s códigos de recuperação."
+
+#: django_mfa/templates/django_mfa/security.html:53
+#, fuzzy
+msgid "You haven't set up two-factor authentication yet."
+msgstr "Você ainda não configurou a autenticação de dois fatores."
+
+#: django_mfa/templates/django_mfa/enroll_email.html:29
+#, fuzzy
+msgid "Your account has no email address, so codes can't be delivered. Add one to your profile first."
+msgstr "Sua conta não tem endereço de e-mail, então os códigos não podem ser entregues. Adicione um ao seu perfil primeiro."
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled.txt:1
+#, fuzzy
+msgid "Your account is no longer protected by two-factor authentication. A password is now all that's needed to sign in."
+msgstr "Sua conta não está mais protegida por autenticação de dois fatores. Agora basta uma senha para entrar."
+
+#: django_mfa/views/verify.py:20
+#, fuzzy
+msgid "Your code is expired or invalid."
+msgstr "Seu código expirou ou é inválido."
+
+#: django_mfa/templates/django_mfa/security.html:17
+#, fuzzy
+msgid "Your methods"
+msgstr "Seus métodos"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:38
+#, fuzzy
+msgid "Your recovery codes were generated earlier and can't be shown again. If you've lost them, remove and re-add two-factor authentication to get a fresh set."
+msgstr "Seus códigos de recuperação foram gerados antes e não podem ser exibidos novamente. Se você os perdeu, remova e adicione novamente a autenticação de dois fatores para obter um novo conjunto."
+
+#: django_mfa/templates/django_mfa/email/otp_code_subject.txt:1
+#, fuzzy
+msgid "Your sign-in code"
+msgstr "Seu código de acesso"
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:1
+#, fuzzy
+msgid "Your sign-in code is %(code)s."
+msgstr "Seu código de acesso é %(code)s."
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:32
+#, fuzzy
+msgid "e.g. YubiKey, work laptop"
+msgstr "ex.: YubiKey, notebook do trabalho"
+
+#: django_mfa/templates/django_mfa/security.html:32
+#, fuzzy
+msgid "last used %(used)s"
+msgstr "usado pela última vez em %(used)s"
diff --git a/django_mfa/locale/zh_Hans/LC_MESSAGES/django.po b/django_mfa/locale/zh_Hans/LC_MESSAGES/django.po
new file mode 100644
index 0000000..6c932b9
--- /dev/null
+++ b/django_mfa/locale/zh_Hans/LC_MESSAGES/django.po
@@ -0,0 +1,417 @@
+# Simplified Chinese translation for django-mfa.
+# Copyright (C) MicroPyramid
+# This file is distributed under the same licence as the django-mfa package.
+#
+# MACHINE-DRAFTED, NOT REVIEWED. Every entry below is marked "fuzzy", which
+# means gettext ignores it and users see the English source instead. Nothing
+# here reaches a user until a human reviews an entry and removes its fuzzy
+# flag. See docs/translations.md.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: django-mfa\n"
+"Report-Msgid-Bugs-To: https://github.com/MicroPyramid/django-mfa/issues\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: zh_Hans\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:14
+#: django_mfa/templates/django_mfa/verify_email.html:15
+#, fuzzy
+msgid "%(code_length)s-digit code"
+msgstr "%(code_length)s 位数字代码"
+
+#: django_mfa/templates/django_mfa/security.html:78
+#, fuzzy
+msgid "%(remaining)s code remaining."
+msgid_plural "%(remaining)s codes remaining."
+msgstr[0] "还剩 %(remaining)s 个代码。"
+
+#: django_mfa/templates/django_mfa/email/factor_added_subject.txt:1
+#, fuzzy
+msgid "A new two-factor method was added to your account"
+msgstr "您的账户已添加新的双重验证方式"
+
+#: django_mfa/templates/django_mfa/email/factor_added.txt:1
+#, fuzzy
+msgid "A new two-factor method was added to your account: %(factor)s"
+msgstr "您的账户已添加新的双重验证方式:%(factor)s"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:1
+#, fuzzy
+msgid "A recovery code was just used to sign in to your account."
+msgstr "刚刚有人使用恢复代码登录了您的账户。"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used_subject.txt:1
+#, fuzzy
+msgid "A recovery code was used to sign in"
+msgstr "已使用恢复代码登录"
+
+#: django_mfa/templates/django_mfa/email/factor_removed_subject.txt:1
+#, fuzzy
+msgid "A two-factor method was removed from your account"
+msgstr "您的账户已移除一种双重验证方式"
+
+#: django_mfa/templates/django_mfa/security.html:59
+#, fuzzy
+msgid "Add a method"
+msgstr "添加方式"
+
+#: django_mfa/templates/django_mfa/security.html:11
+#, fuzzy
+msgid "Add a method below to continue. Until you do, the rest of the site is unavailable."
+msgstr "请在下方添加一种方式以继续。在此之前,网站其余部分不可用。"
+
+#: django_mfa/templates/django_mfa/security.html:7
+#, fuzzy
+msgid "Add a second step to your sign-in so a stolen password isn't enough on its own."
+msgstr "为登录添加第二个步骤,这样仅凭被盗密码就不足以登录。"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:5
+#, fuzzy
+msgid "Add a security key or passkey"
+msgstr "添加安全密钥或通行密钥"
+
+#: django_mfa/templates/django_mfa/security.html:30
+#, fuzzy
+msgid "Added %(created)s"
+msgstr "添加于 %(created)s"
+
+#: django_mfa/adapters/totp.py:47
+#: django_mfa/models.py:34
+#, fuzzy
+msgid "Authenticator app"
+msgstr "身份验证器应用"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:31
+#, fuzzy
+msgid "Back"
+msgstr "返回"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:11
+#, fuzzy
+msgid "Can't scan it?"
+msgstr "无法扫描?"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:25
+#: django_mfa/templates/django_mfa/enroll_totp.html:31
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:42
+#, fuzzy
+msgid "Cancel"
+msgstr "取消"
+
+#: django_mfa/templates/django_mfa/picker.html:6
+#, fuzzy
+msgid "Choose how you'd like to confirm your identity."
+msgstr "请选择确认身份的方式。"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:23
+#: django_mfa/templates/django_mfa/recovery_codes.html:41
+#, fuzzy
+msgid "Done"
+msgstr "完成"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:22
+#, fuzzy
+msgid "Download"
+msgstr "下载"
+
+#: django_mfa/adapters/email.py:104
+#: django_mfa/models.py:37
+#, fuzzy
+msgid "Emailed code"
+msgstr "邮件验证码"
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:5
+#, fuzzy
+msgid "Enter a recovery code"
+msgstr "输入恢复代码"
+
+#: django_mfa/templates/django_mfa/verify_email.html:5
+#: django_mfa/templates/django_mfa/verify_totp.html:5
+#, fuzzy
+msgid "Enter your code"
+msgstr "输入您的代码"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:5
+#, fuzzy
+msgid "If this wasn't you, change your password and generate a fresh set of codes."
+msgstr "如果这不是您本人操作,请修改密码并重新生成一组代码。"
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled.txt:3
+#, fuzzy
+msgid "If this wasn't you, change your password and set up two-factor authentication again immediately."
+msgstr "如果这不是您本人操作,请立即修改密码并重新设置双重验证。"
+
+#: django_mfa/templates/django_mfa/email/factor_removed.txt:3
+#, fuzzy
+msgid "If this wasn't you, change your password immediately."
+msgstr "如果这不是您本人操作,请立即修改密码。"
+
+#: django_mfa/templates/django_mfa/email/factor_added.txt:3
+#, fuzzy
+msgid "If this wasn't you, remove it and change your password immediately."
+msgstr "如果这不是您本人操作,请将其移除并立即修改密码。"
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:5
+#, fuzzy
+msgid "If you didn't try to sign in, someone may know your password. Change it."
+msgstr "如果您没有尝试登录,可能有人知道了您的密码。请立即修改。"
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:3
+#, fuzzy
+msgid "It expires in %(validity_minutes)s minutes and can be used once."
+msgstr "该代码将在 %(validity_minutes)s 分钟后失效,且仅可使用一次。"
+
+#: django_mfa/templates/django_mfa/security.html:46
+#, fuzzy
+msgid "Managed by your organization"
+msgstr "由您的组织管理"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:29
+#, fuzzy
+msgid "Name this key"
+msgstr "为该密钥命名"
+
+#: django_mfa/templates/django_mfa/verify_totp.html:6
+#, fuzzy
+msgid "Open your authenticator app and enter the six-digit code it shows."
+msgstr "打开身份验证器应用,输入其中显示的六位数字代码。"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:36
+#, fuzzy
+msgid "Optional. Helps you tell your keys apart later."
+msgstr "可选。便于您以后区分不同的密钥。"
+
+#: django_mfa/views/verify.py:54
+#, fuzzy
+msgid "Passkey sign-in failed."
+msgstr "通行密钥登录失败。"
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:14
+#, fuzzy
+msgid "Recovery code"
+msgstr "恢复代码"
+
+#: django_mfa/adapters/recovery_codes.py:21
+#: django_mfa/models.py:36
+#: django_mfa/templates/django_mfa/recovery_codes.html:5
+#: django_mfa/templates/django_mfa/security.html:75
+#, fuzzy
+msgid "Recovery codes"
+msgstr "恢复代码"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:41
+#, fuzzy
+msgid "Register"
+msgstr "注册"
+
+#: django_mfa/templates/django_mfa/security.html:42
+#, fuzzy
+msgid "Remove"
+msgstr "移除"
+
+#: django_mfa/templates/django_mfa/security.html:41
+#, fuzzy
+msgid "Remove this method?"
+msgstr "要移除此方式吗?"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:8
+#, fuzzy
+msgid "Save these somewhere safe. Each code works once, and this is the only time they'll be shown."
+msgstr "请妥善保存。每个代码仅可使用一次,且仅此一次显示。"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:6
+#, fuzzy
+msgid "Scan this code with an authenticator app, then enter the six-digit code it shows."
+msgstr "使用身份验证器应用扫描此码,然后输入其中显示的六位数字代码。"
+
+#: django_mfa/adapters/webauthn.py:72
+#: django_mfa/models.py:35
+#, fuzzy
+msgid "Security key or passkey"
+msgstr "安全密钥或通行密钥"
+
+#: django_mfa/templates/django_mfa/verify_webauthn.html:6
+#, fuzzy
+msgid "Select the button below, then follow your browser's prompt to use your security key or passkey."
+msgstr "请点击下方按钮,然后按照浏览器提示使用您的安全密钥或通行密钥。"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:5
+#, fuzzy
+msgid "Set up an authenticator app"
+msgstr "设置身份验证器应用"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:5
+#, fuzzy
+msgid "Set up email codes"
+msgstr "设置邮件验证码"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:20
+#: django_mfa/templates/django_mfa/verify_totp.html:12
+#, fuzzy
+msgid "Six-digit code"
+msgstr "六位数字代码"
+
+#: django_mfa/templates/django_mfa/security.html:69
+#, fuzzy
+msgid "There is nothing left to add."
+msgstr "没有可添加的项了。"
+
+#: django_mfa/templates/django_mfa/verify_email.html:30
+#, fuzzy
+msgid "This account has no email code to verify. Use another method, or contact support if you believe this is a mistake."
+msgstr "此账户没有可验证的邮件验证码。请使用其他方式,若您认为这是错误,请联系支持人员。"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:12
+#: django_mfa/templates/django_mfa/verify_webauthn.html:12
+#, fuzzy
+msgid "This browser doesn't support security keys or passkeys. Try another browser, or choose a different method."
+msgstr "此浏览器不支持安全密钥或通行密钥。请更换浏览器,或选择其他方式。"
+
+#: django_mfa/templates/django_mfa/email/factor_removed.txt:1
+#, fuzzy
+msgid "This two-factor method was removed from your account: %(factor)s"
+msgstr "您的账户已移除此双重验证方式:%(factor)s"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:24
+#, fuzzy
+msgid "Turn on email codes"
+msgstr "启用邮件验证码"
+
+#: django_mfa/templates/django_mfa/enroll_totp.html:30
+#, fuzzy
+msgid "Turn on two-factor authentication"
+msgstr "启用双重验证"
+
+#: django_mfa/templates/django_mfa/base.html:19
+#: django_mfa/templates/django_mfa/security.html:6
+#, fuzzy
+msgid "Two-factor authentication"
+msgstr "双重验证"
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled_subject.txt:1
+#, fuzzy
+msgid "Two-factor authentication is off for your account"
+msgstr "您的账户已关闭双重验证"
+
+#: django_mfa/templates/django_mfa/security.html:10
+#, fuzzy
+msgid "Two-factor authentication is required for your account."
+msgstr "您的账户必须启用双重验证。"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:6
+#, fuzzy
+msgid "Use a hardware security key, or a passkey built into this device such as Touch ID or Windows Hello."
+msgstr "请使用硬件安全密钥,或本设备内置的通行密钥(如 Touch ID 或 Windows Hello)。"
+
+#: django_mfa/templates/django_mfa/verify_email.html:26
+#: django_mfa/templates/django_mfa/verify_email.html:32
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:25
+#: django_mfa/templates/django_mfa/verify_totp.html:23
+#: django_mfa/templates/django_mfa/verify_webauthn.html:29
+#, fuzzy
+msgid "Use another method"
+msgstr "使用其他方式"
+
+#: django_mfa/templates/django_mfa/security.html:79
+#, fuzzy
+msgid "Use one to sign in if you lose access to your other methods."
+msgstr "若您无法使用其他方式,可用其中一个登录。"
+
+#: django_mfa/templates/django_mfa/verify_webauthn.html:5
+#, fuzzy
+msgid "Use your security key"
+msgstr "使用您的安全密钥"
+
+#: django_mfa/templates/django_mfa/verify_email.html:25
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:24
+#: django_mfa/templates/django_mfa/verify_totp.html:22
+#: django_mfa/templates/django_mfa/verify_webauthn.html:28
+#, fuzzy
+msgid "Verify"
+msgstr "验证"
+
+#: django_mfa/templates/django_mfa/picker.html:5
+#, fuzzy
+msgid "Verify it's you"
+msgstr "验证您的身份"
+
+#: django_mfa/templates/django_mfa/security.html:82
+#, fuzzy
+msgid "View recovery codes"
+msgstr "查看恢复代码"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:8
+#, fuzzy
+msgid "We've emailed a code to %(address)s. Enter it below to turn on email codes."
+msgstr "我们已向 %(address)s 发送验证码。请在下方输入以启用邮件验证码。"
+
+#: django_mfa/templates/django_mfa/verify_email.html:8
+#, fuzzy
+msgid "We've emailed a code to %(address)s. It expires shortly."
+msgstr "我们已向 %(address)s 发送验证码。该验证码即将失效。"
+
+#: django_mfa/templates/django_mfa/verify_recovery_codes.html:7
+#, fuzzy
+msgid "You have %(remaining)s recovery code left. Each one works only once."
+msgid_plural "You have %(remaining)s recovery codes left. Each one works only once."
+msgstr[0] "您还剩 %(remaining)s 个恢复代码。每个仅可使用一次。"
+
+#: django_mfa/templates/django_mfa/email/recovery_code_used.txt:3
+#, fuzzy
+msgid "You have %(remaining)s recovery codes left."
+msgstr "您还剩 %(remaining)s 个恢复代码。"
+
+#: django_mfa/templates/django_mfa/security.html:53
+#, fuzzy
+msgid "You haven't set up two-factor authentication yet."
+msgstr "您尚未设置双重验证。"
+
+#: django_mfa/templates/django_mfa/enroll_email.html:29
+#, fuzzy
+msgid "Your account has no email address, so codes can't be delivered. Add one to your profile first."
+msgstr "您的账户未设置电子邮件地址,无法发送验证码。请先在个人资料中添加。"
+
+#: django_mfa/templates/django_mfa/email/mfa_disabled.txt:1
+#, fuzzy
+msgid "Your account is no longer protected by two-factor authentication. A password is now all that's needed to sign in."
+msgstr "您的账户不再受双重验证保护。现在仅凭密码即可登录。"
+
+#: django_mfa/views/verify.py:20
+#, fuzzy
+msgid "Your code is expired or invalid."
+msgstr "您的代码已过期或无效。"
+
+#: django_mfa/templates/django_mfa/security.html:17
+#, fuzzy
+msgid "Your methods"
+msgstr "您的验证方式"
+
+#: django_mfa/templates/django_mfa/recovery_codes.html:38
+#, fuzzy
+msgid "Your recovery codes were generated earlier and can't be shown again. If you've lost them, remove and re-add two-factor authentication to get a fresh set."
+msgstr "您的恢复代码此前已生成,无法再次显示。如已丢失,请移除并重新添加双重验证以获取新的一组代码。"
+
+#: django_mfa/templates/django_mfa/email/otp_code_subject.txt:1
+#, fuzzy
+msgid "Your sign-in code"
+msgstr "您的登录代码"
+
+#: django_mfa/templates/django_mfa/email/otp_code.txt:1
+#, fuzzy
+msgid "Your sign-in code is %(code)s."
+msgstr "您的登录代码是 %(code)s。"
+
+#: django_mfa/templates/django_mfa/enroll_webauthn.html:32
+#, fuzzy
+msgid "e.g. YubiKey, work laptop"
+msgstr "例如:YubiKey、工作笔记本电脑"
+
+#: django_mfa/templates/django_mfa/security.html:32
+#, fuzzy
+msgid "last used %(used)s"
+msgstr "上次使用 %(used)s"
diff --git a/django_mfa/models.py b/django_mfa/models.py
index 6ef0dc9..cf8da54 100644
--- a/django_mfa/models.py
+++ b/django_mfa/models.py
@@ -1,6 +1,7 @@
from django.conf import settings
from django.db import models
from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
# Whether a factor type counts as "primary" (protects a user on its own) is
# NOT decided here. It used to be a second, independent definition
@@ -19,10 +20,21 @@ def for_user(self, user):
class Authenticator(models.Model):
class Type(models.TextChoices):
- TOTP = "totp", "Authenticator app"
- WEBAUTHN = "webauthn", "Security key or passkey"
- RECOVERY_CODES = "recovery_codes", "Recovery codes"
- EMAIL = "email", "Emailed code"
+ # Labels are lazily translated: get_type_display() feeds the
+ # notification emails (django_mfa/notifications.py) and the admin
+ # changelist, so leaving them in English would put an untranslated
+ # factor name inside an otherwise translated message.
+ #
+ # This needs NO migration, which is not obvious: `choices` is part of
+ # a field's deconstruction, so changing it normally provokes an
+ # AlterField. A gettext_lazy proxy compares equal to the string it
+ # wraps, though, so the autodetector sees the same choices it already
+ # had. Verified against Django 4.2, 5.2 and 6.1; test_migrations.py's
+ # makemigrations --check test is what would catch it regressing.
+ TOTP = "totp", _("Authenticator app")
+ WEBAUTHN = "webauthn", _("Security key or passkey")
+ RECOVERY_CODES = "recovery_codes", _("Recovery codes")
+ EMAIL = "email", _("Emailed code")
user = models.ForeignKey(settings.AUTH_USER_MODEL,
related_name="mfa_authenticators",
diff --git a/django_mfa/tests/support/i18n.py b/django_mfa/tests/support/i18n.py
new file mode 100644
index 0000000..7acf631
--- /dev/null
+++ b/django_mfa/tests/support/i18n.py
@@ -0,0 +1,287 @@
+"""Extract translatable strings and read PO catalogs, without gettext.
+
+`makemessages` shells out to xgettext, which is a system package this test
+suite cannot assume: it is absent from this project's CI images and from at
+least one maintainer's machine. A catalog guard that silently skips wherever
+gettext is missing guards nothing -- it would have been skipped in every
+environment that actually runs the suite, which is the same shape of failure
+`test_packaging.py` exists to prevent (a green tick over a check that never
+ran).
+
+So extraction happens in pure Python here instead:
+
+* Templates go through ``django.utils.translation.template.templatize()``,
+ the exact function `makemessages` itself feeds to xgettext. It rewrites a
+ Django template into a Python-ish source where every translatable string
+ appears as a ``gettext(...)`` call and *all* other content -- including
+ quote characters -- is replaced by filler letters, preserving line and
+ column positions. That output is deliberately not valid Python (``ast``
+ chokes on it), which is why the calls are found by regex, the way
+ xgettext's own lenient lexer does. The filler guarantee is what makes the
+ regex safe: a string literal in that output can only have come from a real
+ translation tag.
+
+* Python modules are parsed with ``ast``, which is exact.
+
+This module is test support, not shipped API. Nothing in django_mfa imports
+it.
+"""
+
+import ast
+import re
+from pathlib import Path
+
+from django.utils.translation.template import templatize
+
+PACKAGE = Path(__file__).resolve().parents[2]
+REPO_ROOT = PACKAGE.parent
+LOCALE_DIR = PACKAGE / "locale"
+
+#: The gettext family Django's template lexer emits, plus the aliases the
+#: Python side uses. `_` is included because that is how django_mfa imports
+#: gettext_lazy.
+GETTEXT_NAMES = frozenset({
+ "_", "gettext", "gettext_lazy", "gettext_noop",
+ "ngettext", "ngettext_lazy",
+ "pgettext", "pgettext_lazy", "npgettext", "npgettext_lazy",
+})
+
+_CALL_RE = re.compile(r"\b(?Pn?p?gettext(?:_lazy|_noop)?)\s*\(")
+_STR_RE = re.compile(
+ r"""[ubUB]*(?P'''|\"\"\"|'|")(?P(?:\\.|(?!(?P=q))[\s\S])*)(?P=q)""")
+
+
+def _string_run(source, pos):
+ """Consecutive comma-separated string literals starting at ``pos``.
+
+ Stops at the first argument that is not a literal, which is how the
+ count argument of ngettext() and the context of pgettext() are handled:
+ a context is a literal and so is collected, a count is not and so ends
+ the run.
+ """
+ literals = []
+ while True:
+ match = _STR_RE.match(source, pos)
+ if not match:
+ break
+ literals.append(ast.literal_eval(match.group(0)))
+ pos = match.end()
+ while pos < len(source) and source[pos] in " \t":
+ pos += 1
+ if pos >= len(source) or source[pos] != ",":
+ break
+ pos += 1
+ while pos < len(source) and source[pos] in " \t":
+ pos += 1
+ return literals
+
+
+def _key_from_call(name, literals):
+ """Turn one gettext-family call into a catalog key.
+
+ The key is ``(msgctxt, msgid, msgid_plural)`` with None for the parts a
+ given call shape doesn't carry. Getting this right is the difference
+ between a usable catalog and a broken one: an ``ngettext`` pair recorded
+ as two independent singular entries produces msgids gettext will never
+ look up at runtime, because a plural lookup is keyed on the singular AND
+ the plural together.
+
+ Returns None for a call with no literal msgid -- ``_(variable)`` is
+ legal Python that xgettext also declines to extract.
+ """
+ base = name.removesuffix("_lazy").removesuffix("_noop")
+ has_context = base.startswith("p") or base.startswith("np")
+ is_plural = base.startswith("n")
+
+ context = None
+ if has_context:
+ if not literals:
+ return None
+ context, literals = literals[0], literals[1:]
+ if not literals:
+ return None
+ if is_plural:
+ if len(literals) < 2:
+ return None
+ return (context, literals[0], literals[1])
+ return (context, literals[0], None)
+
+
+def strings_in_template(path):
+ """[(key, line number)] for one template file."""
+ source = templatize(path.read_text(encoding="utf-8"), origin=str(path))
+ found = []
+ for match in _CALL_RE.finditer(source):
+ line = source.count("\n", 0, match.start()) + 1
+ key = _key_from_call(match.group("fn"), _string_run(source, match.end()))
+ if key is not None:
+ found.append((key, line))
+ return found
+
+
+def strings_in_python(path):
+ """[(key, line number)] for one Python module."""
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ found = []
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ func = node.func
+ name = getattr(func, "id", None) or getattr(func, "attr", None)
+ if name not in GETTEXT_NAMES:
+ continue
+ literals = []
+ for arg in node.args:
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
+ literals.append(arg.value)
+ else:
+ # A non-literal argument ends the run for the same reason as
+ # in _string_run: it is a count or an interpolation target,
+ # not something a translator can be handed.
+ break
+ # `_` is an alias for gettext_lazy throughout this package; the
+ # regex-driven template path never sees it, so it is normalised here.
+ key = _key_from_call("gettext" if name == "_" else name, literals)
+ if key is not None:
+ found.append((key, node.lineno))
+ return found
+
+
+def source_files():
+ """Every file in the package that can hold a translatable string.
+
+ Tests are excluded: their strings are fixtures, and a translator being
+ asked to translate an assertion message would be a bug in this list.
+ """
+ templates = sorted(
+ p for p in (PACKAGE / "templates").rglob("*") if p.is_file())
+ modules = sorted(
+ p for p in PACKAGE.rglob("*.py")
+ if "tests" not in p.relative_to(PACKAGE).parts
+ )
+ return templates, modules
+
+
+def extract():
+ """Every translatable string in the package.
+
+ Returns ``{(msgctxt, msgid, msgid_plural): [(repo-relative path, line)]}``
+ with keys and references sorted, so the result is stable enough both to
+ write a .pot from and to compare a committed .pot against.
+ """
+ templates, modules = source_files()
+ catalog = {}
+ for path, reader in (
+ *((p, strings_in_template) for p in templates),
+ *((p, strings_in_python) for p in modules),
+ ):
+ for key, line in reader(path):
+ rel = path.relative_to(REPO_ROOT).as_posix()
+ catalog.setdefault(key, []).append((rel, line))
+ return {
+ key: sorted(set(refs))
+ # Sort on the string parts only: None is not orderable against str,
+ # so a plain sorted() on the keys breaks the moment a context or a
+ # plural appears alongside an entry without one.
+ for key, refs in sorted(catalog.items(), key=lambda kv: tuple(
+ part or "" for part in kv[0]))
+ }
+
+
+# --- PO files ---------------------------------------------------------------
+
+def po_escape(value):
+ return (value.replace("\\", "\\\\").replace('"', '\\"')
+ .replace("\n", "\\n").replace("\t", "\\t"))
+
+
+def po_unescape(value):
+ out, i = [], 0
+ simple = {"n": "\n", "t": "\t", '"': '"', "\\": "\\"}
+ while i < len(value):
+ char = value[i]
+ if char == "\\" and i + 1 < len(value):
+ out.append(simple.get(value[i + 1], value[i + 1]))
+ i += 2
+ else:
+ out.append(char)
+ i += 1
+ return "".join(out)
+
+
+def _blank_entry():
+ return {"msgctxt": None, "msgid": None, "msgid_plural": None,
+ "msgstrs": [], "flags": set()}
+
+
+def parse_po(path):
+ """Minimal PO reader, returning one dict per entry with the header dropped.
+
+ Each entry carries ``msgctxt``, ``msgid``, ``msgid_plural``, ``msgstrs``
+ (a list -- one item for a singular entry, nplurals items for a plural
+ one) and ``flags``.
+
+ Deliberately not a general-purpose PO parser: it understands exactly the
+ subset this project's catalogs use, which is also the subset the writer
+ in tools/ emits. It judges nothing -- the tests do that.
+ """
+ entries, current, field = [], _blank_entry(), None
+ # Flags precede the entry they describe ("#, fuzzy" sits above its
+ # msgid), so they are buffered and attached to the NEXT entry. Adding
+ # them to `current` as they are read would file every flag against the
+ # preceding entry instead -- which, for a catalog whose central claim is
+ # "every entry is fuzzy", would be a guard that reads the wrong rows.
+ pending_flags = set()
+
+ for raw in path.read_text(encoding="utf-8").splitlines():
+ line = raw.strip()
+ if not line:
+ continue
+ if line.startswith("#,"):
+ pending_flags |= {f.strip() for f in line[2:].split(",")}
+ continue
+ if line.startswith("#"):
+ continue
+
+ match = re.match(r"(msgctxt|msgid_plural|msgid|msgstr(?:\[(\d+)\])?)\s+"
+ r'"(.*)"$', line)
+ if match:
+ keyword, _index, value = match.groups()
+ value = po_unescape(value)
+ if keyword == "msgid":
+ if current["msgid"]:
+ entries.append(current)
+ current = _blank_entry()
+ current["flags"], pending_flags = pending_flags, set()
+ if keyword.startswith("msgstr"):
+ current["msgstrs"].append(value)
+ field = ("msgstrs", len(current["msgstrs"]) - 1)
+ else:
+ current[keyword] = value
+ field = (keyword, None)
+ elif line.startswith('"') and field:
+ name, index = field
+ chunk = po_unescape(line[1:-1])
+ if index is None:
+ current[name] = (current[name] or "") + chunk
+ else:
+ current[name][index] += chunk
+
+ if current["msgid"]:
+ entries.append(current)
+ return entries
+
+
+def po_key(entry):
+ """The ``extract()`` key an entry corresponds to."""
+ return (entry["msgctxt"], entry["msgid"], entry["msgid_plural"])
+
+
+def locale_files():
+ """Every committed catalog: ``{language code: Path}``."""
+ if not LOCALE_DIR.is_dir():
+ return {}
+ return {
+ po.parent.parent.name: po
+ for po in sorted(LOCALE_DIR.glob("*/LC_MESSAGES/django.po"))
+ }
diff --git a/django_mfa/tests/test_i18n.py b/django_mfa/tests/test_i18n.py
new file mode 100644
index 0000000..68048f7
--- /dev/null
+++ b/django_mfa/tests/test_i18n.py
@@ -0,0 +1,179 @@
+"""Guard the translation catalog and the Python strings that feed it.
+
+Three distinct failure modes live here, none of which any other test or the
+docs build would notice:
+
+1. **A catalog that has silently rotted.** Someone adds a template string,
+ never regenerates the catalog, and every translator's file is quietly
+ missing an entry. Nothing errors -- gettext falls back to the English
+ source -- so a half-translated page is the only symptom, and only in a
+ language nobody on the team reads.
+
+2. **An unreviewed machine draft going live.** The shipped .po files were
+ machine-drafted and are marked fuzzy, which is exactly what keeps them
+ inert. Strip a fuzzy flag by accident (a bulk edit, an over-eager
+ `msgattrib`) and unreviewed text starts appearing in a security UI.
+
+3. **A lazy string reaching somewhere that can't take one.** gettext_lazy
+ returns a proxy, not a str. Templates resolve it, and so does
+ JsonResponse -- but only because it encodes with DjangoJSONEncoder, which
+ special-cases Promise. A bare json.dumps raises TypeError instead.
+
+Extraction runs in pure Python (see tests/support/i18n.py) rather than via
+`makemessages`, so these run wherever the suite runs -- gettext is not
+installed in CI.
+"""
+
+import json
+import re
+import unittest
+
+from django.test import SimpleTestCase
+from django.utils.functional import Promise
+
+from django_mfa.adapters.email import EmailAdapter
+from django_mfa.adapters.recovery_codes import RecoveryCodesAdapter
+from django_mfa.adapters.totp import TOTPAdapter
+from django_mfa.adapters.webauthn import WebAuthnAdapter
+from django_mfa.models import Authenticator
+from django_mfa.tests.support import i18n
+from django_mfa.views.verify import GENERIC_ERROR, _passkey_failure
+
+#: Placeholders are the one thing a translator can break that crashes at
+#: runtime rather than merely reading oddly: "%(code)s" renamed or dropped
+#: makes the interpolation raise KeyError, in the middle of a sign-in.
+PLACEHOLDER_RE = re.compile(r"%\((\w+)\)[sd]")
+
+
+class CatalogCurrentTests(unittest.TestCase):
+ """The committed catalog matches what the package actually contains."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.extracted = i18n.extract()
+ cls.pot = i18n.parse_po(i18n.LOCALE_DIR / "django.pot")
+ cls.locales = i18n.locale_files()
+
+ def test_pot_exists_and_is_not_empty(self):
+ self.assertTrue(self.pot, "django.pot has no entries")
+
+ def test_pot_matches_the_source_tree(self):
+ source_keys = set(self.extracted)
+ pot_keys = {i18n.po_key(e) for e in self.pot}
+
+ missing = source_keys - pot_keys
+ stale = pot_keys - source_keys
+ self.assertEqual(
+ (missing, stale), (set(), set()),
+ "django.pot is out of date. Strings in the code but not in the "
+ f"catalog: {sorted(m[1] for m in missing)}. Entries in the "
+ f"catalog with no string left in the code: "
+ f"{sorted(s[1] for s in stale)}. Regenerate with `makemessages`.",
+ )
+
+ def test_every_language_covers_the_whole_template(self):
+ pot_keys = {i18n.po_key(e) for e in self.pot}
+ self.assertTrue(self.locales, "no .po files found under locale/")
+ for code, path in self.locales.items():
+ with self.subTest(language=code):
+ keys = {i18n.po_key(e) for e in i18n.parse_po(path)}
+ self.assertEqual(
+ keys, pot_keys,
+ f"{code} does not match django.pot. Missing: "
+ f"{sorted(k[1] for k in pot_keys - keys)}. Extra: "
+ f"{sorted(k[1] for k in keys - pot_keys)}.",
+ )
+
+
+class DraftsStayInertTests(unittest.TestCase):
+ """Nothing unreviewed reaches a user.
+
+ Every shipped translation is machine-drafted. `fuzzy` is what makes that
+ safe: gettext skips a fuzzy entry entirely and falls back to the English
+ source. A language graduates by a human reviewing its entries and
+ removing the flags -- at which point this test is the thing that has to
+ be updated deliberately, which is the point.
+ """
+
+ def test_every_translated_entry_is_marked_fuzzy(self):
+ for code, path in i18n.locale_files().items():
+ with self.subTest(language=code):
+ live = [e["msgid"] for e in i18n.parse_po(path)
+ if any(e["msgstrs"]) and "fuzzy" not in e["flags"]]
+ self.assertEqual(
+ live, [],
+ f"{code} has non-fuzzy translations, which means they are "
+ f"live for users: {live}. Either mark them fuzzy, or -- if "
+ f"a human really has reviewed this language -- update this "
+ f"test and docs/translations.md together.",
+ )
+
+ def test_no_compiled_catalogs_are_committed(self):
+ """A fully fuzzy catalog compiles to an empty one, so a .mo here is
+ either dead weight or evidence something was compiled from unreviewed
+ drafts. Either way it should not ship."""
+ found = sorted(p.name for p in i18n.LOCALE_DIR.rglob("*.mo"))
+ self.assertEqual(found, [], f"unexpected compiled catalogs: {found}")
+
+
+class PlaceholderTests(unittest.TestCase):
+ def test_translations_keep_every_placeholder(self):
+ for code, path in i18n.locale_files().items():
+ for entry in i18n.parse_po(path):
+ expected = set(PLACEHOLDER_RE.findall(entry["msgid"]))
+ if entry["msgid_plural"]:
+ expected |= set(PLACEHOLDER_RE.findall(entry["msgid_plural"]))
+ for index, translated in enumerate(entry["msgstrs"]):
+ if not translated:
+ continue
+ with self.subTest(language=code, msgid=entry["msgid"],
+ form=index):
+ self.assertEqual(
+ set(PLACEHOLDER_RE.findall(translated)), expected,
+ "placeholders differ from the source string, which "
+ "raises KeyError at interpolation time",
+ )
+
+
+class LazyStringTests(SimpleTestCase):
+ """The Python strings are translatable, and survive where they are used."""
+
+ def test_user_facing_strings_are_lazy(self):
+ lazies = {
+ "GENERIC_ERROR": GENERIC_ERROR,
+ "TOTPAdapter.verbose_name": TOTPAdapter.verbose_name,
+ "WebAuthnAdapter.verbose_name": WebAuthnAdapter.verbose_name,
+ "RecoveryCodesAdapter.verbose_name": RecoveryCodesAdapter.verbose_name,
+ "EmailAdapter.verbose_name": EmailAdapter.verbose_name,
+ }
+ for name, value in lazies.items():
+ with self.subTest(string=name):
+ self.assertIsInstance(
+ value, Promise,
+ f"{name} is a plain str, so it is not translatable")
+
+ def test_factor_type_labels_are_lazy(self):
+ for choice in Authenticator.Type:
+ with self.subTest(factor=choice.value):
+ self.assertIsInstance(choice.label, Promise)
+
+ def test_passkey_failure_response_serialises(self):
+ """The lazy error string still reaches the client as text.
+
+ This works because JsonResponse encodes with DjangoJSONEncoder,
+ which resolves a Promise; plain json.dumps would raise TypeError and
+ turn every passkey failure -- the ordinary wrong-credential path,
+ not an edge case -- into a 500 instead of the deliberately uniform
+ 400. Asserting the decoded body rather than the encoder keeps this
+ honest if the response is ever built a different way.
+ """
+ response = _passkey_failure()
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(json.loads(response.content),
+ {"error": "Passkey sign-in failed."})
+
+ def test_factor_labels_still_render_as_text(self):
+ """get_type_display() feeds email templates; a proxy must format."""
+ authenticator = Authenticator(type=Authenticator.Type.TOTP)
+ self.assertEqual(f"{authenticator.get_type_display()}",
+ "Authenticator app")
diff --git a/django_mfa/tests/test_packaging.py b/django_mfa/tests/test_packaging.py
index d85bc2e..0d9be53 100644
--- a/django_mfa/tests/test_packaging.py
+++ b/django_mfa/tests/test_packaging.py
@@ -102,6 +102,33 @@ def test_templates_and_static_files_ship(self):
"django_mfa/static/django_mfa/style.css"):
self.assertIn(needle, self.names, f"{needle} missing from the wheel")
+ def test_translation_catalogs_ship(self):
+ """Catalogs ride on file inclusion too, and their absence is silent.
+
+ Django looks for /locale//LC_MESSAGES/ inside the
+ *installed* package. A wheel without it doesn't error -- gettext
+ simply finds no catalog and every string falls back to the English
+ source, so a fully translated install looks untranslated with
+ nothing in the logs to say why.
+
+ Asserted on the .po files and the .pot: no .mo ships yet, because
+ every entry is still a fuzzy machine draft (see
+ django_mfa/tests/test_i18n.py and docs/translations.md). When a
+ language is reviewed and starts shipping compiled catalogs, add the
+ .mo check here -- it is the file gettext actually reads at runtime.
+ """
+ self.assertIn("django_mfa/locale/django.pot", self.names)
+ catalogs = [n for n in self.names
+ if n.startswith("django_mfa/locale/")
+ and n.endswith("/LC_MESSAGES/django.po")]
+ on_disk = sorted(
+ p.relative_to(REPO_ROOT).as_posix()
+ for p in (REPO_ROOT / "django_mfa" / "locale").glob(
+ "*/LC_MESSAGES/django.po"))
+ self.assertEqual(
+ sorted(catalogs), on_disk,
+ "the locale directory on disk and in the wheel disagree")
+
def test_migrations_ship(self):
"""A Django app whose migrations don't ship leaves `migrate` with
nothing to apply and no error -- the tables simply never exist.
diff --git a/django_mfa/views/verify.py b/django_mfa/views/verify.py
index 8e1ca3a..cc1cc57 100644
--- a/django_mfa/views/verify.py
+++ b/django_mfa/views/verify.py
@@ -7,6 +7,7 @@
from django.http import Http404, HttpResponseNotAllowed, JsonResponse
from django.shortcuts import redirect, render, resolve_url
from django.utils.http import url_has_allowed_host_and_scheme
+from django.utils.translation import gettext_lazy as _
from fido2.webauthn import AuthenticationResponse
from django_mfa import events, ratelimit, session
@@ -16,7 +17,7 @@
from django_mfa.models import Authenticator
from django_mfa.registry import registry
-GENERIC_ERROR = "Your code is expired or invalid."
+GENERIC_ERROR = _("Your code is expired or invalid.")
#: Session key the in-progress *passwordless* authentication
#: challenge/state is stashed under between passkey_begin() and
@@ -37,12 +38,22 @@ def _passkey_failure():
Deliberately generic and byte-for-byte identical across all of them
(same status code, same body, constructed the same way every time): see
- passkey_complete()'s docstring for why. A fresh JsonResponse is built on
- every call rather than a module-level singleton being reused, since a
- response object is mutated as it's rendered/sent and must not be shared
- across requests.
+ passkey_complete()'s docstring for why. Translating it does not weaken
+ that -- the active language is a property of the request, not of which
+ failure occurred, so any given caller still gets one identical response
+ for every failure mode. A fresh JsonResponse is built on every call
+ rather than a module-level singleton being reused, since a response
+ object is mutated as it's rendered/sent and must not be shared across
+ requests.
+
+ The lazy string survives here only because JsonResponse encodes with
+ DjangoJSONEncoder by default, which resolves a translation proxy; a bare
+ json.dumps would raise TypeError ("Object of type __proxy__ is not JSON
+ serializable") and turn every passkey failure into a 500. Swap the
+ encoder or hand-roll the serialisation and that stops being true --
+ test_i18n.py pins the behaviour rather than the implementation.
"""
- return JsonResponse({"error": "Passkey sign-in failed."}, status=400)
+ return JsonResponse({"error": _("Passkey sign-in failed.")}, status=400)
def _adapter_or_404(factor_type):
diff --git a/docs/contributing.md b/docs/contributing.md
index f809551..9963cf8 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -14,7 +14,7 @@ To run a single test, pass a label -- `test_runner.py` falls back to the whole s
uv run python test_runner.py django_mfa.tests.test_conf
-To check one Python/Django combination locally (CI runs the full grid of Python 3.10-3.13 against Django 4.2 and 5.2):
+To check one Python/Django combination locally (CI runs the grid of Python 3.10-3.13 against Django 4.2, 5.2 and 6.1, minus the two combinations Django 6.1 doesn't support — it needs Python 3.12+):
uv run --python 3.12 --with "django~=4.2.0" python test_runner.py
diff --git a/docs/index.md b/docs/index.md
index 24ca45e..1d2c16f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -30,6 +30,7 @@ mfa_flow
:caption: Guides
customizing
+translations
enforcement
recipes
custom_factors
diff --git a/docs/installation_setup.md b/docs/installation_setup.md
index 04f09d3..fa2e354 100644
--- a/docs/installation_setup.md
+++ b/docs/installation_setup.md
@@ -5,7 +5,10 @@
| | |
|--------|------------------------|
| Python | 3.10, 3.11, 3.12, 3.13 |
-| Django | 4.2, 5.2 |
+| Django | 4.2, 5.2, 6.1 |
+
+Django 6.1 needs Python 3.12 or newer — that is Django's own requirement, not
+django-mfa's. On Python 3.10 or 3.11, use Django 4.2 or 5.2 (both LTS).
Any database Django supports works: factor state lives in a `JSONField`, and no
backend-specific features are used.
diff --git a/docs/translations.md b/docs/translations.md
new file mode 100644
index 0000000..d9502f5
--- /dev/null
+++ b/docs/translations.md
@@ -0,0 +1,92 @@
+# Translations
+
+Every string django-mfa shows a user is translatable. What ships today is the
+machinery plus six machine-drafted catalogs; **no translation is live yet**,
+by design. This page covers what you get out of the box, what your project
+has to do to use it, and how to review a language so it starts appearing.
+
+## What ships
+
+ django_mfa/locale/django.pot the template, 75 entries
+ django_mfa/locale//LC_MESSAGES/django.po
+
+Six languages have draft catalogs: `de`, `es`, `fr`, `pt_BR`, `ja`, `zh_Hans`.
+
+Every entry in every one of them is marked `#, fuzzy`. That is not an
+oversight — it is the whole safety model. gettext skips a fuzzy entry and
+falls back to the English source, so a machine draft nobody has read cannot
+put words in your product's mouth on a sign-in screen. `django_mfa/tests/`
+`test_i18n.py` fails the build if a fuzzy flag disappears without the
+deliberate steps below.
+
+For the same reason **no compiled `.mo` files ship**: a fully fuzzy catalog
+compiles to an empty one, so shipping it would add bytes and change nothing.
+
+## Using them in your project
+
+Django's i18n has to be switched on in the host project — django-mfa can't
+do it for you:
+
+ USE_I18N = True
+
+ MIDDLEWARE = [
+ ...,
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.locale.LocaleMiddleware", # after sessions
+ "django.middleware.common.CommonMiddleware",
+ ]
+
+`LocaleMiddleware` is what picks a language per request. Without it every
+request uses `LANGUAGE_CODE` and per-user language selection does nothing.
+
+:::{warning}
+Shadowing a template drops its translations with it. A copy of
+`django_mfa/templates/django_mfa/verify_totp.html` in your own app replaces
+the shipped file *and* its `{% trans %}` tags — the strings in your copy are
+yours to translate, in your project's own catalog. See {doc}`customizing`.
+:::
+
+## Reviewing a language so it goes live
+
+A draft becomes a real translation when a human who reads the language has
+checked it. The steps, in order:
+
+1. Read `django_mfa/locale//LC_MESSAGES/django.po` end to end. Fix
+ what's wrong. Pay particular attention to `%(name)s` placeholders: they
+ must appear in the translation exactly as in the source, or interpolation
+ raises `KeyError` in the middle of somebody's sign-in. A test enforces
+ this, but understanding why matters more than the test.
+2. Remove the `#, fuzzy` line above each entry you have reviewed. An entry
+ keeps falling back to English until you do.
+3. Compile it: `django-admin compilemessages -l `. This needs the
+ `gettext` tools installed (`apt install gettext`, `brew install gettext`).
+4. Commit the `.mo` alongside the `.po`. It is deliberately un-ignored in
+ `.gitignore` — the `.mo` is the file gettext reads at runtime, and
+ hatchling won't put an ignored file in the wheel.
+5. Update `test_i18n.py`'s `DraftsStayInertTests`, which asserts nothing is
+ live. Make it assert what's now true — that this language is reviewed and
+ the others are not. Changing that test should feel deliberate.
+
+## Adding a language
+
+ django-admin makemessages -l # run from django_mfa/
+
+Then follow the review steps above. There is no draft to start from, which
+is fine: an empty `msgstr` falls back to English exactly as a fuzzy one does.
+
+## Adding or changing a string
+
+Wrap it — `{% trans %}` / `{% blocktrans %}` in a template,
+`gettext_lazy as _` in Python — then regenerate:
+
+ django-admin makemessages -a --keep-pot # from django_mfa/
+
+`test_i18n.py` fails if the catalog and the code disagree, so a forgotten
+regeneration is caught in CI rather than discovered by a translator months
+later. That check runs a pure-Python extractor (`tests/support/i18n.py`)
+rather than shelling out to `xgettext`, so it works on machines and CI
+images that have no gettext installed.
+
+Two things are deliberately **not** translated: management-command output
+and system-check messages. Both are read by operators and developers, not
+end users, and both are matched against by scripts.
diff --git a/docs/upgrading.md b/docs/upgrading.md
index 38211ea..4c62178 100644
--- a/docs/upgrading.md
+++ b/docs/upgrading.md
@@ -4,7 +4,7 @@ This release is a ground-up rewrite: U2F support is gone, TOTP and WebAuthn/pass
## 1. Django 4.2+ and Python 3.10+ are now required
-Earlier releases supported Django 2.2-3.2 and Python 3.6-3.10. This release requires Django 4.2 or 5.2, and Python 3.10-3.13 (see `pyproject.toml`). The old code could not run on a newer Django at all -- it imported `django.utils.http.is_safe_url` and `django.utils.translation.ugettext`, both removed in Django 4.0 -- so there is no supported path that runs both the old and new code against the same Django version; the Django/Python upgrade and the django-mfa upgrade have to happen together.
+Earlier releases supported Django 2.2-3.2 and Python 3.6-3.10. This release requires Django 4.2, 5.2 or 6.1, and Python 3.10-3.13 (see `pyproject.toml`; Django 6.1 additionally needs Python 3.12+, which is Django's own floor). The old code could not run on a newer Django at all -- it imported `django.utils.http.is_safe_url` and `django.utils.translation.ugettext`, both removed in Django 4.0 -- so there is no supported path that runs both the old and new code against the same Django version; the Django/Python upgrade and the django-mfa upgrade have to happen together.
## 2. U2F support is removed entirely
diff --git a/pyproject.toml b/pyproject.toml
index 2205ed3..e9c051c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "django-mfa"
-version = "4.2.0"
+version = "4.3.0"
description = "Second-factor authentication for Django: authenticator apps (TOTP), security keys and passkeys (WebAuthn), and recovery codes."
readme = "README.md"
requires-python = ">=3.10"
@@ -23,11 +23,12 @@ dependencies = [
# SPDX `license` expression above (PEP 639). The expression is the source of
# truth.
classifiers = [
- "Development Status :: 4 - Beta",
+ "Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
"Framework :: Django",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.2",
+ "Framework :: Django :: 6.1",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python",
diff --git a/uv.lock b/uv.lock
index 5a95f78..b4d5bc8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -566,7 +566,7 @@ wheels = [
[[package]]
name = "django-mfa"
-version = "4.2.0"
+version = "4.3.0"
source = { editable = "." }
dependencies = [
{ name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },