diff --git a/hypha/core/middleware/htmx.py b/hypha/core/middleware/htmx.py index bc784b9df4..a6cbdbad35 100644 --- a/hypha/core/middleware/htmx.py +++ b/hypha/core/middleware/htmx.py @@ -1,4 +1,5 @@ import json +from urllib.parse import parse_qs, urlencode, urlparse from django.contrib.messages import get_messages from django.http import HttpRequest, HttpResponse @@ -7,7 +8,16 @@ class HtmxMessageMiddleware(MiddlewareMixin): """ - Middleware that moves messages into the HX-Trigger header when request is made with HTMX + Middleware that transfers Django messages into the HX-Trigger header for HTMX requests. + + This middleware captures Django messages and adds them to the HX-Trigger header + when the request is made with HTMX. It preserves any existing HX-Trigger values + and adds the messages in a standardized format that can be processed by frontend + JavaScript. + + The messages are formatted as an array of objects, each containing: + - message: The text content of the message + - tags: CSS class names or other categorization for the message """ def process_response( @@ -52,3 +62,55 @@ def process_response( response.headers["HX-Trigger"] = json.dumps(hx_trigger) return response + + +class HtmxAuthRedirectMiddleware: + """ + Middleware to handle HTMX authentication redirects properly. + + When an HTMX request results in a 302 redirect (typically for authentication), + this middleware: + 1. Changes the response status code to 204 (No Content) + 2. Adds an HX-Redirect header with the redirect URL + 3. Preserves the original request path in the 'next' query parameter + + This ensures that after authentication, the user is returned to the page + they were attempting to access, maintaining a seamless UX with HTMX. + + Credits: https://www.caktusgroup.com/blog/2022/11/11/how-handle-django-login-redirects-htmx/ + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + # HTMX request returning 302 likely is login required. + # Take the redirect location and send it as the HX-Redirect header value, + # with 'next' query param set to where the request originated. Also change + # response status code to 204 (no content) so that htmx will obey the + # HX-Redirect header value. + if request.headers.get("HX-Request") == "true" and response.status_code == 302: + # Determine the next path from referer or current request path + ref_header = request.headers.get("Referer", "") + if ref_header: + referer = urlparse(ref_header) + next_path = referer.path + else: + next_path = request.path + + # Parse the redirect URL + redirect_url = urlparse(response["location"]) + + # Set response status code to 204 for HTMX to process the redirect + response.status_code = 204 + + # Update the "?next" query parameter + query_params = parse_qs(redirect_url.query) + query_params["next"] = [next_path] + new_query = urlencode(query_params, doseq=True) + + # Set the new HX-Redirect header + response.headers["HX-Redirect"] = f"{redirect_url.path}?{new_query}" + + return response diff --git a/hypha/core/middleware/tests/__init__.py b/hypha/core/middleware/tests/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/hypha/core/middleware/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/hypha/core/middleware/tests/test_htmx_auth_redirect.py b/hypha/core/middleware/tests/test_htmx_auth_redirect.py new file mode 100644 index 0000000000..f99c27327a --- /dev/null +++ b/hypha/core/middleware/tests/test_htmx_auth_redirect.py @@ -0,0 +1,155 @@ +import pytest +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse +from django.test import RequestFactory +from django.urls import path +from django.views.decorators.http import require_GET + +from hypha.apply.users.tests.factories import UserFactory +from hypha.core.middleware.htmx import HtmxAuthRedirectMiddleware + + +# Create test views +@require_GET +def public_view(request): + return HttpResponse("Public view") + + +@login_required +@require_GET +def private_view(request): + return HttpResponse("Private view") + + +# Create test URL patterns +urlpatterns = [ + path("public/", public_view, name="public"), + path("private/", private_view, name="private"), +] + + +# Mock response handler for middleware +def get_auth_redirect_response(request): + # For authenticated paths, return a redirect response + if request.path == "/private/": + response = HttpResponse() + response.status_code = 302 + response["Location"] = "/accounts/login/?next=/some/path/" + return response + # For non-auth paths, return a regular response + elif request.path == "/public/": + response = HttpResponse("Public content") + return response + # For other redirects + elif request.path == "/redirect/": + response = HttpResponse() + response.status_code = 302 + response["Location"] = "/other-page/" + return response + return HttpResponse() + + +@pytest.fixture +def request_factory(): + return RequestFactory() + + +@pytest.fixture +def user(): + return UserFactory() + + +@pytest.fixture +def middleware(): + return HtmxAuthRedirectMiddleware(get_response=get_auth_redirect_response) + + +@pytest.fixture +def settings_with_login_url(settings): + settings.LOGIN_URL = "/accounts/login/" + return settings + + +def test_htmx_auth_redirect(request_factory, middleware, settings_with_login_url): + """Test that HTMX requests to authenticated views are redirected.""" + # Create a request with HTMX headers + request = request_factory.get("/private/") + request.headers = {"HX-Request": "true"} + request.path = "/private/" + + # Process the request through middleware + response = middleware(request) + + # Check that the response is modified with HX-Redirect + assert response.status_code == 204 + assert response.headers["HX-Redirect"] == "/accounts/login/?next=%2Fprivate%2F" + + +def test_htmx_auth_redirect_with_referer( + request_factory, middleware, settings_with_login_url +): + """Test that HTMX requests with Referer header are redirected correctly.""" + # Create a request with HTMX headers and Referer + request = request_factory.get("/private/") + request.headers = { + "HX-Request": "true", + "Referer": "https://example.com/some/page/", + } + request.path = "/private/" + + # Process the request through middleware + response = middleware(request) + + # Check that the response uses the Referer's path in the next parameter + assert response.status_code == 204 + assert response.headers["HX-Redirect"] == "/accounts/login/?next=%2Fsome%2Fpage%2F" + + +def test_non_htmx_request_not_redirected( + request_factory, middleware, settings_with_login_url +): + """Test that non-HTMX requests are not affected.""" + # Create a regular request without HTMX headers + request = request_factory.get("/private/") + request.headers = {} + request.path = "/private/" + + # Process the request through middleware + response = middleware(request) + + # Assert that the middleware didn't change the response status + assert response.status_code == 302 + assert "HX-Redirect" not in response.headers + assert response["Location"] == "/accounts/login/?next=/some/path/" + + +def test_htmx_non_auth_redirect_not_affected( + request_factory, middleware, settings_with_login_url +): + """Test that HTMX requests with non-auth redirects are not affected.""" + # Create a request with HTMX headers to a path that redirects elsewhere + request = request_factory.get("/redirect/") + request.headers = {"HX-Request": "true"} + request.path = "/redirect/" + + # Process the request through middleware + response = middleware(request) + + # Assert that the middleware handles the redirect with HX-Redirect + assert response.status_code == 204 + assert response.headers["HX-Redirect"] == "/other-page/?next=%2Fredirect%2F" + + +def test_htmx_normal_request(request_factory, middleware, settings_with_login_url): + """Test that HTMX requests to public views work normally.""" + # Create a request with HTMX headers to a public path + request = request_factory.get("/public/") + request.headers = {"HX-Request": "true"} + request.path = "/public/" + + # Process the request through middleware + response = middleware(request) + + # Assert that the middleware didn't affect the normal response + assert response.status_code == 200 + assert "HX-Redirect" not in response.headers diff --git a/hypha/settings/django.py b/hypha/settings/django.py index 73683d80d3..368bff1765 100644 --- a/hypha/settings/django.py +++ b/hypha/settings/django.py @@ -95,6 +95,7 @@ "hypha.apply.middleware.HandleProtectionErrorMiddleware", "django_htmx.middleware.HtmxMiddleware", "hypha.core.middleware.htmx.HtmxMessageMiddleware", + "hypha.core.middleware.htmx.HtmxAuthRedirectMiddleware", "hypha.apply.projects.middleware.ProjectsEnabledMiddleware", ]