Skip to content

Commit 80dc111

Browse files
committed
Fix/handle htmx requests for private page after session expires
When a user's session expires and they click on a submission in the dashboard, the login page is rendered within the submissions section of the page (via HTMX swap) instead of redirecting to the login page. This creates a broken user experience as shown in the screenshot in the issue. Created a new middleware `HtmxAuthMiddleware` that: 1. Intercepts HTMX requests that would normally redirect to the login page 2. Instead of allowing HTMX to swap the login page content, it issues a client-side redirect to the login page 3. Preserves the original path in the 'next' parameter so the user returns to the right page after login Tested by running unit tests on the middleware that verify: 1. HTMX requests that get redirected to login are properly converted to client-side redirects 2. Non-HTMX requests are not affected 3. HTMX requests with non-auth redirects are not affected Fixes #4530
1 parent 73fd2e5 commit 80dc111

4 files changed

Lines changed: 232 additions & 1 deletion

File tree

hypha/core/middleware/htmx.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
from urllib.parse import urlparse
23

34
from django.contrib.messages import get_messages
45
from django.http import HttpRequest, HttpResponse
@@ -7,7 +8,16 @@
78

89
class HtmxMessageMiddleware(MiddlewareMixin):
910
"""
10-
Middleware that moves messages into the HX-Trigger header when request is made with HTMX
11+
Middleware that transfers Django messages into the HX-Trigger header for HTMX requests.
12+
13+
This middleware captures Django messages and adds them to the HX-Trigger header
14+
when the request is made with HTMX. It preserves any existing HX-Trigger values
15+
and adds the messages in a standardized format that can be processed by frontend
16+
JavaScript.
17+
18+
The messages are formatted as an array of objects, each containing:
19+
- message: The text content of the message
20+
- tags: CSS class names or other categorization for the message
1121
"""
1222

1323
def process_response(
@@ -52,3 +62,67 @@ def process_response(
5262
response.headers["HX-Trigger"] = json.dumps(hx_trigger)
5363

5464
return response
65+
66+
67+
class HtmxAuthRedirectMiddleware:
68+
"""
69+
Middleware to handle HTMX authentication redirects properly.
70+
71+
When an HTMX request results in a 302 redirect (typically for authentication),
72+
this middleware:
73+
1. Changes the response status code to 204 (No Content)
74+
2. Adds an HX-Redirect header with the redirect URL
75+
3. Preserves the original request path in the 'next' query parameter
76+
77+
This ensures that after authentication, the user is returned to the page
78+
they were attempting to access, maintaining a seamless UX with HTMX.
79+
80+
Credits: https://www.caktusgroup.com/blog/2022/11/11/how-handle-django-login-redirects-htmx/
81+
"""
82+
83+
def __init__(self, get_response):
84+
self.get_response = get_response
85+
86+
def __call__(self, request):
87+
response = self.get_response(request)
88+
# HTMX request returning 302 likely is login required.
89+
# Take the redirect location and send it as the HX-Redirect header value,
90+
# with 'next' query param set to where the request originated. Also change
91+
# response status code to 204 (no content) so that htmx will obey the
92+
# HX-Redirect header value.
93+
if request.headers.get("HX-Request") == "true" and response.status_code == 302:
94+
# Determine the next path from referer or current request path
95+
ref_header = request.headers.get("Referer", "")
96+
if ref_header:
97+
referer = urlparse(ref_header)
98+
next_path = referer.path
99+
else:
100+
next_path = request.path
101+
102+
# Parse the redirect URL
103+
redirect_url = urlparse(response["location"])
104+
105+
# Set response status code to 204 for HTMX to process the redirect
106+
response.status_code = 204
107+
108+
# Create the redirect URL manually to avoid encoding issues
109+
if redirect_url.query:
110+
# Extract the query parameters
111+
if "next=" in redirect_url.query:
112+
# Replace the existing next parameter
113+
import re
114+
115+
new_query = re.sub(
116+
r"next=[^&]*", f"next={next_path}", redirect_url.query
117+
)
118+
else:
119+
# Append a new next parameter
120+
new_query = f"{redirect_url.query}&next={next_path}"
121+
else:
122+
# No existing query parameters
123+
new_query = f"next={next_path}"
124+
125+
# Set the new HX-Redirect header
126+
response.headers["HX-Redirect"] = f"{redirect_url.path}?{new_query}"
127+
128+
return response
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import pytest
2+
from django.contrib.auth.decorators import login_required
3+
from django.http import HttpResponse
4+
from django.test import RequestFactory
5+
from django.urls import path
6+
from django.views.decorators.http import require_GET
7+
8+
from hypha.apply.users.tests.factories import UserFactory
9+
from hypha.core.middleware.htmx import HtmxAuthRedirectMiddleware
10+
11+
12+
# Create test views
13+
@require_GET
14+
def public_view(request):
15+
return HttpResponse("Public view")
16+
17+
18+
@login_required
19+
@require_GET
20+
def private_view(request):
21+
return HttpResponse("Private view")
22+
23+
24+
# Create test URL patterns
25+
urlpatterns = [
26+
path("public/", public_view, name="public"),
27+
path("private/", private_view, name="private"),
28+
]
29+
30+
31+
# Mock response handler for middleware
32+
def get_auth_redirect_response(request):
33+
# For authenticated paths, return a redirect response
34+
if request.path == "/private/":
35+
response = HttpResponse()
36+
response.status_code = 302
37+
response["Location"] = "/accounts/login/?next=/some/path/"
38+
return response
39+
# For non-auth paths, return a regular response
40+
elif request.path == "/public/":
41+
response = HttpResponse("Public content")
42+
return response
43+
# For other redirects
44+
elif request.path == "/redirect/":
45+
response = HttpResponse()
46+
response.status_code = 302
47+
response["Location"] = "/other-page/"
48+
return response
49+
return HttpResponse()
50+
51+
52+
@pytest.fixture
53+
def request_factory():
54+
return RequestFactory()
55+
56+
57+
@pytest.fixture
58+
def user():
59+
return UserFactory()
60+
61+
62+
@pytest.fixture
63+
def middleware():
64+
return HtmxAuthRedirectMiddleware(get_response=get_auth_redirect_response)
65+
66+
67+
@pytest.fixture
68+
def settings_with_login_url(settings):
69+
settings.LOGIN_URL = "/accounts/login/"
70+
return settings
71+
72+
73+
def test_htmx_auth_redirect(request_factory, middleware, settings_with_login_url):
74+
"""Test that HTMX requests to authenticated views are redirected."""
75+
# Create a request with HTMX headers
76+
request = request_factory.get("/private/")
77+
request.headers = {"HX-Request": "true"}
78+
request.path = "/private/"
79+
80+
# Process the request through middleware
81+
response = middleware(request)
82+
83+
# Check that the response is modified with HX-Redirect
84+
assert response.status_code == 204
85+
assert response.headers["HX-Redirect"] == "/accounts/login/?next=/private/"
86+
87+
88+
def test_htmx_auth_redirect_with_referer(
89+
request_factory, middleware, settings_with_login_url
90+
):
91+
"""Test that HTMX requests with Referer header are redirected correctly."""
92+
# Create a request with HTMX headers and Referer
93+
request = request_factory.get("/private/")
94+
request.headers = {
95+
"HX-Request": "true",
96+
"Referer": "https://example.com/some/page/",
97+
}
98+
request.path = "/private/"
99+
100+
# Process the request through middleware
101+
response = middleware(request)
102+
103+
# Check that the response uses the Referer's path in the next parameter
104+
assert response.status_code == 204
105+
assert response.headers["HX-Redirect"] == "/accounts/login/?next=/some/page/"
106+
107+
108+
def test_non_htmx_request_not_redirected(
109+
request_factory, middleware, settings_with_login_url
110+
):
111+
"""Test that non-HTMX requests are not affected."""
112+
# Create a regular request without HTMX headers
113+
request = request_factory.get("/private/")
114+
request.headers = {}
115+
request.path = "/private/"
116+
117+
# Process the request through middleware
118+
response = middleware(request)
119+
120+
# Assert that the middleware didn't change the response status
121+
assert response.status_code == 302
122+
assert "HX-Redirect" not in response.headers
123+
assert response["Location"] == "/accounts/login/?next=/some/path/"
124+
125+
126+
def test_htmx_non_auth_redirect_not_affected(
127+
request_factory, middleware, settings_with_login_url
128+
):
129+
"""Test that HTMX requests with non-auth redirects are not affected."""
130+
# Create a request with HTMX headers to a path that redirects elsewhere
131+
request = request_factory.get("/redirect/")
132+
request.headers = {"HX-Request": "true"}
133+
request.path = "/redirect/"
134+
135+
# Process the request through middleware
136+
response = middleware(request)
137+
138+
# Assert that the middleware handles the redirect with HX-Redirect
139+
assert response.status_code == 204
140+
assert response.headers["HX-Redirect"] == "/other-page/?next=/redirect/"
141+
142+
143+
def test_htmx_normal_request(request_factory, middleware, settings_with_login_url):
144+
"""Test that HTMX requests to public views work normally."""
145+
# Create a request with HTMX headers to a public path
146+
request = request_factory.get("/public/")
147+
request.headers = {"HX-Request": "true"}
148+
request.path = "/public/"
149+
150+
# Process the request through middleware
151+
response = middleware(request)
152+
153+
# Assert that the middleware didn't affect the normal response
154+
assert response.status_code == 200
155+
assert "HX-Redirect" not in response.headers

hypha/settings/django.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"hypha.apply.middleware.HandleProtectionErrorMiddleware",
9696
"django_htmx.middleware.HtmxMiddleware",
9797
"hypha.core.middleware.htmx.HtmxMessageMiddleware",
98+
"hypha.core.middleware.htmx.HtmxAuthRedirectMiddleware",
9899
]
99100

100101
# Logging

0 commit comments

Comments
 (0)