feat: phase 4 admin CMS — dashboard, editor, media, CSRF

Head Hen CMS end-to-end: dashboard lists all posts (drafts + published),
Markdown editor with live preview + drag-drop image upload, Pillow media
pipeline re-encoding every upload to JPEG, post CRUD + publish toggle +
hard delete, About page edit, and double-submit CSRF cookie enforced on
every admin mutating endpoint (Phase 3's TODO markers resolved).

Slug auto-generated on create and server-locked once a post has been
published. Unpublish preserves `published_at` so re-publish keeps
original date ordering. Every admin write invalidates the read-side
Post/Page TTL caches and records an `auth_events` audit row.

CSRF middleware is narrow by design — issues/refreshes the `cb_csrf`
cookie only on `GET /admin*`, and mutating endpoints opt in via
`require_csrf_form` or `require_csrf_header` Depends. Public routes,
healthz, and pre-auth login stay untouched.

64 new tests cover slugs, CSRF, media, admin posts/pages services, and
end-to-end CMS routes. Tests never mock the DB — real temp SQLite files
per the CLAUDE.md mandate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 20:42:01 -05:00
parent 76875a455e
commit 9a8506970c
30 changed files with 3831 additions and 74 deletions

View File

@@ -0,0 +1,43 @@
{#
Single row of the admin dashboard post table.
Context (inherited from the parent):
- post : app.models.entities.Post
- csrf_token : str
#}
<tr class="post-table__row post-table__row--{{ post.status.value }}">
<td>
<a href="/admin/posts/{{ post.id }}/edit">{{ post.title }}</a>
</td>
<td><code>{{ post.slug }}</code></td>
<td>
<span class="status-badge status-badge--{{ post.status.value }}">
{{ post.status.value|capitalize }}
</span>
</td>
<td>
<time datetime="{{ post.updated_at.isoformat() }}">
{{ post.updated_at.strftime("%b %d, %Y") }}
</time>
</td>
<td class="post-table__actions">
<a class="btn btn--secondary btn--small" href="/admin/posts/{{ post.id }}/edit">Edit</a>
<form class="post-table__inline-form"
action="/admin/posts/{{ post.id }}/publish"
method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn--secondary btn--small">
{% if post.status.value == "published" %}Unpublish{% else %}Publish{% endif %}
</button>
</form>
<form class="post-table__inline-form"
action="/admin/posts/{{ post.id }}/delete"
method="post"
onsubmit="return confirm('Delete this post? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn--danger btn--small">Delete</button>
</form>
</td>
</tr>

View File

@@ -8,10 +8,13 @@
label, and a logout control when the viewer is authenticated.
Context the child template may override:
- title : <title> content
- content : main body
- user : app.models.entities.User | None
(passed by the index route; omitted on pre-auth pages)
- title : <title> content
- content : main body
- user : app.models.entities.User | None
(passed by authed routes; omitted on pre-auth pages)
- csrf_token : str
(empty string on pre-auth pages; otherwise the signed
CSRF token issued by CSRFCookieMiddleware)
#}<!doctype html>
<html lang="en">
<head>
@@ -19,6 +22,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Admin &mdash; Chicken Babies R Us{% endblock %}</title>
<meta name="robots" content="noindex, nofollow">
{#
CSRF meta tag: admin JS (live preview, drag-drop upload) reads
this to send the X-CSRF-Token header. Empty string on pre-auth
pages is harmless — those endpoints don't require CSRF.
#}
<meta name="csrf-token" content="{{ csrf_token|default('', true) }}">
<link rel="icon" href="{{ url_for('static', path='img/favicon.ico') }}" sizes="any">
<link rel="stylesheet" href="{{ url_for('static', path='css/site.css') }}">
</head>
@@ -40,16 +49,17 @@
<nav class="site-nav" aria-label="Admin">
<ul class="site-nav__list">
<li class="site-nav__item">
<span class="site-nav__link" aria-current="page">Admin</span>
<a class="site-nav__link" href="/admin">Dashboard</a>
</li>
{% if user is defined and user %}
<li class="site-nav__item">
{#
Plain POST form — no CSRF token yet. Phase 6 adds a
double-submit token; SameSite=Lax is sufficient in the
meantime.
Plain POST form with the double-submit CSRF token.
The token is stamped into the form by the route and
verified against the `cb_csrf` cookie server-side.
#}
<form action="/admin/logout" method="post" class="site-nav__logout-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token|default('', true) }}">
<button type="submit" class="btn btn--link">Log out</button>
</form>
</li>
@@ -72,5 +82,7 @@
</p>
</div>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,85 @@
{#
Admin dashboard — post list + About edit link + new-post button.
Context:
- user : app.models.entities.User (required)
- posts : list[Post] (newest-updated first)
- about : app.models.entities.Page | None
- msg : str (PRG flash key)
- csrf_token : str (for the inline forms)
#}
{% extends "admin/base.html" %}
{% block title %}Dashboard &mdash; Admin{% endblock %}
{% block content %}
<section class="admin-dashboard">
<header class="admin-dashboard__header">
<h1 class="admin-dashboard__title">Dashboard</h1>
<p class="admin-dashboard__greeting">
Signed in as <code>{{ user.email }}</code>.
</p>
</header>
{% if msg %}
<p class="admin-flash admin-flash--ok" role="status">
{% if msg == "created" %}Post created.
{% elif msg == "saved" %}Changes saved.
{% elif msg == "deleted" %}Post deleted.
{% elif msg == "published" %}Post published.
{% elif msg == "unpublished" %}Post moved to draft.
{% else %}Done.
{% endif %}
</p>
{% endif %}
<section class="admin-dashboard__section">
<div class="admin-dashboard__section-head">
<h2>Posts</h2>
<a class="btn btn--primary" href="/admin/posts/new">New post</a>
</div>
{% if posts %}
<table class="post-table">
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Slug</th>
<th scope="col">Status</th>
<th scope="col">Updated</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{% for post in posts %}
{% include "admin/_post_row.html" %}
{% endfor %}
</tbody>
</table>
{% else %}
<p class="post-list__empty">No posts yet &mdash; create one.</p>
{% endif %}
</section>
<section class="admin-dashboard__section">
<div class="admin-dashboard__section-head">
<h2>About page</h2>
</div>
{% if about %}
<p>
<strong>{{ about.title }}</strong>
&mdash; last updated
<time datetime="{{ about.updated_at.isoformat() }}">
{{ about.updated_at.strftime("%b %d, %Y") }}
</time>
</p>
<p>
<a class="btn btn--secondary" href="/admin/pages/about/edit">Edit About</a>
</p>
{% else %}
<p class="post-list__empty">About page is missing. Reseed to recover.</p>
{% endif %}
</section>
</section>
{% endblock %}

View File

@@ -1,29 +0,0 @@
{#
Admin landing page (post-login).
Phase 3 keeps this minimal — Phase 4 turns it into the CMS
dashboard (pages + posts + media). For now it's a welcome screen +
logout control (rendered in admin/base.html's nav since ``user`` is
present in context).
Context:
- user : app.models.entities.User (guaranteed by require_admin)
#}
{% extends "admin/base.html" %}
{% block title %}Welcome &mdash; Admin{% endblock %}
{% block content %}
<article class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">Welcome, {{ user.display_name }}</h1>
</header>
<p>
You're logged in as <code>{{ user.email }}</code>. The dashboard
(pages, posts, media) lands in Phase 4.
</p>
<p>
<a href="/">&laquo; Back to the public site</a>
</p>
</article>
{% endblock %}

View File

@@ -0,0 +1,75 @@
{#
About page edit form.
Context:
- user : app.models.entities.User
- page : app.models.entities.Page
- form : dict {title, body_md}
- errors : dict {field_name: message}
- csrf_token : str
#}
{% extends "admin/base.html" %}
{% block title %}Edit About &mdash; Admin{% endblock %}
{% block content %}
<section class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">Edit About page</h1>
<p>Slug: <code>{{ page.slug }}</code> &middot; slug is fixed.</p>
</header>
{% if errors %}
<p class="admin-flash admin-flash--error" role="alert">
{% for field, msg in errors.items() %}
<span>{{ msg }}</span>
{% endfor %}
</p>
{% endif %}
<form class="editor" method="post" action="/admin/pages/about">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="editor__field">
<label for="page-title">Title</label>
<input type="text"
id="page-title"
name="title"
value="{{ form.title|e }}"
required>
</div>
<div class="editor__split">
<div class="editor__pane">
<label for="page-body">Body (Markdown)</label>
<textarea id="page-body"
name="body_md"
data-editor
data-preview-target="#page-preview"
rows="20">{{ form.body_md|e }}</textarea>
<div class="drop-zone" data-drop-zone
aria-label="Drag an image here to upload and insert it">
Drop an image here to upload &amp; insert. Accepted: JPG, PNG, WebP up to 8&nbsp;MB.
</div>
</div>
<div class="editor__pane">
<span class="editor__label">Preview</span>
<div id="page-preview" class="editor__preview" aria-live="polite">
{{ page.body_html_cached|safe }}
</div>
</div>
</div>
<div class="editor__actions">
<button type="submit" class="btn btn--primary">Save changes</button>
<a class="btn btn--secondary" href="/admin">Cancel</a>
</div>
</form>
</section>
{% endblock %}
{% block scripts %}
<script defer src="{{ url_for('static', path='js/admin_editor.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{#
Shared create + edit form for posts.
Context:
- user : app.models.entities.User
- post : app.models.entities.Post | None (None on create)
- form : dict {title, body_md, status}
- errors : dict {field_name: message}
- csrf_token : str
Status dropdown policy:
- On create: draft is default, admin may pick published.
- On edit: status is read-only here — use the toggle-publish
button on the dashboard. Keeps the write path explicit.
#}
{% extends "admin/base.html" %}
{% block title %}
{% if post %}Edit post{% else %}New post{% endif %} &mdash; Admin
{% endblock %}
{% block content %}
<section class="page-article">
<header class="page-article__header">
<h1 class="page-article__title">
{% if post %}Edit post{% else %}New post{% endif %}
</h1>
{% if post %}
<p>Slug: <code>{{ post.slug }}</code>
{% if post.status.value == "published" %}
&middot; slug is locked because this post is published.
{% endif %}
</p>
{% endif %}
</header>
{% if errors %}
<p class="admin-flash admin-flash--error" role="alert">
{% for field, msg in errors.items() %}
<span>{{ msg }}</span>
{% endfor %}
</p>
{% endif %}
<form class="editor"
method="post"
action="{% if post %}/admin/posts/{{ post.id }}{% else %}/admin/posts{% endif %}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="editor__field">
<label for="post-title">Title</label>
<input type="text"
id="post-title"
name="title"
value="{{ form.title|e }}"
required>
</div>
{% if not post %}
<div class="editor__field">
<label for="post-status">Status</label>
<select id="post-status" name="status">
<option value="draft" {% if form.status == "draft" %}selected{% endif %}>Draft</option>
<option value="published" {% if form.status == "published" %}selected{% endif %}>Published</option>
</select>
</div>
{% endif %}
<div class="editor__split">
<div class="editor__pane">
<label for="post-body">Body (Markdown)</label>
<textarea id="post-body"
name="body_md"
data-editor
data-preview-target="#post-preview"
rows="20">{{ form.body_md|e }}</textarea>
<div class="drop-zone" data-drop-zone
aria-label="Drag an image here to upload and insert it">
Drop an image here to upload &amp; insert. Accepted: JPG, PNG, WebP up to 8&nbsp;MB.
</div>
</div>
<div class="editor__pane">
<span class="editor__label">Preview</span>
<div id="post-preview" class="editor__preview" aria-live="polite">
{% if post %}{{ post.body_html_cached|safe }}{% endif %}
</div>
</div>
</div>
<div class="editor__actions">
<button type="submit" class="btn btn--primary">
{% if post %}Save changes{% else %}Create post{% endif %}
</button>
<a class="btn btn--secondary" href="/admin">Cancel</a>
</div>
</form>
</section>
{% endblock %}
{% block scripts %}
<script defer src="{{ url_for('static', path='js/admin_editor.js') }}"></script>
{% endblock %}