Skip to content

Commit 6ba9504

Browse files
fix(spa): detail-page action toast picks colour by message level (#632)
Plus lock-in tests for two audit verifications that already worked (#629 / #636 — close-with-tests). ## #632 — toast colour by Django level The detail page's `ObjectActionButton.onSuccess` was unconditionally calling `toast.success(message || 'Done')` — so a `message_user(..., level=messages.ERROR)` from a per-object action toasted GREEN instead of red. The list page already dispatched by level correctly; the asymmetry was hidden behind the legacy `runObjectAction` adapter in `@dar/data/mutations.ts`, which collapsed the `ActionRunResponse.messages[]` (each with a level) into a single `message` string and dropped the level. Fix: - `ObjectActionRunResponse` now carries `messages?: UserMessage[]` alongside the legacy `message?: string`. - `runObjectAction` propagates the full list. - A new shared `toastMessages(toast, messages)` helper lives next to `useToast` and maps each level to the right toast method (`error` / `warning` → red, `info` / `debug` → blue, `success` / unknown → green) — mirrors what the list page already does inline. - `DetailPage`'s `onSuccess` now uses the helper when `messages[]` is present; falls back to `toast.success(message || 'Done')` for back-compat with v1.4.x APIs that don't emit messages. ## #629 — prepopulated_fields verify-and-lock The wire already carries `prepopulated_fields`; `CreatePage` already slugifies the target from sources on each keystroke and opts the target out of further auto-fill once the operator edits it by hand. Pulled the logic out of `handleFieldChange` into a pure `applyPrepopulate(...)` helper so a vitest can pin the four behaviours (single source, joined sources, manual-edit lock, empty fallback) without rendering the whole CreatePage. ## #636 — open-redirect protection on `?next=` `DarLoginView` is a `LoginView` subclass that doesn't override `get_success_url` / `form_valid` / `success_url_allowed_hosts`, so the parent's same-host check on `?next=` is intact today. Added a pytest that POSTs the login with `?next=https://evil.example.com/` and asserts the redirect target is NOT off-host — locks the property so a future override can't silently re-introduce the open-redirect. ## Verification - `pnpm -r typecheck` ✓ - `pnpm lint` (js + css + darkmode) ✓ - `pnpm test` — 174 / 174 (up from 163; +11 new) ✓ - `poetry run pytest tests/test_login_next_redirect.py -v` — 2 / 2 ✓ - `pnpm -w build` ✓ Closes #629, closes #632, closes #636. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f48d798 commit 6ba9504

10 files changed

Lines changed: 280 additions & 16 deletions

File tree

frontend/apps/web/src/pages/CreatePage.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { Breadcrumb, Button, Card, EmptyState } from '@dar/ui';
2020
import { FieldInput } from '@dar/form';
2121

2222
import { RecordSkeleton } from '../components/RecordSkeleton';
23-
import { slugify } from '../slugify';
23+
import { applyPrepopulate } from './prepopulate';
2424
import { useModelMeta } from '../useModelMeta';
2525
import { useToast } from '../toast';
2626
import { useUnsavedGuard } from '../useUnsavedGuard';
@@ -184,16 +184,13 @@ function CreateForm({ schema, onCreate, onCancel }: CreateFormProps) {
184184

185185
function handleFieldChange(name: string, v: WriteValue): void {
186186
setValues((prev) => {
187-
const next = { ...prev, [name]: v };
188187
// A direct edit of a target field opts it out of further auto-fill.
189188
if (prepopTargets.has(name)) editedTargets.current.add(name);
190-
for (const [target, sources] of Object.entries(prepopulated)) {
191-
if (editedTargets.current.has(target)) continue;
192-
if (!sources.includes(name)) continue;
193-
const joined = sources.map((s) => (next[s] == null ? '' : String(next[s]))).join(' ');
194-
next[target] = slugify(joined);
195-
}
196-
return next;
189+
return applyPrepopulate({
190+
values: { ...prev, [name]: v },
191+
prepopulated,
192+
editedTargets: editedTargets.current,
193+
});
197194
});
198195
}
199196

frontend/apps/web/src/pages/DetailPage.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
type InlineWriteItem,
3939
type InlineWritePayload,
4040
type ObjectActionDescriptor,
41+
type ObjectActionRunResponse,
4142
type WriteValue,
4243
} from '@dar/data';
4344
import { detailCollapseKey, usePersistedState } from '@dar/customization';
@@ -48,7 +49,7 @@ import { HistoryModal } from '@dar/history';
4849

4950
import { RecordSkeleton } from '../components/RecordSkeleton';
5051
import { useModelMeta } from '../useModelMeta';
51-
import { useToast } from '../toast';
52+
import { toastMessages, useToast } from '../toast';
5253
import { carryPreservedFilters, listPathWithPreservedFilters } from '../changelistFilters';
5354
import { useUnsavedGuard } from '../useUnsavedGuard';
5455

@@ -310,13 +311,21 @@ export function DetailPage({
310311
onRun={() =>
311312
runObjectAction({ client, appLabel, modelName, pk, name: action.name })
312313
}
313-
onSuccess={async (message, redirect) => {
314+
onSuccess={async ({ message, messages, redirect }) => {
314315
if (redirect) {
315316
navigate(redirect);
316317
return;
317318
}
318319
await refresh();
319-
toast.success(message || 'Done');
320+
// Dispatch by Django level tag (#632) — error /
321+
// warning levels must NOT toast green. Falls back to a
322+
// single-line success toast for v1.4.x APIs that don't
323+
// emit `messages[]`.
324+
if (messages && messages.length > 0) {
325+
toastMessages(toast, messages);
326+
} else {
327+
toast.success(message || 'Done');
328+
}
320329
}}
321330
onError={(message) => toast.error(message)}
322331
/>
@@ -444,8 +453,8 @@ function ObjectActionButton({
444453
onError,
445454
}: {
446455
action: ObjectActionDescriptor;
447-
onRun: () => Promise<{ ok: boolean; message?: string; redirect?: string }>;
448-
onSuccess: (message: string | undefined, redirect: string | undefined) => Promise<void> | void;
456+
onRun: () => Promise<ObjectActionRunResponse>;
457+
onSuccess: (result: ObjectActionRunResponse) => Promise<void> | void;
449458
onError: (message: string) => void;
450459
}) {
451460
const [busy, setBusy] = useState(false);
@@ -460,7 +469,7 @@ function ObjectActionButton({
460469
try {
461470
const result = await onRun();
462471
if (result.ok) {
463-
await onSuccess(result.message, result.redirect);
472+
await onSuccess(result);
464473
} else {
465474
onError(result.message || 'The action could not be completed.');
466475
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Lock the prepopulated_fields behaviour (#245 / #629). Verifies:
2+
// 1. typing in a source field slugifies the target on every keystroke
3+
// 2. multiple sources are joined with a space, then slugified together
4+
// 3. once the operator edits a target by hand, further changes to
5+
// its sources DO NOT overwrite — the manual edit wins
6+
// 4. a target with no source values stays an empty string
7+
import { describe, expect, it } from 'vitest';
8+
9+
import { applyPrepopulate } from './prepopulate';
10+
11+
describe('applyPrepopulate', () => {
12+
const prepopulated = { slug: ['title'] };
13+
14+
it('slugifies the target from a single source on first edit', () => {
15+
const out = applyPrepopulate({
16+
values: { title: 'Hello World' },
17+
prepopulated,
18+
editedTargets: new Set(),
19+
});
20+
expect(out.slug).toBe('hello-world');
21+
});
22+
23+
it('joins multiple sources with a space before slugifying', () => {
24+
const out = applyPrepopulate({
25+
values: { first: 'Acme', second: 'Holdings Inc' },
26+
prepopulated: { slug: ['first', 'second'] },
27+
editedTargets: new Set(),
28+
});
29+
expect(out.slug).toBe('acme-holdings-inc');
30+
});
31+
32+
it('does not overwrite a target the operator has edited by hand', () => {
33+
const out = applyPrepopulate({
34+
values: { title: 'New Title', slug: 'custom-slug' },
35+
prepopulated,
36+
editedTargets: new Set(['slug']),
37+
});
38+
expect(out.slug).toBe('custom-slug');
39+
});
40+
41+
it('leaves the target as an empty string when every source is empty', () => {
42+
const out = applyPrepopulate({
43+
values: { title: '' },
44+
prepopulated,
45+
editedTargets: new Set(),
46+
});
47+
expect(out.slug).toBe('');
48+
});
49+
50+
it('returns a new object — does not mutate the input', () => {
51+
const values = { title: 'X' };
52+
const out = applyPrepopulate({ values, prepopulated, editedTargets: new Set() });
53+
expect(out).not.toBe(values);
54+
expect(values).not.toHaveProperty('slug');
55+
});
56+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// prepopulated_fields helper (#245 / #629). Pure function so the
2+
// CreatePage form logic is testable without rendering the page —
3+
// CreatePage owns the per-target "user has edited this" set and
4+
// passes it in; this just applies the slugify-from-sources rule
5+
// to every still-untouched target.
6+
//
7+
// Mirrors Django admin's add-form behaviour: typing in a source
8+
// field auto-fills the target until the operator edits the target
9+
// by hand, after which auto-fill stops for that record.
10+
import type { WriteValue } from '@dar/data';
11+
12+
import { slugify } from '../slugify';
13+
14+
export interface PrepopulateArgs {
15+
/** Current draft values (the function is non-mutating — returns
16+
* a NEW object with the updated targets). */
17+
values: Record<string, WriteValue>;
18+
/** From `schema.prepopulated_fields`: `{target: [...sourceFields]}`. */
19+
prepopulated: Record<string, string[]>;
20+
/** Target field names the operator has edited directly — these
21+
* are SKIPPED so a manual edit isn't overwritten. */
22+
editedTargets: ReadonlySet<string>;
23+
}
24+
25+
export function applyPrepopulate({
26+
values,
27+
prepopulated,
28+
editedTargets,
29+
}: PrepopulateArgs): Record<string, WriteValue> {
30+
const next = { ...values };
31+
for (const [target, sources] of Object.entries(prepopulated)) {
32+
if (editedTargets.has(target)) continue;
33+
const joined = sources.map((s) => (next[s] == null ? '' : String(next[s]))).join(' ');
34+
next[target] = slugify(joined);
35+
}
36+
return next;
37+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Lock the level → toast-method dispatch (#632). The detail page
2+
// previously toasted success-green for every action, including
3+
// `message_user(..., level=messages.ERROR)`, because the legacy
4+
// `runObjectAction` adapter dropped the level field. This test
5+
// pins the per-level routing so a regression turns red, not silent.
6+
import { describe, expect, it, vi } from 'vitest';
7+
8+
import { toastMessages } from './toast';
9+
10+
function makeToast() {
11+
return {
12+
success: vi.fn<(message: string) => void>(),
13+
error: vi.fn<(message: string) => void>(),
14+
info: vi.fn<(message: string) => void>(),
15+
};
16+
}
17+
18+
describe('toastMessages', () => {
19+
it('routes `error` to toast.error (red)', () => {
20+
const t = makeToast();
21+
toastMessages(t, [{ level: 'error', message: 'boom' }]);
22+
expect(t.error).toHaveBeenCalledWith('boom');
23+
expect(t.success).not.toHaveBeenCalled();
24+
expect(t.info).not.toHaveBeenCalled();
25+
});
26+
27+
it('routes `warning` to toast.error (red — closest available)', () => {
28+
const t = makeToast();
29+
toastMessages(t, [{ level: 'warning', message: 'careful' }]);
30+
expect(t.error).toHaveBeenCalledWith('careful');
31+
});
32+
33+
it('routes `info` to toast.info (blue)', () => {
34+
const t = makeToast();
35+
toastMessages(t, [{ level: 'info', message: 'fyi' }]);
36+
expect(t.info).toHaveBeenCalledWith('fyi');
37+
});
38+
39+
it('routes `debug` to toast.info (blue)', () => {
40+
const t = makeToast();
41+
toastMessages(t, [{ level: 'debug', message: 'trace' }]);
42+
expect(t.info).toHaveBeenCalledWith('trace');
43+
});
44+
45+
it('routes `success` (and unknown levels) to toast.success (green)', () => {
46+
const t = makeToast();
47+
toastMessages(t, [
48+
{ level: 'success', message: 'ok' },
49+
{ level: 'unknown', message: 'mystery' },
50+
]);
51+
expect(t.success).toHaveBeenCalledWith('ok');
52+
expect(t.success).toHaveBeenCalledWith('mystery');
53+
});
54+
55+
it('dispatches every entry in order — no merging, no dedupe', () => {
56+
const t = makeToast();
57+
toastMessages(t, [
58+
{ level: 'error', message: 'first' },
59+
{ level: 'success', message: 'second' },
60+
{ level: 'error', message: 'third' },
61+
]);
62+
expect(t.error).toHaveBeenCalledTimes(2);
63+
expect(t.error).toHaveBeenNthCalledWith(1, 'first');
64+
expect(t.error).toHaveBeenNthCalledWith(2, 'third');
65+
expect(t.success).toHaveBeenCalledWith('second');
66+
});
67+
});

frontend/apps/web/src/toast.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,27 @@ export function useToast(): ToastApi {
3232
return ctx;
3333
}
3434

35+
/**
36+
* Dispatch a `message_user`-emitted message list to the toast API,
37+
* picking the colour per Django's level tag (#632 — list AND detail
38+
* page now share this; the detail page previously dropped the level
39+
* via the legacy `runObjectAction` adapter and toasted success-green
40+
* for every action even when the level was `error` / `warning`).
41+
*
42+
* Mirrors the legacy admin: `error` / `warning` → red, `info` /
43+
* `debug` → blue, `success` (and any unknown level) → green.
44+
*/
45+
export function toastMessages(
46+
toast: ToastApi,
47+
messages: ReadonlyArray<{ level: string; message: string }>,
48+
): void {
49+
for (const m of messages) {
50+
if (m.level === 'error' || m.level === 'warning') toast.error(m.message);
51+
else if (m.level === 'info' || m.level === 'debug') toast.info(m.message);
52+
else toast.success(m.message);
53+
}
54+
}
55+
3556
export function ToastProvider({ children }: { children: ReactNode }) {
3657
const [toasts, setToasts] = useState<Toast[]>([]);
3758
const idRef = useRef(0);

frontend/packages/api/src/contract.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,13 @@ export type ObjectActionDescriptor = ActionDescriptor;
319319
*/
320320
export interface ObjectActionRunResponse {
321321
ok: boolean;
322+
/** Convenience: first `messages[]` entry's text — kept for back-compat
323+
* with v1.4.x consumers that only ever toasted one line. */
322324
message?: string;
325+
/** Every `message_user` line with its Django level tag (#632 — needed
326+
* so the SPA can pick red / amber / blue / green per level instead of
327+
* always toasting green on the detail page). */
328+
messages?: UserMessage[];
323329
redirect?: string;
324330
}
325331

frontend/packages/data/src/mutations.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ export async function runObjectAction(
8989
return {
9090
ok: res.executed,
9191
...(message !== undefined ? { message } : {}),
92+
// Propagate the full level-tagged messages list (#632) so the
93+
// detail page can pick the toast colour per level — the legacy
94+
// single-`message` field collapses every message into one string
95+
// with no level information.
96+
...(res.messages !== undefined ? { messages: res.messages } : {}),
9297
...(res.redirect !== undefined ? { redirect: res.redirect } : {}),
9398
};
9499
}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-admin-react"
3-
version = "1.4.11"
3+
version = "1.4.12"
44
description = "A drop-in React single-page admin for Django, driven entirely by ModelAdmin."
55
authors = ["django-admin-react contributors"]
66
license = "MIT"

tests/test_login_next_redirect.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Lock the ``?next=`` open-redirect protection on ``DarLoginView`` (#636).
2+
3+
``DarLoginView`` subclasses ``django.contrib.auth.views.LoginView``,
4+
which defaults ``success_url_allowed_hosts = set()`` and rejects any
5+
``next`` query value that points off-host. The risk: a subclass that
6+
overrides ``get_success_url`` / ``form_valid`` without preserving the
7+
allow-list check would silently re-introduce an open redirect — every
8+
"please log in, then return to <X>" mail-out would become a phishing
9+
weapon.
10+
11+
This test posts a login with ``?next=https://evil.example.com/`` and
12+
asserts the post-login redirect target is NOT off-host. If a future
13+
change overrides the redirect helpers without the safe-host gate,
14+
this test fails red and the regression doesn't ship.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from urllib.parse import urlparse
20+
21+
import pytest
22+
from django.contrib.auth import get_user_model
23+
from django.test import Client
24+
25+
LOGIN_URL = "/admin-react/login/"
26+
27+
28+
@pytest.fixture
29+
def staff_user(db):
30+
u = get_user_model().objects.create_user(username="alice", password="pw12345!")
31+
u.is_staff = True
32+
u.is_active = True
33+
u.save()
34+
return u
35+
36+
37+
def test_next_param_rejects_external_host(staff_user):
38+
"""An off-host ``next`` value MUST NOT survive into the redirect target."""
39+
c = Client()
40+
response = c.post(
41+
f"{LOGIN_URL}?next=https://evil.example.com/phish",
42+
data={"username": "alice", "password": "pw12345!"},
43+
)
44+
# Successful login should redirect to the safe default, not off-host.
45+
assert response.status_code == 302, "Login POST should 302 on success."
46+
target = response.headers["Location"]
47+
parsed = urlparse(target)
48+
# An empty netloc means same-host (a path-only redirect like `/admin/`).
49+
# Any non-empty netloc must NOT be the attacker's host.
50+
assert parsed.netloc in ("", "testserver"), (
51+
f"Login redirected to off-host URL {target!r} — open redirect."
52+
)
53+
54+
55+
def test_next_param_accepts_same_host_path(staff_user):
56+
"""A same-host ``next`` path IS honoured (the legitimate use case)."""
57+
c = Client()
58+
response = c.post(
59+
f"{LOGIN_URL}?next=/admin-react/auth/user/",
60+
data={"username": "alice", "password": "pw12345!"},
61+
)
62+
assert response.status_code == 302
63+
target = response.headers["Location"]
64+
parsed = urlparse(target)
65+
assert parsed.netloc in ("", "testserver")
66+
assert parsed.path == "/admin-react/auth/user/"

0 commit comments

Comments
 (0)