From 4aec7e37630e5ec9baeed289ff889271b4e26732 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Thu, 17 Sep 2026 14:16:58 +0200 Subject: [PATCH] Redirect user on activation to passwordless flow. --- .../templates/users/activation/email.txt | 9 ++++---- hypha/apply/users/tests/test_utils.py | 8 ++++++- hypha/apply/users/tests/test_views.py | 12 ++++++---- hypha/apply/users/views.py | 22 +++++++++++++------ 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/hypha/apply/users/templates/users/activation/email.txt b/hypha/apply/users/templates/users/activation/email.txt index 003cbd6e3a..553557d231 100644 --- a/hypha/apply/users/templates/users/activation/email.txt +++ b/hypha/apply/users/templates/users/activation/email.txt @@ -5,14 +5,13 @@ {% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}{{ activation_path }} -{% blocktrans %}This link can be used only once and will lead you to a page where you can set your password. It will remain active for {{ timeout_minutes }} minutes, so please set your password as soon as possible.{% endblocktrans %} +{% blocktrans %}This link can be used only once and will log you in. It will remain active for {{ timeout_minutes }} minutes, so please activate your account as soon as possible.{% endblocktrans %} -{% trans "After setting your password, you will be able to log in at" %}: {% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %} {% trans "in the future using" %}: +{% blocktrans %}In the future you can log in at{% endblocktrans %}: {% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}{% url 'users:passwordless_login_signup' %} -{% trans "Username" %}: {{ username }} -{% trans "Password" %}: {% trans "Your chosen password" %} +{% blocktrans %}Enter your email address ({{ username }}) and we will send you a link that logs you in — no password needed. You can set a password later from your account settings if you prefer.{% endblocktrans %} -{% blocktrans %}If you do not complete the activation process within {{ timeout_minutes }} minutes you can use the password reset form at{% endblocktrans %}: {% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}{% url 'users:password_reset' %} +{% blocktrans %}If you do not complete the activation process within {{ timeout_minutes }} minutes, just request a new login link from the address above.{% endblocktrans %} {% blocktrans %}Kind Regards, The {{ ORG_SHORT_NAME }} Team{% endblocktrans %} diff --git a/hypha/apply/users/tests/test_utils.py b/hypha/apply/users/tests/test_utils.py index 518378ae78..693abd4339 100644 --- a/hypha/apply/users/tests/test_utils.py +++ b/hypha/apply/users/tests/test_utils.py @@ -18,7 +18,13 @@ def test_activation_email_includes_link(self): send_activation_email(UserFactory()) assert len(mail.outbox) == 1 email_body = mail.outbox[0].body - assert "password reset form at: https://primary-test-host.org" in email_body + assert "https://primary-test-host.org/account/activate/" in email_body + + def test_activation_email_points_to_passwordless_login(self): + send_activation_email(UserFactory()) + email_body = mail.outbox[0].body + assert "log in at: https://primary-test-host.org/auth/" in email_body + assert "no password needed" in email_body class TestGetUserByEmail(TestCase): diff --git a/hypha/apply/users/tests/test_views.py b/hypha/apply/users/tests/test_views.py index 2004e8e10e..a00fac545b 100644 --- a/hypha/apply/users/tests/test_views.py +++ b/hypha/apply/users/tests/test_views.py @@ -1,7 +1,7 @@ from django.conf import settings from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.core import mail -from django.test import TestCase +from django.test import TestCase, override_settings from django.urls import reverse from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_encode @@ -166,7 +166,6 @@ def test_get_invalid_uid_shows_invalid_page(self): def test_post_valid_token_logs_user_in(self): response = self.client.post(self.url, follow=True) self.assertEqual(response.status_code, 200) - # Should redirect to create_password page self.assertTrue(response.wsgi_request.user.is_authenticated) def test_post_invalid_token_shows_invalid_page(self): @@ -177,14 +176,19 @@ def test_post_invalid_token_shows_invalid_page(self): response = self.client.post(url) self.assertTemplateUsed(response, "users/activation/invalid.html") - def test_post_valid_token_redirects_to_set_password(self): + def test_post_valid_token_redirects_to_dashboard(self): response = self.client.post(self.url) self.assertRedirects( response, - reverse("users:activate_password"), + reverse("dashboard:dashboard"), fetch_redirect_response=False, ) + @override_settings(ENFORCE_TWO_FACTOR=True) + def test_post_valid_token_redirects_to_two_factor_setup(self): + response = self.client.post(self.url) + self.assertIn(reverse("two_factor:setup"), response["Location"]) + def test_post_valid_token_with_next_includes_redirect(self): url = self.url + "?next=/dashboard/" response = self.client.post(url) diff --git a/hypha/apply/users/views.py b/hypha/apply/users/views.py index 81e471b32a..b850b6e1bb 100644 --- a/hypha/apply/users/views.py +++ b/hypha/apply/users/views.py @@ -7,7 +7,7 @@ from django.contrib import messages from django.contrib.auth import get_user_model, login, update_session_auth_hash from django.contrib.auth.decorators import login_required, permission_required -from django.contrib.auth.forms import AdminPasswordChangeForm +from django.contrib.auth.forms import SetPasswordForm from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.contrib.auth.views import INTERNAL_RESET_SESSION_TOKEN from django.contrib.auth.views import ( @@ -340,10 +340,18 @@ def post(self, request, *args, **kwargs): if self.valid(user, kwargs.get("token")): user.backend = settings.CUSTOM_AUTH_BACKEND login(request, user) - url = reverse("users:activate_password") + if redirect_url := get_redirect_url(request, self.redirect_field_name): - url = f"{url}?next={redirect_url}" - return redirect(url) + return redirect(redirect_url) + + # If 2FA is enabled, redirect to setup page instead of dashboard + if settings.ENFORCE_TWO_FACTOR: + return redirect( + reverse("two_factor:setup") + + f"?next={reverse('dashboard:dashboard')}" + ) + + return redirect("dashboard:dashboard") return render(request, "users/activation/invalid.html") @@ -371,7 +379,7 @@ def get_user(self, uidb64): def create_password(request): """ - A custom view for the admin password change form used for account activation. + A custom view for setting a password, used for account activation. """ redirect_url = get_redirect_url(request, redirect_field="next") @@ -379,7 +387,7 @@ def create_password(request): raise PermissionDenied() if request.method == "POST": - form = AdminPasswordChangeForm(request.user, request.POST) + form = SetPasswordForm(request.user, request.POST) if form.is_valid(): user = form.save() @@ -391,7 +399,7 @@ def create_password(request): else: messages.error(request, _("Please correct the errors below.")) else: - form = AdminPasswordChangeForm(request.user) + form = SetPasswordForm(request.user) return render( request,