Repository: pypi/warehouse

Issue:
Improve 2fa error and confirmation messages
## Change webauthn error texts
Current: Your browser does not support WebAuthn

New (admin page): You cannot set up a USB security key, as your browser does not work with WebAuthn (the standard supported by PyPI)

New (login workflow): You cannot use a USB security key, as your browser does not work with WebAuthn (the standard supported by PyPI)

Current: "WebAuthn cannot be used without JavaScript."

New (admin page): "Enable JavaScript to setup 2FA with a USB security key"

New (login page): "Enable JavaScript to login to PyPI with a USB security key"

### Audit confirmation and error flash messages

- Go through [this file](https://github.com/pypa/warehouse/blob/master/warehouse/manage/views.py) to ensure flash messages are consistent with content copy, e.g. "WebAuthn successfully provisioned", should become "USB security key set up"

- Remind users to delete their TOTP entry on their phone after disabling 2fa with TOTP.


Discussion:

Resolving patch:
diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py
--- a/warehouse/manage/views.py
+++ b/warehouse/manage/views.py
@@ -337,7 +337,7 @@ def default_response(self):
     def generate_totp_qr(self):
         if not self.request.user.two_factor_provisioning_allowed:
             self.request.session.flash(
-                "Modifying 2FA requires a verified email.", queue="error"
+                "Verify your email to modify two factor authentication", queue="error"
             )
             return Response(status=403)
 
@@ -355,13 +355,17 @@ def generate_totp_qr(self):
     def totp_provision(self):
         if not self.request.user.two_factor_provisioning_allowed:
             self.request.session.flash(
-                "Modifying 2FA requires a verified email.", queue="error"
+                "Verify your email to modify two factor authentication", queue="error"
             )
             return Response(status=403)
 
         totp_secret = self.user_service.get_totp_secret(self.request.user.id)
         if totp_secret:
-            self.request.session.flash("TOTP already provisioned.", queue="error")
+            self.request.session.flash(
+                "Account cannot be linked to more than one authentication "
+                "application at a time",
+                queue="error",
+            )
             return HTTPSeeOther(self.request.route_path("manage.account"))
 
         return self.default_response
@@ -370,13 +374,17 @@ def totp_provision(self):
     def validate_totp_provision(self):
         if not self.request.user.two_factor_provisioning_allowed:
             self.request.session.flash(
-                "Modifying 2FA requires a verified email.", queue="error"
+                "Verify your email to modify two factor authentication", queue="error"
             )
             return Response(status=403)
 
         totp_secret = self.user_service.get_totp_secret(self.request.user.id)
         if totp_secret:
-            self.request.session.flash("TOTP already provisioned.", queue="error")
+            self.request.session.flash(
+                "Account cannot be linked to more than one authentication "
+                "application at a time",
+                queue="error",
+            )
             return HTTPSeeOther(self.request.route_path("manage.account"))
 
         form = ProvisionTOTPForm(
@@ -390,7 +398,7 @@ def validate_totp_provision(self):
 
             self.request.session.clear_totp_secret()
             self.request.session.flash(
-                "TOTP application successfully provisioned.", queue="success"
+                "Authentication application successfully set up", queue="success"
             )
 
             return HTTPSeeOther(self.request.route_path("manage.account"))
@@ -401,13 +409,15 @@ def validate_totp_provision(self):
     def delete_totp(self):
         if not self.request.user.two_factor_provisioning_allowed:
             self.request.session.flash(
-                "Modifying 2FA requires a verified email.", queue="error"
+                "Verify your email to modify two factor authentication", queue="error"
             )
             return Response(status=403)
 
         totp_secret = self.user_service.get_totp_secret(self.request.user.id)
         if not totp_secret:
-            self.request.session.flash("No TOTP application to delete.", queue="error")
+            self.request.session.flash(
+                "There is no authentication application to delete", queue="error"
+            )
             return HTTPSeeOther(self.request.route_path("manage.account"))
 
         form = DeleteTOTPForm(
@@ -418,9 +428,13 @@ def delete_totp(self):
 
         if form.validate():
             self.user_service.update_user(self.request.user.id, totp_secret=None)
-            self.request.session.flash("TOTP application deleted.", queue="success")
+            self.request.session.flash(
+                "Authentication application deleted from PyPI. "
+                "Remember to remove PyPI from your application.",
+                queue="success",
+            )
         else:
-            self.request.session.flash("Invalid credentials.", queue="error")
+            self.request.session.flash("Invalid credentials", queue="error")
 
         return HTTPSeeOther(self.request.route_path("manage.account"))
 
@@ -488,9 +502,9 @@ def validate_webauthn_provision(self):
                 sign_count=form.validated_credential.sign_count,
             )
             self.request.session.flash(
-                "WebAuthn successfully provisioned.", queue="success"
+                "Physical security key successfully set up", queue="success"
             )
-            return {"success": "WebAuthn successfully provisioned"}
+            return {"success": "Physical security key successfully set up"}
 
         errors = [
             str(error) for error_list in form.errors.values() for error in error_list
@@ -504,7 +518,9 @@ def validate_webauthn_provision(self):
     )
     def delete_webauthn(self):
         if len(self.request.user.webauthn) == 0:
-            self.request.session.flash("No WebAuthhn device to delete.", queue="error")
+            self.request.session.flash(
+                "There is no physical security key to delete", queue="error"
+            )
             return HTTPSeeOther(self.request.route_path("manage.account"))
 
         form = DeleteWebAuthnForm(
@@ -516,9 +532,9 @@ def delete_webauthn(self):
 
         if form.validate():
             self.request.user.webauthn.remove(form.webauthn)
-            self.request.session.flash("WebAuthn device deleted.", queue="success")
+            self.request.session.flash("Physical security key deleted", queue="success")
         else:
-            self.request.session.flash("Invalid credentials.", queue="error")
+            self.request.session.flash("Invalid credentials", queue="error")
 
         return HTTPSeeOther(self.request.route_path("manage.account"))
 
@@ -769,7 +785,7 @@ def manage_project_roles(project, request, _form_class=CreateRoleForm):
         elif user.primary_email is None or not user.primary_email.verified:
             request.session.flash(
                 f"User '{username}' does not have a verified primary email "
-                f"address and cannot be added as a {role_name} for project.",
+                f"address and cannot be added as a {role_name} for project",
                 queue="error",
             )
         else:


Test patch:
diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py
--- a/tests/unit/manage/test_views.py
+++ b/tests/unit/manage/test_views.py
@@ -744,7 +744,9 @@ def test_generate_totp_qr_two_factor_not_allowed(self):
         assert isinstance(result, Response)
         assert result.status_code == 403
         assert request.session.flash.calls == [
-            pretend.call("Modifying 2FA requires a verified email.", queue="error")
+            pretend.call(
+                "Verify your email to modify two factor authentication", queue="error"
+            )
         ]
 
     def test_totp_provision(self, monkeypatch):
@@ -812,7 +814,11 @@ def test_totp_provision_already_provisioned(self, monkeypatch):
         assert isinstance(result, HTTPSeeOther)
         assert result.headers["Location"] == "/foo/bar/"
         assert request.session.flash.calls == [
-            pretend.call("TOTP already provisioned.", queue="error")
+            pretend.call(
+                "Account cannot be linked to more than one authentication "
+                "application at a time",
+                queue="error",
+            )
         ]
 
     def test_totp_provision_two_factor_not_allowed(self):
@@ -831,7 +837,9 @@ def test_totp_provision_two_factor_not_allowed(self):
         assert isinstance(result, Response)
         assert result.status_code == 403
         assert request.session.flash.calls == [
-            pretend.call("Modifying 2FA requires a verified email.", queue="error")
+            pretend.call(
+                "Verify your email to modify two factor authentication", queue="error"
+            )
         ]
 
     def test_validate_totp_provision(self, monkeypatch):
@@ -872,7 +880,9 @@ def test_validate_totp_provision(self, monkeypatch):
             pretend.call(request.user.id, totp_secret=b"secret")
         ]
         assert request.session.flash.calls == [
-            pretend.call("TOTP application successfully provisioned.", queue="success")
+            pretend.call(
+                "Authentication application successfully set up", queue="success"
+            )
         ]
 
     def test_validate_totp_provision_already_provisioned(self, monkeypatch):
@@ -902,7 +912,11 @@ def test_validate_totp_provision_already_provisioned(self, monkeypatch):
         assert user_service.update_user.calls == []
         assert request.route_path.calls == [pretend.call("manage.account")]
         assert request.session.flash.calls == [
-            pretend.call("TOTP already provisioned.", queue="error")
+            pretend.call(
+                "Account cannot be linked to more than one authentication "
+                "application at a time",
+                queue="error",
+            )
         ]
 
         assert isinstance(result, HTTPSeeOther)
@@ -966,7 +980,9 @@ def test_validate_totp_provision_two_factor_not_allowed(self):
         assert isinstance(result, Response)
         assert result.status_code == 403
         assert request.session.flash.calls == [
-            pretend.call("Modifying 2FA requires a verified email.", queue="error")
+            pretend.call(
+                "Verify your email to modify two factor authentication", queue="error"
+            )
         ]
 
     def test_delete_totp(self, monkeypatch, db_request):
@@ -1000,7 +1016,11 @@ def test_delete_totp(self, monkeypatch, db_request):
             pretend.call(request.user.id, totp_secret=None)
         ]
         assert request.session.flash.calls == [
-            pretend.call("TOTP application deleted.", queue="success")
+            pretend.call(
+                "Authentication application deleted from PyPI. "
+                "Remember to remove PyPI from your application.",
+                queue="success",
+            )
         ]
         assert isinstance(result, HTTPSeeOther)
         assert result.headers["Location"] == "/foo/bar/"
@@ -1033,7 +1053,7 @@ def test_delete_totp_bad_username(self, monkeypatch, db_request):
 
         assert user_service.update_user.calls == []
         assert request.session.flash.calls == [
-            pretend.call("Invalid credentials.", queue="error")
+            pretend.call("Invalid credentials", queue="error")
         ]
         assert isinstance(result, HTTPSeeOther)
         assert result.headers["Location"] == "/foo/bar/"
@@ -1066,7 +1086,9 @@ def test_delete_totp_not_provisioned(self, monkeypatch, db_request):
 
         assert user_service.update_user.calls == []
         assert request.session.flash.calls == [
-            pretend.call("No TOTP application to delete.", queue="error")
+            pretend.call(
+                "There is no authentication application to delete", queue="error"
+            )
         ]
         assert isinstance(result, HTTPSeeOther)
         assert result.headers["Location"] == "/foo/bar/"
@@ -1087,7 +1109,9 @@ def test_delete_totp_two_factor_not_allowed(self):
         assert isinstance(result, Response)
         assert result.status_code == 403
         assert request.session.flash.calls == [
-            pretend.call("Modifying 2FA requires a verified email.", queue="error")
+            pretend.call(
+                "Verify your email to modify two factor authentication", queue="error"
+            )
         ]
 
 
@@ -1182,9 +1206,9 @@ def test_validate_webauthn_provision(self, monkeypatch):
             )
         ]
         assert request.session.flash.calls == [
-            pretend.call("WebAuthn successfully provisioned.", queue="success")
+            pretend.call("Physical security key successfully set up", queue="success")
         ]
-        assert result == {"success": "WebAuthn successfully provisioned"}
+        assert result == {"success": "Physical security key successfully set up"}
 
     def test_validate_webauthn_provision_invalid_form(self, monkeypatch):
         user_service = pretend.stub(
@@ -1251,7 +1275,7 @@ def test_delete_webauthn(self, monkeypatch):
         result = view.delete_webauthn()
 
         assert request.session.flash.calls == [
-            pretend.call("WebAuthn device deleted.", queue="success")
+            pretend.call("Physical security key deleted", queue="success")
         ]
         assert request.route_path.calls == [pretend.call("manage.account")]
         assert isinstance(result, HTTPSeeOther)
@@ -1269,7 +1293,7 @@ def test_delete_webauthn_not_provisioned(self):
         result = view.delete_webauthn()
 
         assert request.session.flash.calls == [
-            pretend.call("No WebAuthhn device to delete.", queue="error")
+            pretend.call("There is no physical security key to delete", queue="error")
         ]
         assert request.route_path.calls == [pretend.call("manage.account")]
         assert isinstance(result, HTTPSeeOther)
@@ -1296,7 +1320,7 @@ def test_delete_webauthn_invalid_form(self, monkeypatch):
         result = view.delete_webauthn()
 
         assert request.session.flash.calls == [
-            pretend.call("Invalid credentials.", queue="error")
+            pretend.call("Invalid credentials", queue="error")
         ]
         assert request.route_path.calls == [pretend.call("manage.account")]
         assert isinstance(result, HTTPSeeOther)
@@ -2017,7 +2041,7 @@ def test_post_unverified_email(self, db_request, with_email):
         assert db_request.session.flash.calls == [
             pretend.call(
                 "User 'testuser' does not have a verified primary email address "
-                "and cannot be added as a Owner for project.",
+                "and cannot be added as a Owner for project",
                 queue="error",
             )
         ]