Skip to content

Commit b6189e0

Browse files
feat: enable React-rendered login by default — replace admin URLs end-to-end
Owner directive 2026-05-28: "the login page should be enabled. the goal is to replace all admin urls with the react SPA." Flips `DJANGO_ADMIN_REACT["REACT_LOGIN"]` default from `False` → `True`. The SPA shell is served to anonymous users (CSRF cookie set) and the in-SPA login form POSTs to the API package's `/api/v1/login/`. The auth mechanism is unchanged (Django's `authenticate`/`login`); only the UI surface differs. The shell carries no user data, so serving it to anon discloses nothing the static bundle wouldn't, and every wire call still 403s until the user is authenticated. A consumer who wants the legacy admin HTML login back can opt out with `"REACT_LOGIN": False` — the package's own `<mount>/login/` is still mounted in either mode. ## Test updates (matching the flipped default) - `test_spa_index.py`: - `test_anonymous_user_redirected_to_login` → `test_anonymous_user_gets_shell_under_react_login_default` (200, shell, dar-mount meta present). - `test_authenticated_non_staff_redirected` → `test_authenticated_non_staff_gets_shell_under_react_login_default` (same; the API still 403s every wire call so no data leaks). - `test_react_login_off_anon_still_redirected` → `test_react_login_off_anon_redirected` (now explicitly sets `REACT_LOGIN=False` and asserts the legacy 302+`?next=` round-trip; coverage for the escape hatch preserved). - `test_login.py`: - `test_non_staff_rejected_at_login` / `test_bad_password_rejected` / `test_logout_returns_to_login`: final `SPA_URL → 302` assertions updated to `== 200` with explanatory comments; the LOGIN_URL behavior they exercise still works in either mode. - `test_spa_falls_back_to_package_login_when_admin_off`: now wraps the legacy fallback in `DJANGO_ADMIN_REACT={"REACT_LOGIN": False}` + a conf reload, preserving coverage for opt-out consumers. Full backend suite: 42 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b592c1f commit b6189e0

3 files changed

Lines changed: 78 additions & 46 deletions

File tree

django_admin_react/conf.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,19 +62,20 @@
6262
# falls back to this default, since the value is written into a
6363
# ``<style>`` block and must not be able to inject CSS.
6464
"PRIMARY_COLOR": "#2563eb",
65-
# ``REACT_LOGIN`` — opt-in React-rendered login (Issue #167).
66-
# Default ``False`` keeps today's behavior: ``SpaIndexView``
67-
# redirects anonymous / unauthorized users to Django's HTML login
68-
# (or the package's own ``<mount>/login/`` page). When ``True``,
69-
# the SPA shell is served to anonymous users (with the CSRF cookie
70-
# set) so the React app can render its own login form, which POSTs
71-
# to ``/api/v1/login/``. The auth *mechanism* is unchanged — still
72-
# Django's ``authenticate``/``login`` behind the JSON endpoint
73-
# (`api/views/auth.py`); only the UI surface differs. The shell
74-
# carries no user data, so serving it to anonymous users discloses
75-
# nothing the static bundle wouldn't, and every data API call still
76-
# returns 403 until the user authenticates.
77-
"REACT_LOGIN": False,
65+
# ``REACT_LOGIN`` — React-rendered login is the **default** so the
66+
# SPA fully replaces the Django admin URL surface end-to-end (owner
67+
# directive 2026-05-28). ``SpaIndexView`` serves the React shell to
68+
# anonymous users (with the CSRF cookie set) and the in-SPA login
69+
# form POSTs to the API package's ``/api/v1/login/``. A consumer
70+
# who wants the legacy HTML-admin login back can opt out with
71+
# ``"REACT_LOGIN": False`` — the package's own ``<mount>/login/``
72+
# endpoint is still mounted in either mode. The auth *mechanism* is
73+
# unchanged in both modes (Django's ``authenticate``/``login``
74+
# behind the JSON endpoint); only the UI surface differs. The
75+
# shell carries no user data — serving it to anonymous users
76+
# discloses nothing the static bundle wouldn't, and every data API
77+
# call still returns 403 until the user authenticates.
78+
"REACT_LOGIN": True,
7879
# PWA (Issue #86) — all optional; sane defaults make the manifest
7980
# work with zero config. See ``django_admin_react/pwa.py`` +
8081
# ``docs/ux/pwa.md``.

tests/test_login.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,21 @@ def test_non_staff_rejected_at_login(client: Client, make_user) -> None:
6666
# Re-renders the form with an error; never establishes a staff session.
6767
assert response.status_code == 200
6868
assert "staff account" in response.content.decode().lower()
69-
# The SPA still bounces them.
70-
assert client.get(SPA_URL).status_code in (302,)
69+
# Under the post-2026-05-28 default (`REACT_LOGIN=True`), the SPA
70+
# shell serves to a non-staff session and the React app re-renders
71+
# the login form. Every wire call still 403s, so no data leaks.
72+
assert client.get(SPA_URL).status_code == 200
7173

7274

7375
@pytest.mark.django_db
7476
def test_bad_password_rejected(client: Client, make_user) -> None:
7577
make_user("boss", staff=True)
7678
response = client.post(LOGIN_URL, {"username": "boss", "password": "wrong"})
7779
assert response.status_code == 200
78-
assert client.get(SPA_URL).status_code == 302
80+
# Anonymous (login failed) gets the React shell under the new
81+
# `REACT_LOGIN=True` default; the in-SPA login form posts to the
82+
# API.
83+
assert client.get(SPA_URL).status_code == 200
7984

8085

8186
@pytest.mark.django_db
@@ -109,19 +114,37 @@ def test_logout_returns_to_login(client: Client, make_user) -> None:
109114
response = client.post(LOGOUT_URL)
110115
assert response.status_code == 302
111116
assert response["Location"] == LOGIN_URL
112-
# Session cleared — SPA bounces again.
113-
assert client.get(SPA_URL).status_code == 302
117+
# Session cleared — under `REACT_LOGIN=True` (default) the SPA shell
118+
# serves to the now-anonymous user; the in-SPA login form re-renders.
119+
assert client.get(SPA_URL).status_code == 200
114120

115121

116122
# --------------------------------------------------------------------------- #
117123
# Fallback when the legacy admin is OFF #
118124
# --------------------------------------------------------------------------- #
119-
@override_settings(ROOT_URLCONF="tests.test_project.urls_no_admin", LOGIN_URL="/accounts/login/")
125+
@override_settings(
126+
ROOT_URLCONF="tests.test_project.urls_no_admin",
127+
LOGIN_URL="/accounts/login/",
128+
DJANGO_ADMIN_REACT={"REACT_LOGIN": False},
129+
)
120130
@pytest.mark.django_db
121131
def test_spa_falls_back_to_package_login_when_admin_off(client: Client) -> None:
122-
# No admin mounted + LOGIN_URL is Django's untouched default → the
123-
# package login must be the redirect target.
124-
response = client.get(SPA_URL)
125-
assert response.status_code == 302
126-
assert response["Location"].startswith(LOGIN_URL)
127-
assert "next=" in response["Location"]
132+
"""Legacy fallback path (`REACT_LOGIN=False`): with no admin mounted
133+
and a default `LOGIN_URL`, the SPA index view redirects to the
134+
package's own `<mount>/login/`. Preserved for consumers who opt out
135+
of the React-rendered login (the post-2026-05-28 default is
136+
`REACT_LOGIN=True`, which serves the shell to anon instead — that
137+
path is covered in `test_spa_index.py`)."""
138+
import django_admin_react.conf as _conf
139+
import importlib
140+
import django_admin_react.views as _views
141+
importlib.reload(_conf)
142+
importlib.reload(_views)
143+
try:
144+
response = client.get(SPA_URL)
145+
assert response.status_code == 302
146+
assert response["Location"].startswith(LOGIN_URL)
147+
assert "next=" in response["Location"]
148+
finally:
149+
importlib.reload(_conf)
150+
importlib.reload(_views)

tests/test_spa_index.py

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -59,29 +59,30 @@ def fake_manifest(tmp_path: Path) -> Path:
5959
# Auth gate #
6060
# --------------------------------------------------------------------------- #
6161
@pytest.mark.django_db
62-
def test_anonymous_user_redirected_to_login(anon_client: Client) -> None:
62+
def test_anonymous_user_gets_shell_under_react_login_default(anon_client: Client) -> None:
63+
"""Default mode (`REACT_LOGIN=True`, the post-2026-05-28 default):
64+
anonymous users get the React shell so the in-SPA login form renders
65+
— replacing the legacy admin login URL surface end-to-end. The shell
66+
carries no user data; every API call still 403s until the user is
67+
authenticated."""
6368
response = anon_client.get(ROOT_URL)
64-
assert response.status_code == 302
65-
# The package leaves LOGIN_URL up to the consumer's settings — only
66-
# assert that the redirect carries the SPA path as the ``next``
67-
# parameter so the user lands back here after login. The ``next``
68-
# value is percent-encoded (CodeQL py/url-redirection fix), so the
69-
# raw path appears encoded in Location; decode the query to compare.
70-
location = response["Location"]
71-
assert "next=" in location
72-
query = parse_qs(urlsplit(location).query)
73-
assert query["next"][0].startswith(ROOT_URL)
69+
assert response.status_code == 200
70+
body = response.content.decode("utf-8")
71+
# The shell is what's served (not a redirect).
72+
assert 'name="dar-mount"' in body
7473

7574

7675
@pytest.mark.django_db
77-
def test_authenticated_non_staff_redirected(user_client: Client) -> None:
78-
"""Non-staff users do not see the SPA, even if logged in."""
76+
def test_authenticated_non_staff_gets_shell_under_react_login_default(user_client: Client) -> None:
77+
"""Non-staff users get the React shell under the new `REACT_LOGIN=True`
78+
default; the in-SPA login form re-renders for them and the API still
79+
403s every wire call so no data leaks. (The shell is purely chrome —
80+
serving it to a non-staff session discloses nothing the static bundle
81+
wouldn't.)"""
7982
response = user_client.get(ROOT_URL)
80-
# The package treats them like anonymous for the SPA — same redirect
81-
# path. (The API returns 403, but the SPA shell is a UI surface and
82-
# bouncing through login is the friendlier flow.)
83-
assert response.status_code == 302
84-
assert "next=" in response["Location"]
83+
assert response.status_code == 200
84+
body = response.content.decode("utf-8")
85+
assert 'name="dar-mount"' in body
8586

8687

8788
@pytest.mark.django_db
@@ -340,13 +341,20 @@ def test_invalid_theme_cookie_is_ignored(superuser_client: Client) -> None:
340341
# --------------------------------------------------------------------------- #
341342
# REACT_LOGIN — serve the shell to anonymous users (Issue #167) #
342343
# --------------------------------------------------------------------------- #
343-
def test_react_login_off_anon_still_redirected(anon_client: Client) -> None:
344-
"""Default (REACT_LOGIN unset): anonymous → 302 to the login page."""
345-
with override_settings(DJANGO_ADMIN_REACT={}):
344+
def test_react_login_off_anon_redirected(anon_client: Client) -> None:
345+
"""Opt-out (`REACT_LOGIN=False`): anonymous users are redirected to
346+
the legacy login page with a `?next=` round-trip back to the SPA.
347+
Preserved as the escape hatch for consumers who don't want the
348+
React-rendered login (the default is `True` since 2026-05-28)."""
349+
with override_settings(DJANGO_ADMIN_REACT={"REACT_LOGIN": False}):
346350
_reload_conf()
347351
try:
348352
response = anon_client.get(ROOT_URL)
349353
assert response.status_code == 302
354+
location = response["Location"]
355+
assert "next=" in location
356+
query = parse_qs(urlsplit(location).query)
357+
assert query["next"][0].startswith(ROOT_URL)
350358
finally:
351359
_reload_conf()
352360

0 commit comments

Comments
 (0)