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
21 changes: 17 additions & 4 deletions django_admin_react/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@

from django.conf import settings as django_settings

# Built-in fallback for the ``--dar-primary`` accent color when the
# consumer hasn't set ``PRIMARY_COLOR`` AND their ``AdminSite`` has no
# ``site_primary_color`` attribute. Re-exported so ``views.py`` can
# pick up the same constant instead of stringifying its own.
DEFAULT_PRIMARY_COLOR = "#2563eb"

DEFAULTS: dict[str, Any] = {
"ADMIN_SITE": "django.contrib.admin.site",
# The list page size derives from the model's
Expand Down Expand Up @@ -59,9 +65,16 @@
# ``--dar-primary`` CSS variable so a consumer can brand the admin with
# no React rebuild. Must be a hex color (``#rgb`` / ``#rgba`` /
# ``#rrggbb`` / ``#rrggbbaa``); anything else is rejected at render and
# 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",
# falls back to ``DEFAULT_PRIMARY_COLOR`` below, since the value is
# written into a ``<style>`` block and must not be able to inject CSS.
#
# ``None`` (default) means "consumer didn't explicitly set this" — the
# SPA reads ``site_primary_color`` off the configured ``AdminSite``
# next, then falls back to ``DEFAULT_PRIMARY_COLOR``. Mirrors
# ``BRAND_TITLE`` / ``BRAND_LOGO_URL``: setting wins as the
# per-deployment override, AdminSite attr is the structural default,
# built-in default last (#631).
"PRIMARY_COLOR": None,
# ``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
Expand Down Expand Up @@ -149,7 +162,7 @@ class _PackageSettings:
ENABLE_PROFILING: bool = DEFAULTS["ENABLE_PROFILING"]
BRAND_TITLE: str | None = DEFAULTS["BRAND_TITLE"]
BRAND_LOGO_URL: str | None = DEFAULTS["BRAND_LOGO_URL"]
PRIMARY_COLOR: str = DEFAULTS["PRIMARY_COLOR"]
PRIMARY_COLOR: str | None = DEFAULTS["PRIMARY_COLOR"]
REACT_LOGIN: bool = DEFAULTS["REACT_LOGIN"]
API_URL_PREFIX: str | None = DEFAULTS["API_URL_PREFIX"]
LEGACY_ADMIN_URL_PREFIX: str | None = DEFAULTS["LEGACY_ADMIN_URL_PREFIX"]
Expand Down
27 changes: 21 additions & 6 deletions django_admin_react/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
"brand_title": _resolve_brand_title(admin_site),
"tab_title": _resolve_tab_title(admin_site),
"brand_logo_url": _resolve_brand_logo(admin_site),
"primary_color": _resolve_primary_color(),
"primary_color": _resolve_primary_color(admin_site),
"initial_theme": _resolve_initial_theme(request),
},
)
Expand Down Expand Up @@ -315,19 +315,34 @@ def _resolve_brand_logo(admin_site: Any) -> str | None:
_HEX_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")


def _resolve_primary_color() -> str:
def _resolve_primary_color(admin_site: Any) -> str:
"""The validated accent color injected as ``--dar-primary``.

Resolution order — matches ``BRAND_TITLE`` / ``BRAND_LOGO_URL`` so a
consumer with a custom ``AdminSite`` can brand the whole admin
(legacy + SPA) from one place without a settings entry (#631):

1. ``DJANGO_ADMIN_REACT["PRIMARY_COLOR"]`` — explicit per-deployment
override.
2. ``admin_site.site_primary_color`` — convention for shops with a
custom ``AdminSite`` subclass (``site_header`` / ``site_logo``
pattern). Stock Django has no such attribute; consumers add it.
3. The package default (``#2563eb``).

The value lands inside a ``<style>`` block in the SPA template, where
HTML-escaping does NOT prevent CSS injection (``}``/``;`` aren't
HTML-special). So only a strict hex color is allowed; anything else
(or a non-string) falls back to the default. This is a trust boundary
even though the value comes from the consumer's own settings.
HTML-special). So only a strict hex color is allowed at every layer;
anything else (or a non-string) falls through to the next step and
eventually the default. This is a trust boundary even though the
value comes from the consumer's own settings / site attribute.
"""
configured = dar_conf.PRIMARY_COLOR
if isinstance(configured, str) and _HEX_COLOR_RE.match(configured.strip()):
return configured.strip()
return dar_conf.DEFAULTS["PRIMARY_COLOR"]
site_color = getattr(admin_site, "site_primary_color", None)
if isinstance(site_color, str) and _HEX_COLOR_RE.match(site_color.strip()):
return site_color.strip()
return dar_conf.DEFAULT_PRIMARY_COLOR


def _resolve_initial_theme(request: HttpRequest) -> str | None:
Expand Down
59 changes: 59 additions & 0 deletions tests/test_spa_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,65 @@ def test_primary_color_non_hex_value_cannot_inject_css(superuser_client: Client)
assert "--dar-primary: #2563eb;" in html


@pytest.mark.django_db
def test_primary_color_falls_back_to_admin_site_attr(superuser_client: Client) -> None:
"""When `PRIMARY_COLOR` is unset, the SPA reads `site_primary_color`
off the AdminSite — mirrors `site_header` / `site_logo` so a consumer
with a custom AdminSite can brand from one place (#631)."""
with override_settings(DJANGO_ADMIN_REACT={}):
_reload_conf()
original = getattr(default_admin_site, "site_primary_color", None)
default_admin_site.site_primary_color = "#10b981" # emerald
try:
html = superuser_client.get(ROOT_URL).content.decode("utf-8")
assert "--dar-primary: #10b981;" in html
finally:
if original is None:
del default_admin_site.site_primary_color
else:
default_admin_site.site_primary_color = original


@pytest.mark.django_db
def test_primary_color_setting_wins_over_admin_site_attr(superuser_client: Client) -> None:
"""Explicit `PRIMARY_COLOR` setting overrides `site_primary_color` —
the setting is the per-deployment override, the attr is the
structural default. Same precedence as `BRAND_TITLE` (#631)."""
with override_settings(DJANGO_ADMIN_REACT={"PRIMARY_COLOR": "#ff8800"}):
_reload_conf()
original = getattr(default_admin_site, "site_primary_color", None)
default_admin_site.site_primary_color = "#10b981"
try:
html = superuser_client.get(ROOT_URL).content.decode("utf-8")
assert "--dar-primary: #ff8800;" in html
assert "#10b981" not in html
finally:
if original is None:
del default_admin_site.site_primary_color
else:
default_admin_site.site_primary_color = original


@pytest.mark.django_db
def test_primary_color_admin_site_non_hex_is_rejected(superuser_client: Client) -> None:
"""A non-hex `site_primary_color` on the AdminSite still can't inject
CSS — same regex gate as `PRIMARY_COLOR`. Falls through to the
default (#437 / #631)."""
with override_settings(DJANGO_ADMIN_REACT={}):
_reload_conf()
original = getattr(default_admin_site, "site_primary_color", None)
default_admin_site.site_primary_color = "red; } body { display: none } :root {"
try:
html = superuser_client.get(ROOT_URL).content.decode("utf-8")
assert "display: none" not in html
assert "--dar-primary: #2563eb;" in html
finally:
if original is None:
del default_admin_site.site_primary_color
else:
default_admin_site.site_primary_color = original


@pytest.mark.django_db
def test_brand_logo_url_renders_favicon_and_meta(superuser_client: Client) -> None:
"""`BRAND_LOGO_URL` populates both the `<link rel="icon">` and the
Expand Down
Loading