Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions django_admin_react/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,20 @@
# falls back to this default, since the value is written into a
# ``<style>`` block and must not be able to inject CSS.
"PRIMARY_COLOR": "#2563eb",
# ``REACT_LOGIN`` — opt-in React-rendered login (Issue #167).
# Default ``False`` keeps today's behavior: ``SpaIndexView``
# redirects anonymous / unauthorized users to Django's HTML login
# (or the package's own ``<mount>/login/`` page). When ``True``,
# the SPA shell is served to anonymous users (with the CSRF cookie
# set) so the React app can render its own login form, which POSTs
# to ``/api/v1/login/``. The auth *mechanism* is unchanged — still
# Django's ``authenticate``/``login`` behind the JSON endpoint
# (`api/views/auth.py`); only the UI surface differs. The shell
# carries no user data, so serving it to anonymous users discloses
# nothing the static bundle wouldn't, and every data API call still
# returns 403 until the user authenticates.
"REACT_LOGIN": False,
# ``REACT_LOGIN`` — React-rendered login is the **default** so the
# SPA fully replaces the Django admin URL surface end-to-end (owner
# directive 2026-05-28). ``SpaIndexView`` serves the React shell to
# anonymous users (with the CSRF cookie set) and the in-SPA login
# form POSTs to the API package's ``/api/v1/login/``. A consumer
# who wants the legacy HTML-admin login back can opt out with
# ``"REACT_LOGIN": False`` — the package's own ``<mount>/login/``
# endpoint is still mounted in either mode. The auth *mechanism* is
# unchanged in both modes (Django's ``authenticate``/``login``
# behind the JSON endpoint); only the UI surface differs. The
# shell carries no user data — serving it to anonymous users
# discloses nothing the static bundle wouldn't, and every data API
# call still returns 403 until the user authenticates.
"REACT_LOGIN": True,
# PWA (Issue #86) — all optional; sane defaults make the manifest
# work with zero config. See ``django_admin_react/pwa.py`` +
# ``docs/ux/pwa.md``.
Expand Down
47 changes: 35 additions & 12 deletions tests/test_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,21 @@ def test_non_staff_rejected_at_login(client: Client, make_user) -> None:
# Re-renders the form with an error; never establishes a staff session.
assert response.status_code == 200
assert "staff account" in response.content.decode().lower()
# The SPA still bounces them.
assert client.get(SPA_URL).status_code in (302,)
# Under the post-2026-05-28 default (`REACT_LOGIN=True`), the SPA
# shell serves to a non-staff session and the React app re-renders
# the login form. Every wire call still 403s, so no data leaks.
assert client.get(SPA_URL).status_code == 200


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


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


# --------------------------------------------------------------------------- #
# Fallback when the legacy admin is OFF #
# --------------------------------------------------------------------------- #
@override_settings(ROOT_URLCONF="tests.test_project.urls_no_admin", LOGIN_URL="/accounts/login/")
@override_settings(
ROOT_URLCONF="tests.test_project.urls_no_admin",
LOGIN_URL="/accounts/login/",
DJANGO_ADMIN_REACT={"REACT_LOGIN": False},
)
@pytest.mark.django_db
def test_spa_falls_back_to_package_login_when_admin_off(client: Client) -> None:
# No admin mounted + LOGIN_URL is Django's untouched default → the
# package login must be the redirect target.
response = client.get(SPA_URL)
assert response.status_code == 302
assert response["Location"].startswith(LOGIN_URL)
assert "next=" in response["Location"]
"""Legacy fallback path (`REACT_LOGIN=False`): with no admin mounted
and a default `LOGIN_URL`, the SPA index view redirects to the
package's own `<mount>/login/`. Preserved for consumers who opt out
of the React-rendered login (the post-2026-05-28 default is
`REACT_LOGIN=True`, which serves the shell to anon instead — that
path is covered in `test_spa_index.py`)."""
import django_admin_react.conf as _conf
import importlib
import django_admin_react.views as _views
importlib.reload(_conf)
importlib.reload(_views)
try:
response = client.get(SPA_URL)
assert response.status_code == 302
assert response["Location"].startswith(LOGIN_URL)
assert "next=" in response["Location"]
finally:
importlib.reload(_conf)
importlib.reload(_views)
50 changes: 29 additions & 21 deletions tests/test_spa_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,29 +59,30 @@ def fake_manifest(tmp_path: Path) -> Path:
# Auth gate #
# --------------------------------------------------------------------------- #
@pytest.mark.django_db
def test_anonymous_user_redirected_to_login(anon_client: Client) -> None:
def test_anonymous_user_gets_shell_under_react_login_default(anon_client: Client) -> None:
"""Default mode (`REACT_LOGIN=True`, the post-2026-05-28 default):
anonymous users get the React shell so the in-SPA login form renders
— replacing the legacy admin login URL surface end-to-end. The shell
carries no user data; every API call still 403s until the user is
authenticated."""
response = anon_client.get(ROOT_URL)
assert response.status_code == 302
# The package leaves LOGIN_URL up to the consumer's settings — only
# assert that the redirect carries the SPA path as the ``next``
# parameter so the user lands back here after login. The ``next``
# value is percent-encoded (CodeQL py/url-redirection fix), so the
# raw path appears encoded in Location; decode the query to compare.
location = response["Location"]
assert "next=" in location
query = parse_qs(urlsplit(location).query)
assert query["next"][0].startswith(ROOT_URL)
assert response.status_code == 200
body = response.content.decode("utf-8")
# The shell is what's served (not a redirect).
assert 'name="dar-mount"' in body


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


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

Expand Down
Loading