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

@@ -517,6 +517,223 @@ a:focus-visible {
}
/* Admin-only components (dashboard, editor, drop-zone, badges). */
.admin-flash {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius);
margin-bottom: var(--space-3);
}
.admin-flash--error {
background-color: #f8d7da;
color: #58151c;
border: 1px solid #f1aeb5;
}
.admin-flash--ok {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.admin-dashboard__header {
margin-bottom: var(--space-4);
}
.admin-dashboard__title {
margin-bottom: var(--space-2);
}
.admin-dashboard__greeting {
color: var(--c-ink);
opacity: 0.85;
}
.admin-dashboard__section {
margin-top: var(--space-5);
}
.admin-dashboard__section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.post-table {
width: 100%;
border-collapse: collapse;
background-color: #ffffff;
border: 1px solid var(--c-wheat);
border-radius: var(--radius);
overflow: hidden;
}
.post-table th,
.post-table td {
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--c-wheat);
vertical-align: middle;
}
.post-table th {
background-color: var(--c-wheat);
font-weight: 700;
}
.post-table__row:last-child td {
border-bottom: 0;
}
.post-table__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.post-table__inline-form {
display: inline-block;
margin: 0;
}
.status-badge {
display: inline-block;
padding: 2px var(--space-2);
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
background-color: var(--c-wheat);
color: var(--c-ink);
}
.status-badge--published {
background-color: var(--c-leaf);
color: var(--c-cream);
}
.status-badge--draft {
background-color: var(--c-wheat);
color: var(--c-ink);
}
.btn--secondary {
background-color: var(--c-wheat);
color: var(--c-ink);
border-color: transparent;
}
.btn--secondary:hover,
.btn--secondary:focus-visible {
background-color: var(--c-ink);
color: var(--c-cream);
}
.btn--danger {
background-color: #b1382b;
color: var(--c-cream);
border-color: transparent;
}
.btn--danger:hover,
.btn--danger:focus-visible {
background-color: #7d2820;
}
.btn--small {
padding: var(--space-1) var(--space-2);
font-size: 0.9rem;
}
.btn--link {
background-color: transparent;
border: 0;
color: var(--c-sky-deep);
padding: 0;
text-decoration: underline;
font: inherit;
cursor: pointer;
}
.btn--link:hover,
.btn--link:focus-visible {
color: var(--c-ink);
}
.editor {
display: grid;
gap: var(--space-3);
}
.editor__field {
display: grid;
gap: var(--space-1);
}
.editor__field input,
.editor__field select,
.editor__field textarea,
.editor textarea {
padding: var(--space-2);
border: 1px solid var(--c-wheat);
border-radius: var(--radius);
background-color: #ffffff;
font-family: var(--font-sans);
}
.editor__split {
display: grid;
gap: var(--space-3);
}
.editor__pane {
display: grid;
gap: var(--space-2);
}
.editor__label {
font-weight: 600;
}
.editor__preview {
padding: var(--space-3);
background-color: #ffffff;
border: 1px solid var(--c-wheat);
border-radius: var(--radius);
min-height: 10rem;
}
.editor__actions {
display: flex;
gap: var(--space-2);
}
.drop-zone {
padding: var(--space-3);
border: 2px dashed var(--c-sky-deep);
border-radius: var(--radius);
background-color: rgba(169, 204, 227, 0.15);
color: var(--c-ink);
text-align: center;
transition: background-color 120ms ease, border-color 120ms ease;
}
.drop-zone.is-hover {
background-color: rgba(169, 204, 227, 0.35);
border-color: var(--c-ink);
}
.drop-zone.is-uploading {
opacity: 0.6;
}
.drop-zone.is-error {
border-color: #b1382b;
background-color: #fdecea;
}
/* 6. Responsive — tablet & up ------------------------------------------- */
@media (min-width: 48rem) {
h1 { font-size: 2.5rem; }
@@ -545,4 +762,8 @@ a:focus-visible {
.post-list {
gap: var(--space-5);
}
.editor__split {
grid-template-columns: 1fr 1fr;
}
}