Repository: pennersr/django-allauth
Pull request #3512 (2023-11-07 16:35:53)

Update Polish translation
Language updates only. This PR encompasses all phrases translated into Polish.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/63806856/badge)](https://coveralls.io/builds/63806856)

coverage: 95.635% (-0.05%) from 95.687%
when pulling **eda577ebae4bee051b78b014936a6d87c9fd1ebb on hoffmannkrzysztof:master**
into **9cd31387b21c2de4f66a24cfe5943ef50e7e1053 on pennersr:main**.


Patch:
diff --git a/ChangeLog.rst b/ChangeLog.rst
index 047cb022..47806df7 100644
--- a/ChangeLog.rst
+++ b/ChangeLog.rst
@@ -9,6 +9,9 @@ Note worthy changes
 
 - The MFA authenticator model is now registed with the Django admin.
 
+- Added MFA signals emitted when authenticators are added, removed or (in case
+  of recovery codes) reset.
+
 
 0.58.2 (2023-11-06)
 *******************
diff --git a/allauth/account/adapter.py b/allauth/account/adapter.py
index 7fb96cb4..ab52f08a 100644
--- a/allauth/account/adapter.py
+++ b/allauth/account/adapter.py
@@ -703,6 +703,32 @@ class DefaultAccountAdapter(object):
             ret.append("allauth.mfa.stages.AuthenticateStage")
         return ret
 
+    def get_reauthentication_methods(self, user):
+        """The order of the methods returned matters. The first method is the
+        default when using the `@reauthentication_required` decorator.
+        """
+        ret = []
+        if not user.is_authenticated:
+            return ret
+        if user.has_usable_password():
+            ret.append(
+                {
+                    "description": _("Use your password"),
+                    "url": reverse("account_reauthenticate"),
+                }
+            )
+        if allauth_app_settings.MFA_ENABLED:
+            from allauth.mfa.utils import is_mfa_enabled
+
+            if is_mfa_enabled(user):
+                ret.append(
+                    {
+                        "description": _("Use your authenticator app"),
+                        "url": reverse("mfa_reauthenticate"),
+                    }
+                )
+        return ret
+
 
 def get_adapter(request=None):
     return import_attribute(app_settings.ADAPTER)(request)
diff --git a/allauth/account/decorators.py b/allauth/account/decorators.py
index 5eb3e0e2..0b9964f0 100644
--- a/allauth/account/decorators.py
+++ b/allauth/account/decorators.py
@@ -5,6 +5,7 @@ from django.contrib.auth.decorators import login_required
 from django.shortcuts import render
 from django.urls import reverse
 
+from allauth.account.adapter import get_adapter
 from allauth.account.models import EmailAddress
 from allauth.account.reauthentication import (
     did_recently_authenticate,
@@ -60,11 +61,10 @@ def reauthentication_required(
             )
             if ena and not pass_method:
                 if request.user.is_anonymous or not did_recently_authenticate(request):
-                    redirect_url = reverse(
-                        "account_login"
-                        if request.user.is_anonymous
-                        else "account_reauthenticate"
-                    )
+                    redirect_url = reverse("account_login")
+                    methods = get_adapter().get_reauthentication_methods(request.user)
+                    if methods:
+                        redirect_url = methods[0]["url"]
                     return suspend_request(request, redirect_url)
             return view_func(request, *args, **kwargs)
 
diff --git a/allauth/account/reauthentication.py b/allauth/account/reauthentication.py
index af2aaa7a..f2df4e20 100644
--- a/allauth/account/reauthentication.py
+++ b/allauth/account/reauthentication.py
@@ -6,6 +6,7 @@ from django.urls import resolve, reverse
 from django.utils.http import urlencode
 
 from allauth.account import app_settings
+from allauth.account.adapter import get_adapter
 from allauth.account.utils import get_next_redirect_url
 from allauth.core.internal.http import deserialize_request, serialize_request
 from allauth.utils import import_callable
@@ -60,7 +61,7 @@ def reauthenticate_then_callback(request, serialize_state, callback):
 def did_recently_authenticate(request):
     if request.user.is_anonymous:
         return False
-    if not request.user.has_usable_password():
+    if not get_adapter().get_reauthentication_methods(request.user):
         # TODO: This user only has social accounts attached. Now, ideally, you
         # would want to reauthenticate over at the social account provider. For
         # now, this is not implemented. Although definitely suboptimal, this
diff --git a/allauth/account/views.py b/allauth/account/views.py
index 2d08bfb4..83e48b8e 100644
--- a/allauth/account/views.py
+++ b/allauth/account/views.py
@@ -2,6 +2,7 @@ from django.contrib import messages
 from django.contrib.auth import REDIRECT_FIELD_NAME
 from django.contrib.auth.decorators import login_required
 from django.contrib.sites.shortcuts import get_current_site
+from django.core.exceptions import PermissionDenied
 from django.core.validators import validate_email
 from django.forms import ValidationError
 from django.http import (
@@ -981,23 +982,37 @@ class EmailVerificationSentView(TemplateView):
 email_verification_sent = EmailVerificationSentView.as_view()
 
 
-class ReauthenticateView(FormView):
-    form_class = ReauthenticateForm
-    template_name = "account/reauthenticate." + app_settings.TEMPLATE_EXTENSION
+class BaseReauthenticateView(FormView):
     redirect_field_name = REDIRECT_FIELD_NAME
 
     def dispatch(self, request, *args, **kwargs):
-        r429 = ratelimit.consume_or_429(
+        resp = self._check_reauthentication_method_available(request)
+        if resp:
+            return resp
+        resp = self._check_ratelimit(request)
+        if resp:
+            return resp
+        return super().dispatch(request, *args, **kwargs)
+
+    def _check_ratelimit(self, request):
+        return ratelimit.consume_or_429(
             self.request,
             action="reauthenticate",
             user=self.request.user,
         )
-        if r429:
-            return r429
-        return super().dispatch(request, *args, **kwargs)
 
-    def get_form_class(self):
-        return get_form_class(app_settings.FORMS, "reauthenticate", self.form_class)
+    def _check_reauthentication_method_available(self, request):
+        methods = get_adapter().get_reauthentication_methods(self.request.user)
+        if any([m["url"] == request.path for m in methods]):
+            # Method is available
+            return None
+        if not methods:
+            # Reauthentication not available
+            raise PermissionDenied("Reauthentication not available")
+        url = passthrough_next_redirect_url(
+            request, methods[0]["url"], self.redirect_field_name
+        )
+        return HttpResponseRedirect(url)
 
     def get_success_url(self):
         url = get_next_redirect_url(self.request, self.redirect_field_name)
@@ -1005,11 +1020,6 @@ class ReauthenticateView(FormView):
             url = get_adapter(self.request).get_login_redirect_url(self.request)
         return url
 
-    def get_form_kwargs(self):
-        ret = super().get_form_kwargs()
-        ret["user"] = self.request.user
-        return ret
-
     def form_valid(self, form):
         record_authentication(self.request, self.request.user)
         response = resume_request(self.request)
@@ -1018,15 +1028,43 @@ class ReauthenticateView(FormView):
         return super().form_valid(form)
 
     def get_context_data(self, **kwargs):
-        ret = super(ReauthenticateView, self).get_context_data(**kwargs)
+        ret = super().get_context_data(**kwargs)
         redirect_field_value = get_request_param(self.request, self.redirect_field_name)
         ret.update(
             {
                 "redirect_field_name": self.redirect_field_name,
                 "redirect_field_value": redirect_field_value,
+                "reauthentication_alternatives": self.get_reauthentication_alternatives(),
             }
         )
         return ret
 
+    def get_reauthentication_alternatives(self):
+        methods = get_adapter().get_reauthentication_methods(self.request.user)
+        alts = []
+        for method in methods:
+            alt = dict(method)
+            if self.request.path == alt["url"]:
+                continue
+            alt["url"] = passthrough_next_redirect_url(
+                self.request, alt["url"], self.redirect_field_name
+            )
+            alts.append(alt)
+        alts = sorted(alts, key=lambda alt: alt["description"])
+        return alts
+
+
+class ReauthenticateView(BaseReauthenticateView):
+    form_class = ReauthenticateForm
+    template_name = "account/reauthenticate." + app_settings.TEMPLATE_EXTENSION
+
+    def get_form_class(self):
+        return get_form_class(app_settings.FORMS, "reauthenticate", self.form_class)
+
+    def get_form_kwargs(self):
+        ret = super().get_form_kwargs()
+        ret["user"] = self.request.user
+        return ret
+
 
 reauthenticate = login_required(ReauthenticateView.as_view())
diff --git a/allauth/locale/pl/LC_MESSAGES/django.po b/allauth/locale/pl/LC_MESSAGES/django.po
index 56191102..d6083605 100644
--- a/allauth/locale/pl/LC_MESSAGES/django.po
+++ b/allauth/locale/pl/LC_MESSAGES/django.po
@@ -6,18 +6,18 @@ msgid ""
 msgstr ""
 "Project-Id-Version: \n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2023-11-03 04:26-0500\n"
+"POT-Creation-Date: 2023-11-07 10:24-0600\n"
 "PO-Revision-Date: 2020-10-12 12:34+0200\n"
-"Last-Translator: Radek Czajka <radekczajka@mdrn.pl>\n"
+"Last-Translator: Krzysztof Hoffmann <krzysiekpl@gmail.com>\n"
 "Language-Team: \n"
 "Language: pl\n"
 "MIME-Version: 1.0\n"
 "Content-Type: text/plain; charset=UTF-8\n"
 "Content-Transfer-Encoding: 8bit\n"
 "X-Generator: Poedit 2.3\n"
-"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n"
-"%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n"
-"%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
+"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && "
+"(n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && "
+"n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
 
 #: account/adapter.py:51
 msgid "Username can not be used. Please use other username."
@@ -46,111 +46,111 @@ msgstr "Hasło musi składać się z co najmniej {0} znaków."
 msgid "Accounts"
 msgstr "Konta"
 
-#: account/forms.py:61 account/forms.py:445
+#: account/forms.py:59 account/forms.py:443
 msgid "You must type the same password each time."
 msgstr "Musisz wpisać za każdym razem to samo hasło."
 
-#: account/forms.py:93 account/forms.py:408 account/forms.py:547
-#: account/forms.py:685
+#: account/forms.py:91 account/forms.py:406 account/forms.py:546
+#: account/forms.py:684
 msgid "Password"
 msgstr "Hasło"
 
-#: account/forms.py:94
+#: account/forms.py:92
 msgid "Remember Me"
 msgstr "Pamiętaj mnie"
 
-#: account/forms.py:98
+#: account/forms.py:96
 msgid "This account is currently inactive."
 msgstr "Konto jest obecnie nieaktywne."
 
-#: account/forms.py:100
+#: account/forms.py:98
 msgid "The email address and/or password you specified are not correct."
 msgstr "Podany adres e-mail i/lub hasło są niepoprawne."
 
-#: account/forms.py:103
+#: account/forms.py:101
 msgid "The username and/or password you specified are not correct."
 msgstr "Podana nazwa użytkownika i/lub hasło są niepoprawne."
 
-#: account/forms.py:114 account/forms.py:283 account/forms.py:472
-#: account/forms.py:567
+#: account/forms.py:112 account/forms.py:281 account/forms.py:471
+#: account/forms.py:566
 msgid "Email address"
 msgstr "Adres e-mail"
 
-#: account/forms.py:118 account/forms.py:321 account/forms.py:469
-#: account/forms.py:562
+#: account/forms.py:116 account/forms.py:319 account/forms.py:468
+#: account/forms.py:561
 msgid "Email"
 msgstr "E-mail"
 
-#: account/forms.py:121 account/forms.py:124 account/forms.py:273
-#: account/forms.py:276
+#: account/forms.py:119 account/forms.py:122 account/forms.py:271
+#: account/forms.py:274
 msgid "Username"
 msgstr "Nazwa użytkownika"
 
-#: account/forms.py:134
+#: account/forms.py:132
 msgid "Username or email"
 msgstr "Nazwa użytkownika lub e-mail"
 
-#: account/forms.py:137
+#: account/forms.py:135
 msgctxt "field label"
 msgid "Login"
 msgstr "Login"
 
-#: account/forms.py:148
+#: account/forms.py:146
 #, fuzzy
 #| msgid "Forgot Password?"
 msgid "Forgot your password?"
 msgstr "Nie pamiętasz hasła?"
 
-#: account/forms.py:312
+#: account/forms.py:310
 msgid "Email (again)"
 msgstr "E-mail (ponownie)"
 
-#: account/forms.py:316
+#: account/forms.py:314
 msgid "Email address confirmation"
 msgstr "Powierdzenie adresu email"
 
-#: account/forms.py:324
+#: account/forms.py:322
 msgid "Email (optional)"
 msgstr "E-mail (opcjonalnie)"
 
-#: account/forms.py:379
+#: account/forms.py:377
 msgid "You must type the same email each time."
 msgstr "Musisz wpisać za każdym razem ten sam e-mail."
 
-#: account/forms.py:414 account/forms.py:550
+#: account/forms.py:412 account/forms.py:549
 msgid "Password (again)"
 msgstr "Hasło (ponownie)"
 
-#: account/forms.py:484
+#: account/forms.py:483
 msgid "This email address is already associated with this account."
 msgstr "Ten adres e-mail jest już powiązany z tym kontem."
 
-#: account/forms.py:486
+#: account/forms.py:485
 #, python-format
 msgid "You cannot add more than %d email addresses."
 msgstr "Nie możesz dodać więcej niż %d adresów e-mail."
 
-#: account/forms.py:524
+#: account/forms.py:523
 msgid "Current Password"
 msgstr "Obecne hasło"
 
-#: account/forms.py:527 account/forms.py:634
+#: account/forms.py:526 account/forms.py:633
 msgid "New Password"
 msgstr "Nowe hasło"
 
-#: account/forms.py:530 account/forms.py:635
+#: account/forms.py:529 account/forms.py:634
 msgid "New Password (again)"
 msgstr "Nowe hasło (ponownie)"
 
-#: account/forms.py:538
+#: account/forms.py:537
 msgid "Please type your current password."
 msgstr "Proszę wpisz swoje obecne hasło."
 
-#: account/forms.py:579
+#: account/forms.py:578
 msgid "The email address is not assigned to any user account"
 msgstr "Adres e-mail nie jest powiązany z żadnym kontem użytkownika"
 
-#: account/forms.py:655
+#: account/forms.py:654
 msgid "The password reset token was invalid."
 msgstr "Token resetowania hasła był nieprawidłowy."
 
@@ -182,7 +182,7 @@ msgstr "utworzono"
 msgid "sent"
 msgstr "wysłano"
 
-#: account/models.py:143 socialaccount/models.py:65
+#: account/models.py:143 socialaccount/models.py:63
 msgid "key"
 msgstr "klucz"
 
@@ -199,38 +199,42 @@ msgid ""
 "You cannot activate two-factor authentication until you have verified your "
 "email address."
 msgstr ""
+"Nie możesz aktywować uwierzytelniania dwuskładnikowego dopóki nie "
+"zweryfikujesz swojego adresu e-mail."
 
 #: mfa/adapter.py:22
 msgid ""
 "You cannot add an email address to an account protected by two-factor "
 "authentication."
 msgstr ""
+"Nie można dodać adresu e-mail do konta chronionego dwuskładnikową "
+"autoryzacją."
 
 #: mfa/adapter.py:24
 msgid "Incorrect code."
-msgstr ""
+msgstr "Błędny kod."
 
 #: mfa/apps.py:7
 msgid "MFA"
-msgstr ""
+msgstr "MFA"
 
 #: mfa/forms.py:15 mfa/forms.py:17
 msgid "Code"
-msgstr ""
+msgstr "Kod"
 
-#: mfa/forms.py:48
+#: mfa/forms.py:51
 msgid "Authenticator code"
-msgstr ""
+msgstr "Kod autentykacyjny"
 
-#: mfa/models.py:15
+#: mfa/models.py:16
 msgid "Recovery codes"
-msgstr ""
+msgstr "Kody odzyskiwania"
 
-#: mfa/models.py:16
+#: mfa/models.py:17
 msgid "TOTP Authenticator"
-msgstr ""
+msgstr "Autentykator TOTP"
 
-#: socialaccount/adapter.py:32
+#: socialaccount/adapter.py:30
 #, fuzzy, python-format
 #| msgid ""
 #| "An account already exists with this e-mail address. Please sign in to "
@@ -242,11 +246,11 @@ msgstr ""
 "Istnieje już konto dla tego adresu e-mail. Zaloguj się najpierw na to konto, "
 "a następnie połącz swoje konto %s."
 
-#: socialaccount/adapter.py:138
+#: socialaccount/adapter.py:136
 msgid "Your account has no password set up."
 msgstr "Twoje konto nie posiada hasła."
 
-#: socialaccount/adapter.py:145
+#: socialaccount/adapter.py:143
 msgid "Your account has no verified email address."
 msgstr "Twoje konto nie ma zweryfikowanego adresu e-mail."
 
@@ -254,97 +258,97 @@ msgstr "Twoje konto nie ma zweryfikowanego adresu e-mail."
 msgid "Social Accounts"
 msgstr "Konta społecznościowe"
 
-#: socialaccount/models.py:39 socialaccount/models.py:93
+#: socialaccount/models.py:37 socialaccount/models.py:91
 msgid "provider"
 msgstr "dostawca usług"
 
-#: socialaccount/models.py:48
+#: socialaccount/models.py:46
 #, fuzzy
 #| msgid "provider"
 msgid "provider ID"
 msgstr "dostawca usług"
 
-#: socialaccount/models.py:52
+#: socialaccount/models.py:50
 msgid "name"
 msgstr "nazwa"
 
-#: socialaccount/models.py:54
+#: socialaccount/models.py:52
 msgid "client id"
 msgstr "id klienta"
 
-#: socialaccount/models.py:56
+#: socialaccount/models.py:54
 msgid "App ID, or consumer key"
 msgstr "ID aplikacji lub klucz odbiorcy"
 
-#: socialaccount/models.py:59
+#: socialaccount/models.py:57
 msgid "secret key"
 msgstr "klucz prywatny"
 
-#: socialaccount/models.py:62
+#: socialaccount/models.py:60
 msgid "API secret, client secret, or consumer secret"
 msgstr "Klucz prywatny API, klienta lub odbiorcy"
 
-#: socialaccount/models.py:65
+#: socialaccount/models.py:63
 msgid "Key"
 msgstr "Klucz"
 
-#: socialaccount/models.py:77
+#: socialaccount/models.py:75
 msgid "social application"
 msgstr "aplikacja społecznościowa"
 
-#: socialaccount/models.py:78
+#: socialaccount/models.py:76
 msgid "social applications"
 msgstr "aplikacje społecznościowe"
 
-#: socialaccount/models.py:113
+#: socialaccount/models.py:111
 msgid "uid"
 msgstr "uid"
 
-#: socialaccount/models.py:115
+#: socialaccount/models.py:113
 msgid "last login"
 msgstr "data ostatniego logowania"
 
-#: socialaccount/models.py:116
+#: socialaccount/models.py:114
 msgid "date joined"
 msgstr "data przyłączenia"
 
-#: socialaccount/models.py:117
+#: socialaccount/models.py:115
 msgid "extra data"
 msgstr "dodatkowe dane"
 
-#: socialaccount/models.py:121
+#: socialaccount/models.py:119
 msgid "social account"
 msgstr "konto społecznościowe"
 
-#: socialaccount/models.py:122
+#: socialaccount/models.py:120
 msgid "social accounts"
 msgstr "konta społecznościowe"
 
-#: socialaccount/models.py:156
+#: socialaccount/models.py:154
 msgid "token"
 msgstr "token"
 
-#: socialaccount/models.py:157
+#: socialaccount/models.py:155
 msgid "\"oauth_token\" (OAuth1) or access token (OAuth2)"
 msgstr "\"oauth_token\" (OAuth1) lub access token (OAuth2)"
 
-#: socialaccount/models.py:161
+#: socialaccount/models.py:159
 msgid "token secret"
 msgstr "token secret"
 
-#: socialaccount/models.py:162
+#: socialaccount/models.py:160
 msgid "\"oauth_token_secret\" (OAuth1) or refresh token (OAuth2)"
 msgstr "\"oauth_token_secret\" (OAuth1) lub refresh token (OAuth2)"
 
-#: socialaccount/models.py:165
+#: socialaccount/models.py:163
 msgid "expires at"
 msgstr "wygasa"
 
-#: socialaccount/models.py:170
+#: socialaccount/models.py:168
 msgid "social application token"
 msgstr "token aplikacji społecznościowej"
 
-#: socialaccount/models.py:171
+#: socialaccount/models.py:169
 msgid "social application tokens"
 msgstr "tokeny aplikacji społecznościowych"
 
@@ -455,10 +459,20 @@ msgid ""
 "\n"
 "%(password_reset_url)s"
 msgstr ""
+"Otrzymujesz tę wiadomość e-mail, ponieważ Ty lub ktoś inny próbował "
+"zarejestrować konto używając adresu e-mail:\n"
+"\n"
+"%(email)s\n"
+"\n"
+"Jednakże, konto z tym adresem e-mail już istnieje. Jeśli o tym zapomniałeś, "
+"proszę skorzystaj z procedury odzyskiwania hasła, aby odzyskać dostęp do "
+"swojego konta:\n"
+"\n"
+"%(password_reset_url)s"
 
 #: templates/account/email/account_already_exists_subject.txt:3
 msgid "Account Already Exists"
-msgstr ""
+msgstr "Konto już istnieje."
 
 #: templates/account/email/base_message.txt:1
 #, python-format
@@ -612,14 +626,14 @@ msgstr "To konto społecznościowe jest już połączone z innym kontem."
 #: templates/account/email_confirm.html:35
 #, fuzzy, python-format
 #| msgid ""
-#| "This e-mail confirmation link expired or is invalid. Please <a href="
-#| "\"%(email_url)s\">issue a new e-mail confirmation request</a>."
+#| "This e-mail confirmation link expired or is invalid. Please <a "
+#| "href=\"%(email_url)s\">issue a new e-mail confirmation request</a>."
 msgid ""
-"This email confirmation link expired or is invalid. Please <a href="
-"\"%(email_url)s\">issue a new email confirmation request</a>."
+"This email confirmation link expired or is invalid. Please <a "
+"href=\"%(email_url)s\">issue a new email confirmation request</a>."
 msgstr ""
-"Łącze z adresem do zresetowania hasła wygasło lub jest niepoprawne.  <a href="
-"\"%(email_url)s\">wygenerować nowe łącze dla Twojego adresu. </a>."
+"Łącze z adresem do zresetowania hasła wygasło lub jest niepoprawne.  <a "
+"href=\"%(email_url)s\">wygenerować nowe łącze dla Twojego adresu. </a>."
 
 #: templates/account/login.html:5 templates/account/login.html:9
 #: templates/account/login.html:29 templates/allauth/layouts/base.html:36
@@ -638,8 +652,8 @@ msgid ""
 "If you have not created an account yet, then please\n"
 "    <a href=\"%(signup_url)s\">sign up</a> first."
 msgstr ""
-"Jeżeli nie masz jeszcze konta, to proszę <a href=\"%(signup_url)s"
-"\">zarejestruj się</a>."
+"Jeżeli nie masz jeszcze konta, to proszę <a "
+"href=\"%(signup_url)s\">zarejestruj się</a>."
 
 #: templates/account/logout.html:4 templates/account/logout.html:8
 #: templates/account/logout.html:23 templates/allauth/layouts/base.html:32
@@ -784,7 +798,7 @@ msgstr "Potwierdź adres e-mail"
 
 #: templates/account/reauthenticate.html:12
 msgid "To safeguard the security of your account, please enter your password:"
-msgstr ""
+msgstr "Dla bezpieczeństwa konta, prosimy o podanie hasła:"
 
 #: templates/account/signup.html:4 templates/socialaccount/signup.html:5
 msgid "Signup"
@@ -875,7 +889,7 @@ msgid ""
 "verify ownership of your email address. "
 msgstr ""
 "Ta część strony wymaga weryfikacji tego, kim jesteś. Dlatego wymagamy "
-"weryfikacji Twojego adresu e-mail. "
+"weryfikacji Twojego adresu e-mail."
 
 #: templates/account/verified_email_required.html:18
 #, fuzzy
@@ -908,48 +922,52 @@ msgstr ""
 
 #: templates/allauth/layouts/base.html:18
 msgid "Messages:"
-msgstr ""
+msgstr "Wiadomości:"
 
 #: templates/allauth/layouts/base.html:25
 msgid "Menu:"
-msgstr ""
+msgstr "Menu:"
 
 #: templates/mfa/authenticate.html:9 templates/mfa/index.html:5
 #: templates/mfa/index.html:9
 msgid "Two-Factor Authentication"
-msgstr ""
+msgstr "Autoryzacja dwuskładnikowa"
 
 #: templates/mfa/authenticate.html:12
 msgid ""
 "Your account is protected by two-factor authentication. Please enter an "
 "authenticator code:"
-msgstr ""
+msgstr "Twoje konto jest chronione przez uwierzytelnianie dwuskładnikowe. Proszę wprowadzić kod uwierzytelniający:"
+
+#: templates/mfa/authenticate.html:27
+msgid "Cancel"
+msgstr "Anuluj"
 
 #: templates/mfa/index.html:13 templates/mfa/totp/base.html:4
 msgid "Authenticator App"
-msgstr ""
+msgstr "Aplikacja autoryzacyjna"
 
 #: templates/mfa/index.html:17
 msgid "Authentication using an authenticator app is active."
-msgstr ""
+msgstr "Autoryzacja za pomocą aplikacji autoryzacyjnej jest aktywna."
 
 #: templates/mfa/index.html:19
 msgid "An authenticator app is not active."
-msgstr ""
+msgstr "Aplikacja autoryzacyjna nie jest aktywna."
 
 #: templates/mfa/index.html:27 templates/mfa/totp/deactivate_form.html:24
 msgid "Deactivate"
-msgstr ""
+msgstr "Dezaktywuj"
 
 #: templates/mfa/index.html:31 templates/mfa/totp/activate_form.html:32
 msgid "Activate"
-msgstr ""
+msgstr "Aktywuj"
 
 #: templates/mfa/index.html:39 templates/mfa/recovery_codes/base.html:4
 #: templates/mfa/recovery_codes/generate.html:6
 #: templates/mfa/recovery_codes/index.html:6
 msgid "Recovery Codes"
-msgstr ""
+msgstr "Kody odzyskiwania"
 
 #: templates/mfa/index.html:44 templates/mfa/recovery_codes/index.html:9
 #, python-format
@@ -958,66 +976,67 @@ msgid ""
 msgid_plural ""
 "There are %(unused_count)s out of %(total_count)s recovery codes available."
 msgstr[0] ""
+"Dostępnych jest %(unused_count)s z %(total_count)s kodów odzyskiwania."
 msgstr[1] ""
 msgstr[2] ""
 msgstr[3] ""
 
 #: templates/mfa/index.html:47
 msgid "No recovery codes set up."
-msgstr ""
+msgstr "Nie ustawiono żadnych kodów odzyskiwania."
 
 #: templates/mfa/index.html:56
 msgid "View"
-msgstr ""
+msgstr "Widok"
 
 #: templates/mfa/index.html:62
 msgid "Download"
-msgstr ""
+msgstr "Pobierz"
 
 #: templates/mfa/index.html:70 templates/mfa/recovery_codes/generate.html:29
 msgid "Generate"
-msgstr ""
+msgstr "Generuj"
 
 #: templates/mfa/messages/recovery_codes_generated.txt:2
 msgid "A new set of recovery codes has been generated."
-msgstr ""
+msgstr "Wygenerowano nowy zestaw kodów odzyskiwania."
 
 #: templates/mfa/messages/totp_activated.txt:2
 msgid "Authenticator app activated."
-msgstr ""
+msgstr "Aplikacja autoryzacyjna została aktywowana."
 
 #: templates/mfa/messages/totp_deactivated.txt:2
 msgid "Authenticator app deactivated."
-msgstr ""
+msgstr "Aplikacja autoryzacyjna została dezaktywowana."
 
 #: templates/mfa/recovery_codes/generate.html:9
 msgid "You are about to generate a new set of recovery codes for your account."
-msgstr ""
+msgstr "Zaraz wygenerujesz nowy zestaw kodów odzyskiwania dla Twojego konta."
 
 #: templates/mfa/recovery_codes/generate.html:11
 msgid "This action will invalidate your existing codes."
-msgstr ""
+msgstr "Ta czynność spowoduje unieważnienie istniejących kodów."
 
 #: templates/mfa/recovery_codes/generate.html:13
 msgid "Are you sure?"
-msgstr ""
+msgstr "Jesteś pewny?"
 
 #: templates/mfa/recovery_codes/index.html:13
 msgid "Unused codes"
-msgstr ""
+msgstr "Niewykorzystane kody"
 
 #: templates/mfa/recovery_codes/index.html:25
 msgid "Download codes"
-msgstr ""
+msgstr "Pobierz kody"
 
 #: templates/mfa/recovery_codes/index.html:30
 msgid "Generate new codes"
-msgstr ""
+msgstr "Wygeneruj nowe kody."
 
 #: templates/mfa/totp/activate_form.html:4
 #: templates/mfa/totp/activate_form.html:8
 msgid "Activate Authenticator App"
-msgstr ""
+msgstr "Aktywuj aplikację uwierzytelniającą."
 
 #: templates/mfa/totp/activate_form.html:11
 msgid ""
@@ -1025,6 +1044,9 @@ msgid ""
 "below with your authenticator app. Then, input the verification code "
 "generated by the app below."
 msgstr ""
+"Aby zabezpieczyć swoje konto za pomocą uwierzytelniania dwuskładnikowego, "
+"zeskanuj poniższy kod QR za pomocą aplikacji uwierzytelniającej. Następnie "
+"wprowadź poniżej kod weryfikacyjny wygenerowany przez aplikację."
 
 #: templates/mfa/totp/activate_form.html:21
 #, fuzzy
@@ -1037,17 +1059,21 @@ msgid ""
 "You can store this secret and use it to reinstall your authenticator app at "
 "a later time."
 msgstr ""
+"Możesz przechować ten sekret i użyć go później do ponownej instalacji "
+"aplikacji uwierzytelniającej."
 
 #: templates/mfa/totp/deactivate_form.html:5
 #: templates/mfa/totp/deactivate_form.html:9
 msgid "Deactivate Authenticator App"
-msgstr ""
+msgstr "Wyłącz aplikację uwierzytelniającą."
 
 #: templates/mfa/totp/deactivate_form.html:12
 msgid ""
 "You are about to deactivate authenticator app based authentication. Are you "
 "sure?"
 msgstr ""
+"Zamierzasz wyłączyć uwierzytelnianie oparte na aplikacji Authenticator. "
+"Jesteś pewien?"
 
 #: templates/socialaccount/authentication_error.html:5
 #: templates/socialaccount/authentication_error.html:9
@@ -1092,26 +1118,28 @@ msgstr "Dodaj konto zewnętrzne"
 #: templates/socialaccount/login.html:10
 #, python-format
 msgid "Connect %(provider)s"
-msgstr ""
+msgstr "Połącz z %(provider)s."
 
 #: templates/socialaccount/login.html:13
 #, python-format
 msgid "You are about to connect a new third-party account from %(provider)s."
-msgstr ""
+msgstr "Zamierzasz połączyć nowe konto zewnętrzne od dostawcy %(provider)s."
 
 #: templates/socialaccount/login.html:17
 #, python-format
 msgid "Sign In Via %(provider)s"
-msgstr ""
+msgstr "Zaloguj się za pomocą %(provider)s."
 
 #: templates/socialaccount/login.html:20
 #, python-format
 msgid "You are about to sign in using a third-party account from %(provider)s."
 msgstr ""
+"Zamierzasz zalogować się przy użyciu konta zewnętrznego dostawcy "
+"%(provider)s."
 
 #: templates/socialaccount/login.html:27
 msgid "Continue"
-msgstr ""
+msgstr "Kontynuuj"
 
 #: templates/socialaccount/login_cancelled.html:5
 #: templates/socialaccount/login_cancelled.html:9
@@ -1122,8 +1150,8 @@ msgstr "Logowanie anulowane"
 #, python-format
 msgid ""
 "You decided to cancel logging in to our site using one of your existing "
-"accounts. If this was a mistake, please proceed to <a href=\"%(login_url)s"
-"\">sign in</a>."
+"accounts. If this was a mistake, please proceed to <a "
+"href=\"%(login_url)s\">sign in</a>."
 msgstr ""
 "Wybrano anulowanie logowania przez jedno z istniejących kont. Jeżeli to była "
 "pomyłka, proszę przejdź do <a href=\"%(login_url)s\">zaloguj</a>."
@@ -1151,13 +1179,13 @@ msgstr ""
 
 #: templates/socialaccount/snippets/login.html:8
 msgid "Or use a third-party"
-msgstr ""
+msgstr "Lub skorzystaj z serwisów społecznościowych"
 
 #, python-format
 #~ msgid ""
 #~ "Please sign in with one\n"
-#~ "of your existing third party accounts. Or, <a href=\"%(signup_url)s"
-#~ "\">sign up</a>\n"
+#~ "of your existing third party accounts. Or, <a "
+#~ "href=\"%(signup_url)s\">sign up</a>\n"
 #~ "for a %(site_name)s account and sign in below:"
 #~ msgstr ""
 #~ "Proszę zaloguj się jednym\n"
@@ -1197,11 +1225,6 @@ msgstr ""
 #~ msgid "This username is already taken. Please choose another."
 #~ msgstr "Nazwa użytkownika jest już w użyciu. Proszę wybierz inną."
 
-#, fuzzy
-#~| msgid "Sign In"
-#~ msgid "Shopify Sign In"
-#~ msgstr "Zaloguj"
-
 #~ msgid ""
 #~ "You have confirmed that <a href=\"mailto:%(email)s\">%(email)s</a> is an "
 #~ "e-mail address for user %(user_display)s."
diff --git a/allauth/mfa/models.py b/allauth/mfa/models.py
index ada2b493..535292f2 100644
--- a/allauth/mfa/models.py
+++ b/allauth/mfa/models.py
@@ -6,9 +6,12 @@ from django.utils.translation import gettext_lazy as _
 
 class AuthenticatorManager(models.Manager):
     def delete_dangling_recovery_codes(self, user):
+        deleted_authenticator = None
         qs = Authenticator.objects.filter(user=user)
         if not qs.exclude(type=Authenticator.Type.RECOVERY_CODES).exists():
+            deleted_authenticator = qs.first()
             qs.delete()
+        return deleted_authenticator
 
 
 class Authenticator(models.Model):
diff --git a/allauth/mfa/signals.py b/allauth/mfa/signals.py
index 7eba221c..4eb4bd1d 100644
--- a/allauth/mfa/signals.py
+++ b/allauth/mfa/signals.py
@@ -1,9 +1,23 @@
+from django.dispatch import Signal
 from django.forms import ValidationError
 
 from allauth.mfa.adapter import get_adapter
 from allauth.mfa.utils import is_mfa_enabled
 
 
+# Emitted when an authenticator is added.
+# Arguments: request, user, authenticator
+authenticator_added = Signal()
+
+# Emitted when an authenticator is removed.
+# Arguments: request, user, authenticator
+authenticator_removed = Signal()
+
+# Emitted when an authenticator is reset (e.g. recovery codes regenerated).
+# Arguments: request, user, authenticator
+authenticator_reset = Signal()
+
+
 def on_add_email(sender, email, user, **kwargs):
     if is_mfa_enabled(user):
         adapter = get_adapter()
diff --git a/allauth/mfa/totp.py b/allauth/mfa/totp.py
index 9978a043..6e2620de 100644
--- a/allauth/mfa/totp.py
+++ b/allauth/mfa/totp.py
@@ -102,7 +102,6 @@ class TOTP:
 
     def deactivate(self):
         self.instance.delete()
-        Authenticator.objects.delete_dangling_recovery_codes(self.instance.user)
 
     def validate_code(self, code):
         secret = decrypt(self.instance.data["secret"])
diff --git a/allauth/mfa/urls.py b/allauth/mfa/urls.py
index 04c99bde..3186a29d 100644
--- a/allauth/mfa/urls.py
+++ b/allauth/mfa/urls.py
@@ -6,6 +6,7 @@ from allauth.mfa import views
 urlpatterns = [
     path("", views.index, name="mfa_index"),
     path("authenticate/", views.authenticate, name="mfa_authenticate"),
+    path("reauthenticate/", views.reauthenticate, name="mfa_reauthenticate"),
     path(
         "totp/",
         include(
diff --git a/allauth/mfa/views.py b/allauth/mfa/views.py
index 218ee66d..a41732f1 100644
--- a/allauth/mfa/views.py
+++ b/allauth/mfa/views.py
@@ -14,7 +14,8 @@ from allauth.account import app_settings as account_settings
 from allauth.account.adapter import get_adapter as get_account_adapter
 from allauth.account.decorators import reauthentication_required
 from allauth.account.stages import LoginStageController
-from allauth.mfa import app_settings, totp
+from allauth.account.views import BaseReauthenticateView
+from allauth.mfa import app_settings, signals, totp
 from allauth.mfa.adapter import get_adapter
 from allauth.mfa.forms import ActivateTOTPForm, AuthenticateForm
 from allauth.mfa.models import Authenticator
@@ -48,6 +49,20 @@ class AuthenticateView(FormView):
 authenticate = AuthenticateView.as_view()
 
 
+@method_decorator(login_required, name="dispatch")
+class ReauthenticateView(BaseReauthenticateView):
+    form_class = AuthenticateForm
+    template_name = "mfa/reauthenticate." + account_settings.TEMPLATE_EXTENSION
+
+    def get_form_kwargs(self):
+        ret = super().get_form_kwargs()
+        ret["user"] = self.request.user
+        return ret
+
+
+reauthenticate = ReauthenticateView.as_view()
+
+
 @method_decorator(login_required, name="dispatch")
 class IndexView(TemplateView):
     template_name = "mfa/index." + account_settings.TEMPLATE_EXTENSION
@@ -103,8 +118,14 @@ class ActivateTOTPView(FormView):
         return ret
 
     def form_valid(self, form):
-        totp.TOTP.activate(self.request.user, form.secret)
-        RecoveryCodes.activate(self.request.user)
+        totp_auth = totp.TOTP.activate(self.request.user, form.secret)
+        rc_auth = RecoveryCodes.activate(self.request.user)
+        for auth in [totp_auth, rc_auth]:
+            signals.authenticator_added.send(
+                sender=Authenticator,
+                user=self.request.user,
+                authenticator=auth.instance,
+            )
         adapter = get_account_adapter(self.request)
         adapter.add_message(
             self.request, messages.SUCCESS, "mfa/messages/totp_activated.txt"
@@ -141,6 +162,16 @@ class DeactivateTOTPView(FormView):
 
     def form_valid(self, form):
         self.authenticator.wrap().deactivate()
+        rc_auth = Authenticator.objects.delete_dangling_recovery_codes(
+            self.authenticator.user
+        )
+        for auth in [self.authenticator, rc_auth]:
+            if auth:
+                signals.authenticator_removed.send(
+                    sender=Authenticator,
+                    user=self.request.user,
+                    authenticator=auth,
+                )
         adapter = get_account_adapter(self.request)
         adapter.add_message(
             self.request, messages.SUCCESS, "mfa/messages/totp_deactivated.txt"
@@ -161,11 +192,14 @@ class GenerateRecoveryCodesView(FormView):
         Authenticator.objects.filter(
             user=self.request.user, type=Authenticator.Type.RECOVERY_CODES
         ).delete()
-        RecoveryCodes.activate(self.request.user)
+        rc_auth = RecoveryCodes.activate(self.request.user)
         adapter = get_account_adapter(self.request)
         adapter.add_message(
             self.request, messages.SUCCESS, "mfa/messages/recovery_codes_generated.txt"
         )
+        signals.authenticator_reset.send(
+            sender=Authenticator, user=self.request.user, authenticator=rc_auth.instance
+        )
         return super().form_valid(form)
 
     def get_context_data(self, **kwargs):
diff --git a/allauth/templates/account/base_reauthenticate.html b/allauth/templates/account/base_reauthenticate.html
new file mode 100644
index 00000000..65d4c495
--- /dev/null
+++ b/allauth/templates/account/base_reauthenticate.html
@@ -0,0 +1,27 @@
+{% extends "account/base_entrance.html" %}
+{% load allauth %}
+{% load i18n %}
+{% block head_title %}
+    {% trans "Confirm Access" %}
+{% endblock head_title %}
+{% block content %}
+    {% element h1 %}
+        {% trans "Confirm Access" %}
+    {% endelement %}
+    <p>{% blocktranslate %}Please reauthenticate to safeguard your account.{% endblocktranslate %}</p>
+    {% block reauthenticate_content %}{% endblock %}
+    {% if reauthentication_alternatives %}
+        {% element hr %}
+        {% endelement %}
+        {% element h2 %}
+            {% translate "Alternative options" %}
+        {% endelement %}
+        <ul>
+            {% for alt in reauthentication_alternatives %}
+                <li>
+                    <a href="{{ alt.url }}">{{ alt.description }}</a>
+                </li>
+            {% endfor %}
+        </ul>
+    {% endif %}
+{% endblock content %}
diff --git a/allauth/templates/account/reauthenticate.html b/allauth/templates/account/reauthenticate.html
index 16ae1fcc..38c22def 100644
--- a/allauth/templates/account/reauthenticate.html
+++ b/allauth/templates/account/reauthenticate.html
@@ -1,21 +1,13 @@
-{% extends "account/base_manage.html" %}
+{% extends "account/base_reauthenticate.html" %}
 {% load allauth %}
 {% load i18n %}
-{% block head_title %}
-    {% trans "Confirm Access" %}
-{% endblock head_title %}
-{% block content %}
-    {% element h1 %}
-        {% trans "Confirm Access" %}
-    {% endelement %}
-    <p>
-        {% blocktranslate %}To safeguard the security of your account, please enter your password:{% endblocktranslate %}
-    </p>
+{% block reauthenticate_content %}
+    <p>{% blocktranslate %}Enter your password:{% endblocktranslate %}</p>
     {% url 'account_reauthenticate' as action_url %}
     {% element form form=form method="post" action=action_url %}
         {% slot body %}
             {% csrf_token %}
-            {% element fields form=form %}
+            {% element fields form=form unlabeled=True %}
             {% endelement %}
             {% if redirect_field_value %}
                 <input type="hidden"
@@ -29,4 +21,4 @@
             {% endelement %}
         {% endslot %}
     {% endelement %}
-{% endblock content %}
+{% endblock %}
diff --git a/allauth/templates/allauth/elements/hr.html b/allauth/templates/allauth/elements/hr.html
new file mode 100644
index 00000000..e123ba7e
--- /dev/null
+++ b/allauth/templates/allauth/elements/hr.html
@@ -0,0 +1,1 @@
+<hr>
diff --git a/allauth/templates/mfa/reauthenticate.html b/allauth/templates/mfa/reauthenticate.html
new file mode 100644
index 00000000..749af87a
--- /dev/null
+++ b/allauth/templates/mfa/reauthenticate.html
@@ -0,0 +1,24 @@
+{% extends "account/base_reauthenticate.html" %}
+{% load i18n %}
+{% load allauth %}
+{% block reauthenticate_content %}
+    <p>{% blocktranslate %}Enter an authenticator code:{% endblocktranslate %}</p>
+    {% url 'mfa_reauthenticate' as action_url %}
+    {% element form form=form method="post" action=action_url %}
+        {% slot body %}
+            {% csrf_token %}
+            {% element fields form=form unlabeled=True %}
+            {% endelement %}
+            {% if redirect_field_value %}
+                <input type="hidden"
+                       name="{{ redirect_field_name }}"
+                       value="{{ redirect_field_value }}" />
+            {% endif %}
+        {% endslot %}
+        {% slot actions %}
+            {% element button type="submit" tags="primary,mfa,login" %}
+                {% trans "Confirm" %}
+            {% endelement %}
+        {% endslot %}
+    {% endelement %}
+{% endblock %}
diff --git a/allauth/templates/socialaccount/snippets/login.html b/allauth/templates/socialaccount/snippets/login.html
index e112ef93..9ec904e8 100644
--- a/allauth/templates/socialaccount/snippets/login.html
+++ b/allauth/templates/socialaccount/snippets/login.html
@@ -3,7 +3,8 @@
 {% load socialaccount %}
 {% get_providers as socialaccount_providers %}
 {% if socialaccount_providers %}
-    <hr>
+    {% element hr %}
+    {% endelement %}
     {% element h2 %}
         {% translate "Or use a third-party" %}
     {% endelement %}


Test patch:
diff --git a/allauth/account/tests/test_reauthentication.py b/allauth/account/tests/test_reauthentication.py
new file mode 100644
index 00000000..a9a62a74
--- /dev/null
+++ b/allauth/account/tests/test_reauthentication.py
@@ -0,0 +1,33 @@
+from django.urls import reverse
+
+import pytest
+
+from allauth.account.adapter import get_adapter
+
+
+@pytest.mark.parametrize(
+    "with_totp,with_password,expected_method_urlnames",
+    [
+        (False, True, ["account_reauthenticate"]),
+        (True, True, ["account_reauthenticate", "mfa_reauthenticate"]),
+        (True, False, ["mfa_reauthenticate"]),
+    ],
+)
+def test_user_with_mfa_only(
+    user_factory, with_totp, with_password, expected_method_urlnames, client
+):
+    user = user_factory(with_totp=with_totp, password=None if with_password else "!")
+    assert user.has_usable_password() == with_password
+    client.force_login(user)
+    methods = get_adapter().get_reauthentication_methods(user)
+    assert len(methods) == len(expected_method_urlnames)
+    assert set([m["url"] for m in methods]) == set(
+        map(reverse, expected_method_urlnames)
+    )
+    for urlname in ["account_reauthenticate", "mfa_reauthenticate"]:
+        resp = client.get(reverse(urlname) + "?next=/foo")
+        if urlname in expected_method_urlnames:
+            assert resp.status_code == 200
+        else:
+            assert resp.status_code == 302
+            assert "next=%2Ffoo" in resp["location"]
diff --git a/allauth/conftest.py b/allauth/conftest.py
index 59b1f0b9..a5d36bcb 100644
--- a/allauth/conftest.py
+++ b/allauth/conftest.py
@@ -37,6 +37,8 @@ def user_password(password_factory):
 
 @pytest.fixture
 def user_factory(email_factory, db, user_password):
+    from allauth.mfa import totp
+
     def factory(
         email=None,
         username=None,
@@ -45,6 +47,7 @@ def user_factory(email_factory, db, user_password):
         email_verified=True,
         password=None,
         with_emailaddress=True,
+        with_totp=False,
     ):
         if not username:
             username = uuid.uuid4().hex
@@ -54,7 +57,10 @@ def user_factory(email_factory, db, user_password):
 
         User = get_user_model()
         user = User()
-        user.set_password(user_password if password is None else password)
+        if password == "!":
+            user.password = password
+        else:
+            user.set_password(user_password if password is None else password)
         user_username(user, username)
         user_email(user, email or "")
         if commit:
@@ -63,7 +69,8 @@ def user_factory(email_factory, db, user_password):
                 EmailAddress.objects.create(
                     user=user, email=email, verified=email_verified, primary=True
                 )
-
+        if with_totp:
+            totp.TOTP.activate(user, totp.generate_totp_secret())
         return user
 
     return factory