Each author account now carries five grantable permissions, editable on the Users page: publish posts, unpublish posts, delete posts (all three scoped to the author's own posts), create tags, and approve comments (scoped to comments on the author's posts, without delete). A bare author writes and edits their own drafts only. Permission checks gate the status TRANSITION, so editing an already-published post never requires the publish permission, and the editor's status dropdown only offers what the account may do. Tags an author creates are granted to them automatically, and "creating" an existing off-grant tag is refused (it would be a self-grant loophole). Existing author accounts keep publish+unpublish via migration backfill. Tags the admin attaches outside an author's grants now survive the author's edits: the form shows them checked-and-locked and the server re-attaches them on every save. Every account can change its own password on the new /admin/account page (current password required); the username in the admin header links there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
99 lines
4.1 KiB
TypeScript
99 lines
4.1 KiB
TypeScript
import Link from "next/link";
|
|
import { logoutAction } from "@/actions/auth";
|
|
import { requireUser } from "@/lib/auth/dal";
|
|
import { countCommentsByStatus } from "@/lib/services/comments";
|
|
import { getSettings } from "@/lib/services/settings";
|
|
|
|
const navLinkClasses =
|
|
"rounded-md px-2.5 py-1.5 text-sm font-medium text-ink transition-colors hover:bg-background hover:text-ink-strong";
|
|
|
|
/**
|
|
* Every route in this group is server-guarded: the layout redirects
|
|
* anonymous visitors, each page calls requireAdmin() again (defense in
|
|
* depth), and every mutating server action re-checks on its own.
|
|
*/
|
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
|
const user = await requireUser();
|
|
const isAdmin = user.role === "admin";
|
|
const canModerate = user.permissions.approveComments;
|
|
const [settings, commentCounts] = await Promise.all([
|
|
getSettings(),
|
|
canModerate
|
|
? countCommentsByStatus(isAdmin ? undefined : { postAuthorId: user.id })
|
|
: { pending: 0, approved: 0 },
|
|
]);
|
|
|
|
return (
|
|
<>
|
|
<header className="border-b border-edge bg-surface">
|
|
<div className="container-site flex flex-wrap items-center justify-between gap-x-6 gap-y-2 py-3">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-x-6 gap-y-2">
|
|
<Link
|
|
href="/admin"
|
|
className="font-semibold text-ink-bright transition-colors hover:text-link"
|
|
>
|
|
{settings.siteTitle}
|
|
<span className="font-normal text-ink-muted"> · Admin</span>
|
|
</Link>
|
|
<nav aria-label="Admin sections">
|
|
{/* Authors only manage posts; everything else is the admin's. */}
|
|
<ul className="flex items-center gap-1">
|
|
{isAdmin && <li><Link href="/admin" className={navLinkClasses}>Dashboard</Link></li>}
|
|
<li><Link href="/admin/posts" className={navLinkClasses}>Posts</Link></li>
|
|
{isAdmin && (
|
|
<li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li>
|
|
)}
|
|
{canModerate && (
|
|
<li>
|
|
<Link href="/admin/comments" className={navLinkClasses}>
|
|
Comments
|
|
{commentCounts.pending > 0 && (
|
|
<span className="ml-1.5 inline-flex min-w-5 items-center justify-center rounded-full bg-warning/20 px-1.5 py-0.5 text-xs font-semibold text-warning">
|
|
{commentCounts.pending}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
</li>
|
|
)}
|
|
{isAdmin && (
|
|
<>
|
|
<li><Link href="/admin/users" className={navLinkClasses}>Users</Link></li>
|
|
<li><Link href="/admin/settings" className={navLinkClasses}>Settings</Link></li>
|
|
</>
|
|
)}
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<Link href="/" className="text-ink-muted transition-colors hover:text-link">
|
|
View site
|
|
</Link>
|
|
<span aria-hidden="true" className="text-edge-strong">|</span>
|
|
<span className="text-ink-muted">
|
|
Signed in as{" "}
|
|
<Link
|
|
href="/admin/account"
|
|
className="text-ink-strong underline-offset-4 transition-colors hover:text-link hover:underline"
|
|
title="Account settings"
|
|
>
|
|
{user.username}
|
|
</Link>
|
|
</span>
|
|
<form action={logoutAction}>
|
|
<button
|
|
type="submit"
|
|
className="rounded-md border border-edge px-3 py-1.5 font-medium text-ink transition-colors hover:border-edge-strong hover:text-ink-strong"
|
|
>
|
|
Log out
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<main id="main" className="container-site flex-1 py-8">
|
|
{children}
|
|
</main>
|
|
</>
|
|
);
|
|
}
|