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
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.12.0] — 2026-06-02

### Added
- **Custom `change_form_template` admins now render INSIDE the SPA shell via a
server-rendered html-fragment — no iframe (#679).** The form-spec endpoint
(rest-api 1.7.0+, #75) renders a custom-template admin server-side, strips
the admin chrome, and returns `{renderer: "html-fragment", html, csrf_token,
submit_url, method, messages}`. The SPA injects that HTML into the content
area while the breadcrumb / sidebar / title / toolbar stay React-rendered.
Inline `<script>` / `<style>` the integrator's template emits **execute /
apply after injection**: `dangerouslySetInnerHTML` leaves parsed `<script>`
elements inert, so the new `HtmlFragment` component clones each into a fresh
`<script>` and re-inserts it (the dual-listbox JS only runs because of this).
The injected `<form>` POSTs via `fetch(…, {credentials: "include", headers:
{"X-CSRFToken": …}})` to the round-trip route; a returned `html-fragment`
re-injects in place (validation errors, no SPA route change), a
`{renderer: "redirect", to}` triggers an SPA `navigate(to)` (never a
`window.location` reload), and any Django `messages` surface as toasts.
The backend HTML is **trusted** — it is the integrator's own admin template,
rendered behind the same auth as `/admin/` over the same-origin API — so the
custom JS/CSS is injected verbatim, deliberately not sanitised (see the
`HtmlFragment.tsx` trust-boundary note). ModelAdmins that use only documented
hooks (`form` / `fieldsets` / `formfield_overrides` / `get_form`) are
unaffected — they keep rendering via the JSON field-map path. The
`examples/jobs` `?run_custom=1` dual-listbox fixture exercises the full path
(form-spec → POST → validation re-render → redirect) end-to-end against the
example backend.

### Removed
- **The `legacy-iframe` renderer and its iframe fallback are gone (#679).**
No iframe element is ever rendered for a custom-template form again. This
drops `LegacyIframe` (and the #673 "detect framing refusal → open-in-new-tab"
workaround) and the `safeLegacyUrl` same-origin validation that only guarded
the iframe `src`. Custom-template admins now render in-shell via the
html-fragment renderer above — no `X-Frame-Options`, `SameSite=None; Secure`,
or cross-origin cookie bridge required. The contract type drops
`LegacyIframeResponse` / `renderer: "legacy-iframe"` and adds
`HtmlFragmentResponse` (`renderer: "html-fragment"`) + `RedirectResponse`
(`renderer: "redirect"`).

### Changed
- **`django-admin-rest-api` floor raised to `^1.7.0` (#679).** 1.7.0 ships the
html-fragment form-spec renderer (`renderer: "html-fragment"`) plus the POST
round-trip route the in-shell custom form submits to. The SPA still degrades
gracefully to the JSON form-spec / detail-driven form on an older backend.

## [1.11.2] — 2026-06-02

### Fixed
Expand Down
85 changes: 36 additions & 49 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@ issues link the work to close each gap.

| Stock-Django hook | SPA behaviour | Tracked |
|---|---|---|
| `change_form_template` / `add_form_template` overrides | **Embedded in an iframe** (since 1.9.0, #659): the change/add form-spec endpoint returns a `legacy-iframe` pointer and the SPA embeds the legacy admin page inside the SPA shell (breadcrumb / sidebar / toolbar stay SPA-rendered). Port the form to documented ModelAdmin hooks at your own pace. | [#624](https://github.com/MartinCastroAlvarez/django-admin-react/issues/624) |
| `change_form_template` / `add_form_template` overrides | **Rendered server-side as an html-fragment, in-shell** (since 1.12.0, #679): the change/add form-spec endpoint renders the custom template server-side, strips the admin chrome, and returns `{renderer: "html-fragment", html, …}`; the SPA injects it into the content area while the breadcrumb / sidebar / title / toolbar stay React-rendered. The injected form's inline `<script>` / `<style>` run, its submit round-trips through the API (validation re-render / redirect / `messages` toasts), and **no iframe is used** — so no `X-Frame-Options` / `SameSite` configuration is required. Port the form to documented ModelAdmin hooks at your own pace. | [#624](https://github.com/MartinCastroAlvarez/django-admin-react/issues/624) |
| `change_list_template` / `change_password_template` / `object_history_template` overrides | Silently ignored — those surfaces render entirely from the JSON wire. | [#624](https://github.com/MartinCastroAlvarez/django-admin-react/issues/624) |
| `formfield_overrides = {Field: {"widget": CustomWidget}}` | Custom widget rendered via the React widget-registration API (`registerFieldWidget`, #625) when the consumer registers a renderer for the widget class; otherwise falls back to the default control + an operator-visible "not registered" note. | [#625](https://github.com/MartinCastroAlvarez/django-admin-react/issues/625) |
| `empty_value_display` | **Hard-coded to `—`.** A per-`ModelAdmin` / per-field `empty_value_display` override is **not** surfaced — the SPA renders the literal em-dash for every empty value, regardless of the consumer's chosen placeholder. | [#629](https://github.com/MartinCastroAlvarez/django-admin-react/issues/629) |
Expand All @@ -667,56 +667,43 @@ typical workaround is to keep that model on the legacy
[experience-toggle strip](#experience-toggle-strip-optional) — the
SPA + legacy admin happily coexist.

#### Embedding the legacy admin in an iframe — required backend headers

When a `ModelAdmin` overrides `change_form_template` / `add_form_template`,
the SPA embeds the legacy admin page in an `<iframe>` (the row above). For
the browser to actually render that frame, the **legacy admin responses must
allow being framed by the SPA's origin** — otherwise the embed is refused and
the SPA shows a "Embedding refused by the legacy admin — open in new tab"
fallback (never a broken-image icon, #673).

Most projects mount `django.middleware.clickjacking.XFrameOptionsMiddleware`,
which by default sets `X-Frame-Options: DENY` on **every** response and blocks
the iframe. Configure the legacy responses as follows:

**Same origin (SPA and legacy admin under one host — the common case):**

```python
# settings.py
X_FRAME_OPTIONS = "SAMEORIGIN" # was the implicit "DENY"
# …or drop XFrameOptionsMiddleware entirely if you don't need clickjacking
# protection on the legacy surface.
```

`SAMEORIGIN` lets the same-origin SPA frame the legacy page while still
blocking cross-site framing.

**Cross-origin (SPA and legacy admin on different origins):**

```python
# On the legacy admin responses (e.g. via a middleware or
# django-csp), allow ONLY the SPA origin to frame them:
# Content-Security-Policy: frame-ancestors https://admin.example.com
#
# And, because the iframe is a cross-site context, the legacy session
# cookie must be sent in it:
# settings.py
SESSION_COOKIE_SAMESITE = "None" # send the cookie in the cross-site frame
SESSION_COOKIE_SECURE = True # required whenever SameSite=None
#### Custom `change_form_template` admins — rendered in-shell (no iframe)

When a `ModelAdmin` overrides `change_form_template` / `add_form_template`
(or a `change_view` override renders a non-standard template — e.g. a
`?run_custom=1` branch), the JSON form-spec can't reproduce the form. Since
1.12.0 (#679) the form-spec endpoint (rest-api 1.7.0+) renders the admin's
real view **server-side**, strips the admin chrome, and returns the content
HTML for the SPA to inject inside its own shell:

```json
{
"renderer": "html-fragment",
"html": "<form …>…</form>",
"csrf_token": "…",
"submit_url": "/admin/<app>/<model>/<pk>/change/?<qs>",
"method": "POST",
"messages": [{ "level": "success", "text": "…" }]
}
```

Without these, the framed legacy page either refuses to load (`frame-ancestors`
block) or loads unauthenticated (cookie dropped) and bounces through a login
redirect the browser then refuses to display. The
[`examples/jobs`](examples/jobs) `JobAdmin` (the `?run_custom=1` variant)
exercises this path end-to-end against the example backend.

> The SPA detects a refused frame client-side (a `loading → loaded → refused`
> state machine: a ~4s window with no iframe `load` event ⇒ refused) and
> renders the fallback. A future server-side `legacy_iframeable` flag computed
> from the response middleware chain (cross-repo, rest-api) could switch to the
> "open in new tab only" UI immediately — tracked as a follow-up.
The SPA injects `html` into the content area (breadcrumb / sidebar / title /
toolbar stay React-rendered), **re-executes** the template's inline
`<script>` (so custom-widget JS / dual-listbox handlers / drag-and-drop run),
and wires the injected `<form>` to POST back through the API round-trip route
(`credentials: "include"` + `X-CSRFToken`). On the response: another
`html-fragment` re-injects in place (validation errors), a
`{renderer: "redirect", to}` triggers an SPA `navigate(to)`, and any Django
`messages` surface as toasts.

This needs **no iframe**, so there is nothing to configure: no
`X-Frame-Options`, no `Content-Security-Policy: frame-ancestors`, no
`SESSION_COOKIE_SAMESITE = "None"` cross-origin cookie bridge. The fragment is
same-origin and trusted (it is your own admin template, rendered behind the
same auth as `/admin/`), so its custom JS/CSS is injected verbatim. The
[`examples/jobs`](examples/jobs) `JobAdmin` (the `?run_custom=1` dual-listbox
variant) exercises the full path — form-spec → POST → validation re-render →
redirect — end-to-end against the example backend.

---

Expand Down
20 changes: 13 additions & 7 deletions examples/jobs/admin.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
"""``JobAdmin`` — the custom-form fixture that proves the legacy-iframe escape.
"""``JobAdmin`` — the custom-form fixture that exercises the html-fragment path.

This admin uses *only* documented Django ``ModelAdmin`` hooks — no
django-admin-react / -rest-api / -mcp-specific API. The point: if this runs
on the React admin at ``/admin-react/`` with at most a single-view iframe
fallback, then any legacy ``/admin/`` ModelAdmin does too.
inside the React admin shell at ``/admin2/`` with the server-rendered
html-fragment renderer, then any legacy ``/admin/`` ModelAdmin does too.

Two rendering paths:

* **Path A** — ``/admin-react/jobs/job/<pk>/change/`` (no query). The stock
* **Path A** — ``/admin2/jobs/job/<pk>/change/`` (no query). The stock
change form, fully describable by the form-spec contract.
``formfield_for_dbfield`` swaps ``metadata`` to a large textarea, which
the SPA renders natively from ``widget.kind == "textarea"``.
* **Path B** — ``?run_custom=1``. ``change_view`` returns a hand-rolled
dual-listbox template (not a ModelForm, not fieldsets). The form-spec
resolver detects the custom render and returns
``{renderer: "legacy-iframe", legacy_url: …}``; the SPA embeds the legacy
page in an iframe inside its own chrome.
resolver (rest-api 1.7.0+, #75) detects the custom render, renders it
SERVER-SIDE, strips the admin chrome, and returns
``{renderer: "html-fragment", html, csrf_token, submit_url, method,
messages}``. The SPA injects that HTML inside its own shell — breadcrumb /
sidebar / title / toolbar stay React-rendered, no iframe (#679). The
injected ``<form>`` POSTs back through the round-trip route, which
re-renders on a validation error (``messages.error`` + redirect-to-self) or
returns ``{renderer: "redirect", to}`` on success — surfaced as a SPA
``navigate()`` plus toasts.
"""

from __future__ import annotations
Expand Down
8 changes: 6 additions & 2 deletions examples/jobs/templates/admin/jobs/job/run_custom.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@

This is a *pure custom template* with a custom POST contract: no ModelForm,
no fieldsets, no model fields. The form-spec resolver detects this (the
overridden change_view renders a non-standard template) and tells the React
SPA to embed THIS legacy page in an iframe. The contract that must hold:
overridden change_view renders a non-standard template), renders it
server-side, strips the admin chrome, and hands the SPA the content-block
HTML — including the inline <script>/<style> below — to inject INSIDE its own

Check notice

Code scanning / CodeQL

Syntax error Note

Error: Unterminated regular expression
shell (no iframe, #679). The inline JS must survive into the SPA and run after
injection (the SPA re-executes injected <script> elements). The contract that
must hold:

request.POST.getlist("selected_steps") ← ordered, in DOM order of the
right-hand column.
Expand Down
78 changes: 76 additions & 2 deletions examples/jobs/tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Backend tests for the Job custom-form fixture.

Exercises the legacy ``/admin/`` side of the contract — the side the React
SPA embeds in an iframe for Path B. Pure Django ``TestCase`` + test client;
no browser / e2e.
SPA renders server-side as an html-fragment for Path B (#679). Pure Django
``TestCase`` + test client; no browser / e2e.
"""

from django.contrib import admin
Expand Down Expand Up @@ -66,3 +66,77 @@ def test_path_b_post_empty_is_rejected(self) -> None:
# Redirects back to the same custom view with an error message.
self.assertEqual(resp.status_code, 302)
self.assertIn("run_custom=1", resp["Location"])


class JobHtmlFragmentApiTests(TestCase):
"""End-to-end exercise of the SPA's html-fragment path (#679) against the
example backend's REST API — the side the React SPA actually consumes.

The SPA fetches ``GET …/form-spec/?run_custom=1`` (→ ``html-fragment``)
and POSTs the injected form back to ``POST …/<pk>/change/?run_custom=1``
(→ another ``html-fragment`` on a validation error, or a ``redirect`` on
success, with Django ``messages`` surfaced for SPA toasts). Pure Django
``TestCase`` + test client; no browser / e2e.
"""

@classmethod
def setUpTestData(cls) -> None:
cls.user = get_user_model().objects.create_superuser(
username="root",
email="root@example.com",
password="x", # noqa: S106
)
cls.job = Job.objects.create(name="nightly", status="idle")

def setUp(self) -> None:
self.client.force_login(self.user)

def _form_spec_url(self) -> str:
return f"/admin-react/api/v1/jobs/job/{self.job.pk}/form-spec/?run_custom=1"

def _change_post_url(self) -> str:
return f"/admin-react/api/v1/jobs/job/{self.job.pk}/change/?run_custom=1"

def test_form_spec_returns_html_fragment(self) -> None:
"""The custom-template view resolves to a server-rendered html-fragment
(not the JSON form-spec, never an iframe)."""
resp = self.client.get(self._form_spec_url())
self.assertEqual(resp.status_code, 200)
payload = resp.json()
self.assertEqual(payload["renderer"], "html-fragment")
# The dual-listbox markup + the inline JS survive into the fragment.
self.assertIn('data-step="fetch"', payload["html"])
self.assertIn("<script>", payload["html"])
# The form wiring the SPA needs is present.
self.assertTrue(payload["csrf_token"])
self.assertEqual(payload["method"], "POST")
self.assertIn("run_custom=1", payload["submit_url"])

def test_post_empty_re_renders_fragment_with_error(self) -> None:
"""An empty selection re-renders the fragment (the PRG-to-self idiom)
and carries the error message for the SPA to toast."""
resp = self.client.post(self._change_post_url(), data={})
self.assertEqual(resp.status_code, 200)
payload = resp.json()
self.assertEqual(payload["renderer"], "html-fragment")
self.assertTrue(
any(m["level"] == "error" for m in payload["messages"]),
payload["messages"],
)

def test_post_selection_redirects_to_spa(self) -> None:
"""A valid selection redirects; the target is mapped onto the SPA prefix
and the success message is carried for the SPA to toast."""
resp = self.client.post(
self._change_post_url(),
data={"selected_steps": ["validate", "fetch"]},
)
self.assertEqual(resp.status_code, 200)
payload = resp.json()
self.assertEqual(payload["renderer"], "redirect")
# Mapped onto the SPA prefix (not the legacy /admin/ mount).
self.assertNotIn("/admin/", payload["to"])
self.assertTrue(
any("validate → fetch" in m["text"] for m in payload["messages"]),
payload["messages"],
)
37 changes: 0 additions & 37 deletions frontend/apps/web/src/legacy-url.test.ts

This file was deleted.

Loading
Loading