diff --git a/TODO.md b/TODO.md index 22f3cf6..b0f218b 100644 --- a/TODO.md +++ b/TODO.md @@ -32,9 +32,9 @@ This file is the single source of truth for roadmap and delivery progress. - [x] [P1] First-start onboarding route for initial owner creation (`/welcome`) - [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links - [~] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access -- [ ] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository) -- [ ] [P1] Shared CRUD validation strategy (Zod + server-side enforcement) -- [ ] [P1] Shared error and audit hooks for CRUD mutations +- [~] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository) +- [~] [P1] Shared CRUD validation strategy (Zod + server-side enforcement) +- [~] [P1] Shared error and audit hooks for CRUD mutations ### Admin App @@ -44,6 +44,7 @@ This file is the single source of truth for roadmap and delivery progress. - [~] [P2] Base admin dashboard shell and roadmap page (`/todo`) - [x] [P1] Authentication and session model (`admin`, `editor`, `manager`) - [x] [P1] Protected admin routes and session handling +- [~] [P1] Temporary admin posts CRUD sandbox for baseline functional validation - [ ] [P1] Core admin IA (pages/media/users/commissions/settings) ### Public App @@ -73,7 +74,7 @@ This file is the single source of truth for roadmap and delivery progress. - [x] [P1] Docs tool baseline added (`docs/` via VitePress) - [x] [P1] RBAC and permission model documentation in docs site - [ ] [P2] i18n conventions docs (keys, namespaces, fallback, translation workflow) -- [ ] [P1] CRUD base patterns documentation and examples +- [~] [P1] CRUD base patterns documentation and examples - [ ] [P1] Environment and deployment runbook docs (dev/staging/production) - [ ] [P2] API and domain glossary pages - [ ] [P2] Architecture Decision Records (ADR) structure and first ADRs @@ -194,6 +195,8 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-10] Auth delete-account endpoints now block protected users (support + canonical owner); admin user-management delete/demote guards remain to be implemented. - [2026-02-10] Public app i18n baseline now uses `next-intl` with a Zustand-backed language switcher and path-stable routes; admin i18n runtime is still pending. - [2026-02-10] Public baseline locales are now `de`, `en`, `es`, `fr`; locale enable/disable policy will move to admin settings later. +- [2026-02-10] Shared CRUD base (`@cms/crud`) is live with validation, not-found errors, and audit hook contracts; only posts are migrated so far. +- [2026-02-10] Admin dashboard includes a temporary posts CRUD sandbox (create/update/delete) to validate the shared CRUD base through the real app UI. ## How We Use This File diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx index 7a025d5..564a275 100644 --- a/apps/admin/src/app/page.tsx +++ b/apps/admin/src/app/page.tsx @@ -1,6 +1,7 @@ import { hasPermission } from "@cms/content/rbac" -import { listPosts } from "@cms/db" +import { createPost, deletePost, listPosts, updatePost } from "@cms/db" import { Button } from "@cms/ui/button" +import { revalidatePath } from "next/cache" import Link from "next/link" import { redirect } from "next/navigation" @@ -9,7 +10,131 @@ import { LogoutButton } from "./logout-button" export const dynamic = "force-dynamic" -export default async function AdminHomePage() { +type SearchParamsInput = Record + +function readFirstValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null + } + + return value ?? null +} + +function readRequiredField(formData: FormData, field: string): string { + const value = formData.get(field) + + if (typeof value !== "string") { + return "" + } + + return value.trim() +} + +function readOptionalField(formData: FormData, field: string): string | undefined { + const value = readRequiredField(formData, field) + return value.length > 0 ? value : undefined +} + +async function requireNewsWritePermission() { + const role = await resolveRoleFromServerContext() + + if (!role || !hasPermission(role, "news:write", "team")) { + redirect("/unauthorized?required=news:write&scope=team") + } +} + +function redirectWithState(params: { notice?: string; error?: string }) { + const query = new URLSearchParams() + + if (params.notice) { + query.set("notice", params.notice) + } + + if (params.error) { + query.set("error", params.error) + } + + const value = query.toString() + redirect(value ? `/?${value}` : "/") +} + +async function createPostAction(formData: FormData) { + "use server" + + await requireNewsWritePermission() + + const status = readRequiredField(formData, "status") + + try { + await createPost({ + title: readRequiredField(formData, "title"), + slug: readRequiredField(formData, "slug"), + excerpt: readOptionalField(formData, "excerpt"), + body: readRequiredField(formData, "body"), + status: status === "published" ? "published" : "draft", + }) + } catch { + redirectWithState({ error: "Create failed. Please check your input." }) + } + + revalidatePath("/") + redirectWithState({ notice: "Post created." }) +} + +async function updatePostAction(formData: FormData) { + "use server" + + await requireNewsWritePermission() + + const id = readRequiredField(formData, "id") + const status = readRequiredField(formData, "status") + + if (!id) { + redirectWithState({ error: "Update failed. Missing post id." }) + } + + try { + await updatePost(id, { + title: readRequiredField(formData, "title"), + slug: readRequiredField(formData, "slug"), + excerpt: readOptionalField(formData, "excerpt"), + body: readRequiredField(formData, "body"), + status: status === "published" ? "published" : "draft", + }) + } catch { + redirectWithState({ error: "Update failed. Please check your input." }) + } + + revalidatePath("/") + redirectWithState({ notice: "Post updated." }) +} + +async function deletePostAction(formData: FormData) { + "use server" + + await requireNewsWritePermission() + + const id = readRequiredField(formData, "id") + + if (!id) { + redirectWithState({ error: "Delete failed. Missing post id." }) + } + + try { + await deletePost(id) + } catch { + redirectWithState({ error: "Delete failed." }) + } + + revalidatePath("/") + redirectWithState({ notice: "Post deleted." }) +} + +export default async function AdminHomePage({ + searchParams, +}: { + searchParams: Promise +}) { const role = await resolveRoleFromServerContext() if (!role) { @@ -20,6 +145,9 @@ export default async function AdminHomePage() { redirect("/unauthorized?required=news:read&scope=team") } + const resolvedSearchParams = await searchParams + const notice = readFirstValue(resolvedSearchParams.notice) + const error = readFirstValue(resolvedSearchParams.error) const canCreatePost = hasPermission(role, "news:write", "team") const posts = await listPosts() @@ -40,22 +168,168 @@ export default async function AdminHomePage() { + {notice ? ( +
+ {notice} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} +
-
-

Posts

- +
+
+

Posts CRUD Sandbox

+

MVP0 functional test

+
+ + {canCreatePost ? ( +
+

Create post

+
+ + +
+ +