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:
43
app/templates/admin/_post_row.html
Normal file
43
app/templates/admin/_post_row.html
Normal 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>
|
||||
@@ -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 — 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>
|
||||
|
||||
85
app/templates/admin/dashboard.html
Normal file
85
app/templates/admin/dashboard.html
Normal 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 — 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 — 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>
|
||||
— 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 %}
|
||||
@@ -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 — 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="/">« Back to the public site</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
75
app/templates/admin/page_form.html
Normal file
75
app/templates/admin/page_form.html
Normal 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 — 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> · 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 & insert. Accepted: JPG, PNG, WebP up to 8 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 %}
|
||||
104
app/templates/admin/post_form.html
Normal file
104
app/templates/admin/post_form.html
Normal 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 %} — 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" %}
|
||||
· 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 & insert. Accepted: JPG, PNG, WebP up to 8 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 %}
|
||||
Reference in New Issue
Block a user