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
14 changes: 14 additions & 0 deletions django_admin_react/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@
# URL or a path under your ``STATIC_URL``.
"BRAND_TITLE": None,
"BRAND_LOGO_URL": None,
# ``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,
}


Expand All @@ -58,6 +71,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"]
REACT_LOGIN: bool = DEFAULTS["REACT_LOGIN"]


def _load() -> _PackageSettings:
Expand Down
15 changes: 13 additions & 2 deletions django_admin_react/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,21 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
# noqa: ARG002 — args/kwargs only present to satisfy CBV signature.
admin_site = get_admin_site()
if not is_admin_user(request, admin_site=admin_site):
return _redirect_to_login(request)
# Default: redirect anonymous / unauthorized users to the
# HTML login. When the consumer opts into the React login
# (``DJANGO_ADMIN_REACT["REACT_LOGIN"]``), serve the SPA
# shell instead so the React app renders its own login form
# (which POSTs to ``/api/v1/login/``). The shell holds no
# user data — bundle loader + mount/brand meta only — so
# anonymous access discloses nothing the static assets
# wouldn't, and every data API call still 403s until login.
if not dar_conf.REACT_LOGIN:
return _redirect_to_login(request)

# Force CSRF cookie so the SPA can read it before any unsafe
# method (the fetch client attaches it as ``X-CSRFToken``).
# method (the fetch client attaches it as ``X-CSRFToken``). Runs
# for anonymous users too under ``REACT_LOGIN`` so the login
# POST carries a valid CSRF token.
get_token(request)

return render(
Expand Down
19 changes: 19 additions & 0 deletions frontend/apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { Route, Routes } from 'react-router-dom';

import { ApiError, useRegistry } from '@dar/data';

import { Layout } from './Layout';
import { HomePage } from './pages/HomePage';
import { ListPage } from './pages/ListPage';
import { DetailPage } from './pages/DetailPage';
import { LoginPage } from './pages/LoginPage';

export function App() {
const registry = useRegistry();

// Auth gate (Issue #167). When the registry load comes back
// unauthenticated (401) or forbidden (403), the session is invalid —
// render the React login full-screen instead of the admin layout.
// This only ever renders when the backend served the SPA shell to an
// anonymous user, i.e. the consumer set
// ``DJANGO_ADMIN_REACT["REACT_LOGIN"]``; otherwise ``SpaIndexView``
// redirected to the HTML login and the SPA never booted. Gating on
// the error status (not on ``data``) means a stale localStorage-cached
// registry can't keep a dead session looking alive.
const { error, refresh } = registry;
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
return <LoginPage onSuccess={refresh} />;
}

return (
<Layout>
<Routes>
Expand Down
16 changes: 6 additions & 10 deletions frontend/apps/web/src/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,13 @@ function filterApps(apps: RegistryApp[], query: string): RegistryApp[] {
const out: RegistryApp[] = [];
for (const app of apps) {
const appMatches =
app.verbose_name.toLowerCase().includes(q) ||
app.app_label.toLowerCase().includes(q);
app.verbose_name.toLowerCase().includes(q) || app.app_label.toLowerCase().includes(q);
if (appMatches) {
out.push(app);
continue;
}
const models = app.models.filter(
(m) =>
modelLabel(m).toLowerCase().includes(q) ||
m.model_name.toLowerCase().includes(q),
(m) => modelLabel(m).toLowerCase().includes(q) || m.model_name.toLowerCase().includes(q),
);
if (models.length > 0) out.push({ ...app, models });
}
Expand All @@ -84,10 +81,7 @@ export function Layout({ children }: PropsWithChildren) {
const [drawerOpen, setDrawerOpen] = useState(false);

const apps = (data?.apps ?? []) as RegistryApp[];
const totalModels = useMemo(
() => apps.reduce((n, app) => n + app.models.length, 0),
[apps],
);
const totalModels = useMemo(() => apps.reduce((n, app) => n + app.models.length, 0), [apps]);
const showFilter = totalModels >= FILTER_THRESHOLD;
const visibleApps = useMemo(
() => (showFilter ? filterApps(apps, query) : apps),
Expand Down Expand Up @@ -151,7 +145,9 @@ export function Layout({ children }: PropsWithChildren) {
onClick={closeDrawer}
className="flex items-center gap-2 text-lg font-semibold hover:text-white"
>
{BRAND_LOGO_URL && <img src={BRAND_LOGO_URL} alt="" className="h-6 w-6 shrink-0 rounded" />}
{BRAND_LOGO_URL && (
<img src={BRAND_LOGO_URL} alt="" className="h-6 w-6 shrink-0 rounded" />
)}
<span>{BRAND_TITLE}</span>
</Link>
{data?.user && (
Expand Down
91 changes: 91 additions & 0 deletions frontend/apps/web/src/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// React login page (Issue #167).
//
// Rendered full-screen by <App> when the registry load comes back
// unauthenticated (401/403) AND the consumer opted into the React
// login (the backend serves the SPA shell to anonymous users only
// when DJANGO_ADMIN_REACT["REACT_LOGIN"] is set). It POSTs to
// /api/v1/login/ via the @dar/data client — which is a thin JSON
// shell over Django's own authenticate/login (api/views/auth.py).
//
// Data-layer rule (CLAUDE.md §7): this page reaches the network only
// through @dar/data's useApiClient(); it never imports @dar/api.

import { type FormEvent, useState } from 'react';

import { ApiError, useApiClient } from '@dar/data';
import { Button, Card, Input } from '@dar/ui';

export interface LoginPageProps {
/** Called after a successful login so the app can re-fetch state. */
onSuccess: () => void;
/** Optional brand title shown above the form. */
brandTitle?: string;
}

export function LoginPage({ onSuccess, brandTitle }: LoginPageProps) {
const client = useApiClient();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setLoading(true);
try {
await client.login(username, password);
// Success: hand back to the app to re-fetch the registry. We
// intentionally do NOT store the returned user here — the
// registry response is the single source of "who am I".
onSuccess();
} catch (err) {
// The backend returns one generic 403 for every failure mode
// (no username / permission enumeration), so we show one generic
// message regardless of the cause. A non-403 (e.g. network) gets
// its own message.
if (err instanceof ApiError && err.status === 403) {
setError('Invalid credentials or insufficient permissions.');
} else if (err instanceof ApiError) {
setError(err.message);
} else {
setError('Could not reach the server. Please try again.');
}
setLoading(false);
}
}

return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<Card className="w-full max-w-sm">
<form onSubmit={handleSubmit} className="flex flex-col gap-4 p-2">
<h1 className="text-center text-lg font-semibold text-gray-900">
{brandTitle ?? 'Sign in'}
</h1>
<Input
label="Username"
name="username"
autoComplete="username"
autoFocus
required
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Input
label="Password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
error={error ?? undefined}
/>
<Button type="submit" variant="primary" loading={loading} disabled={loading}>
Sign in
</Button>
</form>
</Card>
</div>
);
}
13 changes: 13 additions & 0 deletions frontend/packages/api/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
DetailResponse,
FieldErrorEnvelope,
ListResponse,
LoginResponse,
RegistryResponse,
UpdatePayload,
} from './contract';
Expand Down Expand Up @@ -160,6 +161,18 @@ export class ApiClient {
return this.request<DetailResponse>('GET', `${appLabel}/${modelName}/${pk}/`);
}

/**
* Authenticate via the package's React-login endpoint (contract §7,
* Issue #167). A thin JSON shell over Django's own
* `authenticate`/`login` (`api/views/auth.py`) — on success the
* session cookie is set and the user block returned; a bad login is
* a generic 403 surfaced as an `ApiError` (no user-enumeration
* oracle). CSRF is enforced by the middleware.
*/
login(username: string, password: string): Promise<LoginResponse> {
return this.request<LoginResponse>('POST', 'login/', { username, password });
}

create(appLabel: string, modelName: string, payload: CreatePayload): Promise<CreateResponse> {
return this.request<CreateResponse>('POST', `${appLabel}/${modelName}/`, payload);
}
Expand Down
48 changes: 48 additions & 0 deletions tests/test_spa_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,51 @@ def test_brand_logo_url_unset_falls_back_to_data_uri(superuser_client: Client) -
html = response.content.decode("utf-8")
assert 'rel="icon" href="data:,"' in html
assert 'name="dar-brand-logo"' not in html


# --------------------------------------------------------------------------- #
# 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={}):
_reload_conf()
try:
response = anon_client.get(ROOT_URL)
assert response.status_code == 302
finally:
_reload_conf()


def test_react_login_on_anon_gets_shell_not_redirect(
anon_client: Client, fake_manifest: Path
) -> None:
"""REACT_LOGIN=True: anonymous gets the SPA shell (200) + CSRF cookie.

The React app then renders its own login form (Issue #167). The
shell carries no user data, so serving it to an anonymous user is
safe — every data API call still 403s until they authenticate.
"""
with override_settings(DJANGO_ADMIN_REACT={"REACT_LOGIN": True}):
_reload_conf()
try:
response = anon_client.get(ROOT_URL)
assert response.status_code == 200
# CSRF cookie issued so the login POST can carry X-CSRFToken.
assert "csrftoken" in response.cookies
# The shell must not leak any authenticated-user data.
body = response.content.decode("utf-8", errors="replace").lower()
assert "is_superuser" not in body
finally:
_reload_conf()


def test_react_login_on_does_not_change_staff_path(superuser_client: Client) -> None:
"""REACT_LOGIN=True doesn't alter the authenticated-staff behavior."""
with override_settings(DJANGO_ADMIN_REACT={"REACT_LOGIN": True}):
_reload_conf()
try:
response = superuser_client.get(ROOT_URL)
assert response.status_code == 200
finally:
_reload_conf()
Loading