Add granular author permissions, admin-tag persistence, own-password change

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>
This commit is contained in:
matt 2026-07-05 12:58:19 -04:00
parent fcb3010f81
commit 35fb33c5a7
26 changed files with 1832 additions and 109 deletions

View file

@ -0,0 +1,8 @@
ALTER TABLE "users" ADD COLUMN "can_create_tags" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "can_publish_posts" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "can_unpublish_posts" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "can_delete_posts" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "can_approve_comments" boolean DEFAULT false NOT NULL;--> statement-breakpoint
-- Existing authors could publish and unpublish before permissions existed;
-- keep that behavior for accounts created under the old rules.
UPDATE "users" SET "can_publish_posts" = true, "can_unpublish_posts" = true WHERE "role" = 'author';

File diff suppressed because it is too large Load diff

View file

@ -43,6 +43,13 @@
"when": 1783214024801,
"tag": "0005_accounts",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1783266633118,
"tag": "0006_author-permissions",
"breakpoints": true
}
]
}

View file

@ -2,12 +2,13 @@
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireAdmin } from "@/lib/auth/dal";
import { requireAdmin, requireUser } from "@/lib/auth/dal";
import type { FormState } from "@/lib/forms";
import { zodErrorToFormState } from "@/lib/forms";
import {
createComment,
deleteComment,
getCommentPostAuthorId,
setCommentStatus,
} from "@/lib/services/comments";
import { commentFormSchema } from "@/lib/validation";
@ -63,9 +64,16 @@ export async function setCommentStatusAction(
id: number,
status: "pending" | "approved",
): Promise<void> {
await requireAdmin();
const user = await requireUser();
const commentId = z.number().int().positive().parse(id);
const nextStatus = z.enum(["pending", "approved"]).parse(status);
if (user.role !== "admin") {
// Authors with the approve-comments permission moderate the
// comments sitting on their own posts, nothing else.
if (!user.permissions.approveComments) return;
const row = await getCommentPostAuthorId(commentId);
if (!row || row.postAuthorId !== user.id) return;
}
await setCommentStatus(commentId, nextStatus);
revalidatePath("/", "layout");
}

View file

@ -3,11 +3,11 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireAdmin, requireUser } from "@/lib/auth/dal";
import type { SessionUser } from "@/lib/auth/session";
import { requireUser } from "@/lib/auth/dal";
import type { FormState } from "@/lib/forms";
import { zodErrorToFormState } from "@/lib/forms";
import { sanitizeHtml } from "@/lib/html";
import { resolveAuthorTagIds, statusChangeError } from "@/lib/permissions";
import { isUniqueViolation, SlugConflictError } from "@/lib/services/errors";
import {
createPost,
@ -17,7 +17,9 @@ import {
setPostStatus,
updatePost,
} from "@/lib/services/posts";
import { getAllowedTagIds } from "@/lib/services/users";
import { getTagsBySlugs } from "@/lib/services/tags";
import { getAllowedTagIds, grantTags } from "@/lib/services/users";
import { slugify } from "@/lib/slug";
import { postFormSchema } from "@/lib/validation";
function readPostForm(formData: FormData) {
@ -50,51 +52,59 @@ function toPostInput(data: z.infer<typeof postFormSchema>): PostInput {
};
}
/**
* Author-role restrictions on a post's tags: no creating tags, and every
* tag must come from the account's grants with at least one, so the
* post stays inside the author's sandbox. Admins skip this entirely.
*/
async function checkAuthorTagRules(
user: SessionUser,
input: PostInput,
): Promise<FormState | null> {
if (input.newTagNames.length > 0) {
return { fieldErrors: { newTags: ["Only the admin can create new tags."] } };
}
const allowed = new Set(await getAllowedTagIds(user.id));
if (input.tagIds.length === 0) {
return {
fieldErrors: {
tagIds: [
allowed.size === 0
? "You have not been given access to any tags yet — ask the admin."
: "Choose at least one of your tags.",
],
},
};
}
if (input.tagIds.some((id) => !allowed.has(id))) {
return { fieldErrors: { tagIds: ["You can only use tags you have been given access to."] } };
}
return null;
}
async function savePost(id: number | null, formData: FormData): Promise<FormState> {
const user = await requireUser();
const parsed = readPostForm(formData);
if (!parsed.success) return zodErrorToFormState(parsed.error);
const input = toPostInput(parsed.data);
if (user.role !== "admin") {
let existingTagIds: number[] = [];
let fromStatus: "draft" | "published" | null = null;
if (id !== null) {
const existing = await getPostById(id);
if (!existing) return { formError: "This post no longer exists." };
if (existing.authorId !== user.id) {
return { formError: "You can only edit your own posts." };
}
existingTagIds = existing.tags.map((t) => t.id);
fromStatus = existing.status;
}
const tagError = await checkAuthorTagRules(user, input);
if (tagError) return tagError;
const statusErr = statusChangeError(user.permissions, fromStatus, input.status);
if (statusErr) return { formError: statusErr };
const allowed = new Set(await getAllowedTagIds(user.id));
if (input.newTagNames.length > 0) {
if (!user.permissions.createTags) {
return {
fieldErrors: { newTags: ["You do not have permission to create new tags."] },
};
}
// "Creating" a tag that already exists would silently self-grant
// access to it — refuse unless the author already has that grant.
const slugs = input.newTagNames.map(slugify).filter(Boolean);
const existingTags = await getTagsBySlugs(slugs);
const offLimits = existingTags.find((t) => !allowed.has(t.id));
if (offLimits) {
return {
fieldErrors: {
newTags: [
`The tag “${offLimits.name}” already exists — ask the admin for access to it.`,
],
},
};
}
}
const resolved = resolveAuthorTagIds({
submitted: input.tagIds,
existing: existingTagIds,
allowed,
creatingTags: input.newTagNames.length > 0,
});
if ("error" in resolved) return { fieldErrors: { tagIds: [resolved.error] } };
input.tagIds = resolved.tagIds;
}
let postId: number;
@ -118,6 +128,15 @@ async function savePost(id: number | null, formData: FormData): Promise<FormStat
return { formError: "Something went wrong while saving. Please try again." };
}
// Tags the author just created become part of their grants, so their
// next edit doesn't reject their own post.
if (user.role !== "admin" && input.newTagNames.length > 0) {
const saved = await getPostById(postId);
const known = new Set(input.tagIds);
const createdIds = (saved?.tags ?? []).filter((t) => !known.has(t.id)).map((t) => t.id);
await grantTags(user.id, createdIds);
}
revalidatePath("/", "layout");
redirect(`/admin/posts/${postId}/edit?saved=1`);
}
@ -136,18 +155,25 @@ export async function setPostStatusAction(id: number, status: "draft" | "publish
const postId = z.number().int().positive().parse(id);
const nextStatus = z.enum(["draft", "published"]).parse(status);
if (user.role !== "admin") {
// Authors may change status on their own posts, within their
// publish/unpublish permissions.
const post = await getPostById(postId);
// Authors may publish/unpublish their own posts only.
if (!post || post.authorId !== user.id) return;
if (statusChangeError(user.permissions, post.status, nextStatus) !== null) return;
}
await setPostStatus(postId, nextStatus);
revalidatePath("/", "layout");
}
export async function deletePostAction(id: number) {
// Deleting is admin-only, even for a post the author owns.
await requireAdmin();
const user = await requireUser();
const postId = z.number().int().positive().parse(id);
if (user.role !== "admin") {
// Authors with the delete permission may delete their own posts.
if (!user.permissions.deletePosts) return;
const post = await getPostById(postId);
if (!post || post.authorId !== user.id) return;
}
await deletePost(postId);
revalidatePath("/", "layout");
redirect("/admin/posts?deleted=1");

View file

@ -3,12 +3,31 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireAdmin } from "@/lib/auth/dal";
import { requireAdmin, requireUser } from "@/lib/auth/dal";
import type { FormState } from "@/lib/forms";
import { zodErrorToFormState } from "@/lib/forms";
import { isUniqueViolation } from "@/lib/services/errors";
import { createUser, deleteUser, updateUser } from "@/lib/services/users";
import { createUserFormSchema, updateUserFormSchema } from "@/lib/validation";
import {
changeOwnPassword,
createUser,
deleteUser,
updateUser,
} from "@/lib/services/users";
import {
changePasswordFormSchema,
createUserFormSchema,
updateUserFormSchema,
} from "@/lib/validation";
function readPermissions(formData: FormData) {
return {
canCreateTags: formData.get("canCreateTags"),
canPublishPosts: formData.get("canPublishPosts"),
canUnpublishPosts: formData.get("canUnpublishPosts"),
canDeletePosts: formData.get("canDeletePosts"),
canApproveComments: formData.get("canApproveComments"),
};
}
export async function createUserAction(
_prev: FormState,
@ -19,6 +38,7 @@ export async function createUserAction(
username: formData.get("username"),
password: formData.get("password"),
tagIds: formData.getAll("tagIds"),
permissions: readPermissions(formData),
});
if (!parsed.success) return zodErrorToFormState(parsed.error);
@ -44,6 +64,7 @@ export async function updateUserAction(
const parsed = updateUserFormSchema.safeParse({
password: formData.get("password"),
tagIds: formData.getAll("tagIds"),
permissions: readPermissions(formData),
});
if (!parsed.success) return zodErrorToFormState(parsed.error);
@ -59,6 +80,32 @@ export async function updateUserAction(
return { status: "success" };
}
/** Any signed-in account may change its own password. */
export async function changeOwnPasswordAction(
_prev: FormState,
formData: FormData,
): Promise<FormState> {
const user = await requireUser();
const parsed = changePasswordFormSchema.safeParse({
currentPassword: formData.get("currentPassword"),
newPassword: formData.get("newPassword"),
});
if (!parsed.success) return zodErrorToFormState(parsed.error);
try {
const result = await changeOwnPassword(
user.id,
parsed.data.currentPassword,
parsed.data.newPassword,
);
if (!result.ok) return { fieldErrors: { currentPassword: [result.error] } };
} catch (error) {
console.error("changeOwnPasswordAction failed", error);
return { formError: "Something went wrong while changing your password." };
}
return { status: "success" };
}
export async function deleteUserAction(id: number): Promise<void> {
const admin = await requireAdmin();
const userId = z.number().int().positive().parse(id);

View file

@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { changeOwnPasswordAction } from "@/actions/users";
import { ChangePasswordForm } from "@/components/admin/ChangePasswordForm";
import { requireUser } from "@/lib/auth/dal";
export const metadata: Metadata = { title: "Your account" };
/** Self-service account page — available to every signed-in account. */
export default async function AccountPage() {
const user = await requireUser();
return (
<div>
<h1 className="mb-2 text-2xl font-bold tracking-tight text-ink-bright">Your account</h1>
<p className="mb-6 text-sm text-ink-muted">
Signed in as <span className="font-medium text-ink-strong">{user.username}</span>
{" · "}
{user.role === "admin" ? "administrator" : "author"}
</p>
<section aria-labelledby="password-heading">
<h2 id="password-heading" className="mb-4 text-lg font-semibold text-ink-strong">
Change password
</h2>
<ChangePasswordForm action={changeOwnPasswordAction} />
</section>
</div>
);
}

View file

@ -1,15 +1,16 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { deleteCommentAction, setCommentStatusAction } from "@/actions/comments";
import { ConfirmButton } from "@/components/admin/ConfirmButton";
import { Button } from "@/components/ui";
import { requireAdmin } from "@/lib/auth/dal";
import { requireUser } from "@/lib/auth/dal";
import { formatDate } from "@/lib/format";
import { type AdminComment, listCommentsForAdmin } from "@/lib/services/comments";
export const metadata: Metadata = { title: "Comments" };
function CommentCard({ comment }: { comment: AdminComment }) {
function CommentCard({ comment, canDelete }: { comment: AdminComment; canDelete: boolean }) {
return (
<li className="rounded-lg border border-edge bg-surface p-4">
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
@ -53,6 +54,7 @@ function CommentCard({ comment }: { comment: AdminComment }) {
</Button>
</form>
)}
{canDelete && (
<form action={deleteCommentAction.bind(null, comment.id)}>
<ConfirmButton
confirmMessage={`Delete this comment by ${comment.authorName}? Replies to it are deleted too. This cannot be undone.`}
@ -61,14 +63,18 @@ function CommentCard({ comment }: { comment: AdminComment }) {
Delete
</ConfirmButton>
</form>
)}
</div>
</li>
);
}
export default async function AdminCommentsPage() {
await requireAdmin();
const all = await listCommentsForAdmin();
const user = await requireUser();
const isAdmin = user.role === "admin";
// Authors with the approve-comments permission moderate their own posts.
if (!user.permissions.approveComments) redirect("/admin/posts");
const all = await listCommentsForAdmin(isAdmin ? undefined : { postAuthorId: user.id });
const pending = all.filter((c) => c.status === "pending");
const approved = all.filter((c) => c.status === "approved");
@ -87,7 +93,7 @@ export default async function AdminCommentsPage() {
) : (
<ul className="mt-3 space-y-3">
{pending.map((comment) => (
<CommentCard key={comment.id} comment={comment} />
<CommentCard key={comment.id} comment={comment} canDelete={isAdmin} />
))}
</ul>
)}
@ -102,7 +108,7 @@ export default async function AdminCommentsPage() {
) : (
<ul className="mt-3 space-y-3">
{approved.map((comment) => (
<CommentCard key={comment.id} comment={comment} />
<CommentCard key={comment.id} comment={comment} canDelete={isAdmin} />
))}
</ul>
)}

View file

@ -15,9 +15,12 @@ const navLinkClasses =
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(),
isAdmin ? countCommentsByStatus() : { pending: 0, approved: 0 },
canModerate
? countCommentsByStatus(isAdmin ? undefined : { postAuthorId: user.id })
: { pending: 0, approved: 0 },
]);
return (
@ -38,8 +41,9 @@ export default async function AdminLayout({ children }: { children: React.ReactN
{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
@ -50,6 +54,9 @@ export default async function AdminLayout({ children }: { children: React.ReactN
)}
</Link>
</li>
)}
{isAdmin && (
<>
<li><Link href="/admin/users" className={navLinkClasses}>Users</Link></li>
<li><Link href="/admin/settings" className={navLinkClasses}>Settings</Link></li>
</>
@ -63,7 +70,14 @@ export default async function AdminLayout({ children }: { children: React.ReactN
</Link>
<span aria-hidden="true" className="text-edge-strong">|</span>
<span className="text-ink-muted">
Signed in as <span className="text-ink-strong">{user.username}</span>
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

View file

@ -34,6 +34,19 @@ export default async function EditPostPage({
// Authors only reach their own posts; others 404 like unknown ids.
if (!isAdmin && post.authorId !== user.id) notFound();
// Admin-added tags outside the author's grants are shown locked; the
// server preserves them across the author's saves.
const grantedIds = new Set(allTags.map((t) => t.id));
const lockedTags = isAdmin ? [] : post.tags.filter((t) => !grantedIds.has(t.id));
const statusOptions: Array<"draft" | "published"> =
post.status === "published"
? user.permissions.unpublishPosts
? ["draft", "published"]
: ["published"]
: user.permissions.publishPosts
? ["draft", "published"]
: ["draft"];
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
@ -61,8 +74,10 @@ export default async function EditPostPage({
<PostForm
post={post}
allTags={allTags}
lockedTags={lockedTags}
defaultAuthor={user.username}
canCreateTags={isAdmin}
canCreateTags={user.permissions.createTags}
statusOptions={statusOptions}
action={updatePostAction.bind(null, post.id)}
/>
</div>

View file

@ -21,7 +21,8 @@ export default async function NewPostPage() {
<PostForm
allTags={allTags}
defaultAuthor={user.username}
canCreateTags={isAdmin}
canCreateTags={user.permissions.createTags}
statusOptions={user.permissions.publishPosts ? ["draft", "published"] : ["draft"]}
action={createPostAction}
/>
</div>

View file

@ -21,6 +21,8 @@ export default async function AdminPostsPage({
}) {
const user = await requireUser();
const isAdmin = user.role === "admin";
// Session permissions are normalized: the admin has all of them.
const { publishPosts, unpublishPosts, deletePosts } = user.permissions;
const [sp, posts] = await Promise.all([
searchParams,
// Authors manage only their own posts; the admin manages everything.
@ -81,6 +83,7 @@ export default async function AdminPostsPage({
>
Preview
</Link>
{(post.status === "published" ? unpublishPosts : publishPosts) && (
<form
action={setPostStatusAction.bind(
null,
@ -92,7 +95,8 @@ export default async function AdminPostsPage({
{post.status === "published" ? "Unpublish" : "Publish"}
</button>
</form>
{isAdmin && (
)}
{deletePosts && (
<form action={deletePostAction.bind(null, post.id)}>
<ConfirmButton
confirmMessage={`Delete “${post.title}”? This cannot be undone.`}

View file

@ -35,6 +35,7 @@ export default async function AdminUsersPage({
<th scope="col" className="px-4 py-3 font-medium">Username</th>
<th scope="col" className="px-4 py-3 font-medium">Role</th>
<th scope="col" className="px-4 py-3 font-medium">Tag access</th>
<th scope="col" className="px-4 py-3 font-medium">Permissions</th>
<th scope="col" className="px-4 py-3 font-medium">Created</th>
<th scope="col" className="px-4 py-3 text-right font-medium">Actions</th>
</tr>
@ -68,6 +69,19 @@ export default async function AdminUsersPage({
? "None yet"
: user.tags.map((t) => t.name).join(", ")}
</td>
<td className="px-4 py-3 text-ink-muted">
{user.role === "admin"
? "Everything"
: [
user.canPublishPosts && "publish",
user.canUnpublishPosts && "unpublish",
user.canDeletePosts && "delete",
user.canCreateTags && "create tags",
user.canApproveComments && "approve comments",
]
.filter(Boolean)
.join(", ") || "Write drafts only"}
</td>
<td className="whitespace-nowrap px-4 py-3 text-ink-muted">
{formatDate(user.createdAt)}
</td>

View file

@ -0,0 +1,56 @@
"use client";
import { useActionState, useId } from "react";
import { Flash, FormErrorBanner } from "@/components/admin/Flash";
import { SubmitButton } from "@/components/admin/SubmitButton";
import { ErrorText, HelpText, Input, Label } from "@/components/ui";
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
type Props = {
action: (prev: FormState, formData: FormData) => Promise<FormState>;
};
export function ChangePasswordForm({ action }: Props) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
const err = (field: string) => firstFieldError(state, field);
return (
<form action={formAction} className="max-w-md space-y-5">
{state.status === "success" && <Flash>Password changed.</Flash>}
<FormErrorBanner>{state.formError}</FormErrorBanner>
<div>
<Label htmlFor={`${ids}-current`}>Current password</Label>
<Input
id={`${ids}-current`}
name="currentPassword"
type="password"
required
maxLength={200}
autoComplete="current-password"
aria-invalid={err("currentPassword") ? true : undefined}
/>
<ErrorText>{err("currentPassword")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-new`}>New password</Label>
<Input
id={`${ids}-new`}
name="newPassword"
type="password"
required
minLength={8}
maxLength={200}
autoComplete="new-password"
aria-invalid={err("newPassword") ? true : undefined}
/>
<HelpText>At least 8 characters.</HelpText>
<ErrorText>{err("newPassword")}</ErrorText>
</div>
<SubmitButton>Change password</SubmitButton>
</form>
);
}

View file

@ -17,9 +17,20 @@ type Props = {
post?: PostWithTags;
/** For authors this is just their granted tags, not every tag. */
allTags: Tag[];
/**
* Tags on the post the current user cannot toggle (added by the admin
* outside the author's grants). Shown checked and disabled; the server
* preserves them regardless of what the form submits.
*/
lockedTags?: Tag[];
defaultAuthor: string;
/** Admins can mint tags inline; authors cannot. */
/** Whether the user may mint new tags inline. */
canCreateTags: boolean;
/**
* Statuses this user may save the post as, given its current state
* (publish and unpublish are separate permissions).
*/
statusOptions: Array<"draft" | "published">;
action: (prev: FormState, formData: FormData) => Promise<FormState>;
};
@ -42,7 +53,15 @@ const SETTINGS_FIELDS = [
* all fields regardless of the visible tab, and the sticky action bar
* keeps status + save reachable from either.
*/
export function PostForm({ post, allTags, defaultAuthor, canCreateTags, action }: Props) {
export function PostForm({
post,
allTags,
lockedTags = [],
defaultAuthor,
canCreateTags,
statusOptions,
action,
}: Props) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
@ -114,10 +133,20 @@ export function PostForm({ post, allTags, defaultAuthor, canCreateTags, action }
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-32"
disabled={statusOptions.length < 2}
title={
statusOptions.length < 2
? "You do not have permission to change this post's status."
: undefined
}
>
<option value="draft">Draft</option>
{statusOptions.includes("draft") && <option value="draft">Draft</option>}
{statusOptions.includes("published") && (
<option value="published">Published</option>
)}
</Select>
{/* A disabled select submits nothing — carry the status anyway. */}
{statusOptions.length < 2 && <input type="hidden" name="status" value={status} />}
<SubmitButton>Save post</SubmitButton>
<Link href="/admin/posts" className="text-sm text-ink-muted hover:text-ink-strong">
Cancel
@ -271,6 +300,19 @@ export function PostForm({ post, allTags, defaultAuthor, canCreateTags, action }
<fieldset className="rounded-lg border border-edge p-4">
<legend className="px-1 text-sm font-medium text-ink-strong">Tags</legend>
{lockedTags.length > 0 && (
<ul className="mb-2 flex flex-wrap gap-x-5 gap-y-2">
{lockedTags.map((tag) => (
<li key={tag.id}>
<label className="inline-flex items-center gap-2 text-sm text-ink-muted">
<input type="checkbox" checked disabled className="size-4" />
{tag.name}
<span className="text-xs">(added by admin)</span>
</label>
</li>
))}
</ul>
)}
{allTags.length > 0 ? (
<ul className="flex flex-wrap gap-x-5 gap-y-2">
{allTags.map((tag) => (

View file

@ -15,6 +15,34 @@ type Props = {
action: (prev: FormState, formData: FormData) => Promise<FormState>;
};
const PERMISSION_OPTIONS = [
{
name: "canPublishPosts",
label: "Publish posts",
help: "Move their own posts from draft to published.",
},
{
name: "canUnpublishPosts",
label: "Unpublish posts",
help: "Move their own published posts back to draft.",
},
{
name: "canDeletePosts",
label: "Delete posts",
help: "Permanently delete their own posts.",
},
{
name: "canCreateTags",
label: "Create tags",
help: "Mint new tags from the post editor; created tags are granted to them.",
},
{
name: "canApproveComments",
label: "Approve comments",
help: "Moderate comments left on their own posts.",
},
] as const;
export function UserForm({ user, allTags, action }: Props) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
@ -83,6 +111,33 @@ export function UserForm({ user, allTags, action }: Props) {
<ErrorText>{err("password")}</ErrorText>
</div>
{!isAdminAccount && (
<fieldset className="rounded-lg border border-edge p-4">
<legend className="px-1 text-sm font-medium text-ink-strong">Permissions</legend>
<p className="mb-3 text-sm text-ink-muted">
Without these, the author can only write and edit their own drafts.
</p>
<ul className="space-y-2.5">
{PERMISSION_OPTIONS.map((perm) => (
<li key={perm.name}>
<label className="flex cursor-pointer items-start gap-2 text-sm text-ink">
<input
type="checkbox"
name={perm.name}
defaultChecked={user?.[perm.name] ?? false}
className="mt-0.5 size-4 accent-(--link)"
/>
<span>
{perm.label}
<span className="block text-xs text-ink-muted">{perm.help}</span>
</span>
</label>
</li>
))}
</ul>
</fieldset>
)}
{!isAdminAccount && (
<fieldset className="rounded-lg border border-edge p-4">
<legend className="px-1 text-sm font-medium text-ink-strong">Tag access</legend>

View file

@ -53,6 +53,13 @@ export const users = pgTable("users", {
username: text("username").notNull().unique(),
passwordHash: text("password_hash").notNull(),
role: userRoleEnum("role").notNull().default("author"),
// Granular author permissions; irrelevant for the admin, who can do
// everything regardless. Post-scoped ones apply to the author's own posts.
canCreateTags: boolean("can_create_tags").notNull().default(false),
canPublishPosts: boolean("can_publish_posts").notNull().default(false),
canUnpublishPosts: boolean("can_unpublish_posts").notNull().default(false),
canDeletePosts: boolean("can_delete_posts").notNull().default(false),
canApproveComments: boolean("can_approve_comments").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

View file

@ -8,7 +8,21 @@ import { sessions, type UserRole, users } from "@/db/schema";
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export type SessionUser = { id: number; username: string; role: UserRole };
/** Author permissions, already normalized: the admin has all of them. */
export type Permissions = {
createTags: boolean;
publishPosts: boolean;
unpublishPosts: boolean;
deletePosts: boolean;
approveComments: boolean;
};
export type SessionUser = {
id: number;
username: string;
role: UserRole;
permissions: Permissions;
};
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
@ -33,6 +47,11 @@ export async function validateSessionToken(token: string): Promise<SessionUser |
userId: users.id,
username: users.username,
role: users.role,
canCreateTags: users.canCreateTags,
canPublishPosts: users.canPublishPosts,
canUnpublishPosts: users.canUnpublishPosts,
canDeletePosts: users.canDeletePosts,
canApproveComments: users.canApproveComments,
expiresAt: sessions.expiresAt,
})
.from(sessions)
@ -44,7 +63,19 @@ export async function validateSessionToken(token: string): Promise<SessionUser |
await db.delete(sessions).where(eq(sessions.id, id));
return null;
}
return { id: row.userId, username: row.username, role: row.role };
const isAdmin = row.role === "admin";
return {
id: row.userId,
username: row.username,
role: row.role,
permissions: {
createTags: isAdmin || row.canCreateTags,
publishPosts: isAdmin || row.canPublishPosts,
unpublishPosts: isAdmin || row.canUnpublishPosts,
deletePosts: isAdmin || row.canDeletePosts,
approveComments: isAdmin || row.canApproveComments,
},
};
}
export async function deleteSession(token: string): Promise<void> {

57
src/lib/permissions.ts Normal file
View file

@ -0,0 +1,57 @@
import type { ContentStatus } from "@/db/schema";
import type { Permissions } from "@/lib/auth/session";
/**
* Publishing and unpublishing are separate permissions, checked on the
* TRANSITION: saving an already-published post as published needs neither.
* `from` is null when the post is being created.
*/
export function statusChangeError(
permissions: Permissions,
from: ContentStatus | null,
to: ContentStatus,
): string | null {
if (from !== "published" && to === "published" && !permissions.publishPosts) {
return "You do not have permission to publish posts. Save as a draft instead.";
}
if (from === "published" && to === "draft" && !permissions.unpublishPosts) {
return "You do not have permission to unpublish posts.";
}
return null;
}
/**
* Resolves the tags an author's save should leave on the post:
*
* - Submitted tags must all come from the author's grants (the form only
* offers granted tags; anything else is a forged request).
* - Tags already on the post that the author was never granted e.g.
* added by the admin are preserved untouched, since the author's
* form can't legitimately re-submit them.
* - The post must keep at least one granted tag, unless this save is
* also creating a new tag (which will be granted to the author).
*/
export function resolveAuthorTagIds(options: {
submitted: number[];
/** Tag ids currently on the post; empty when creating. */
existing: number[];
allowed: ReadonlySet<number>;
/** True when this save also creates new tags. */
creatingTags: boolean;
}): { tagIds: number[] } | { error: string } {
const { submitted, existing, allowed, creatingTags } = options;
if (submitted.some((id) => !allowed.has(id))) {
return { error: "You can only use tags you have been given access to." };
}
if (submitted.length === 0 && !creatingTags) {
return {
error:
allowed.size === 0
? "You have not been given access to any tags yet — ask the admin."
: "Choose at least one of your tags.",
};
}
const preserved = existing.filter((id) => !allowed.has(id));
return { tagIds: [...new Set([...submitted, ...preserved])] };
}

View file

@ -117,10 +117,16 @@ export type AdminComment = Comment & {
parentAuthorName: string | null;
};
/** Admin moderation list: every comment with its post, newest first. */
export async function listCommentsForAdmin(): Promise<AdminComment[]> {
/**
* Moderation list: every comment with its post, newest first.
* `postAuthorId` scopes it to comments on one account's posts (authors
* with the approve-comments permission moderate only their own posts).
*/
export async function listCommentsForAdmin(options?: {
postAuthorId?: number;
}): Promise<AdminComment[]> {
const parent = alias(comments, "parent");
const rows = await db
const base = db
.select({
comment: comments,
postTitle: posts.title,
@ -129,8 +135,11 @@ export async function listCommentsForAdmin(): Promise<AdminComment[]> {
})
.from(comments)
.innerJoin(posts, eq(posts.id, comments.postId))
.leftJoin(parent, eq(parent.id, comments.parentId))
.orderBy(desc(comments.createdAt), desc(comments.id));
.leftJoin(parent, eq(parent.id, comments.parentId));
const rows = await (options?.postAuthorId === undefined
? base
: base.where(eq(posts.authorId, options.postAuthorId))
).orderBy(desc(comments.createdAt), desc(comments.id));
return rows.map((r) => ({
...r.comment,
@ -157,15 +166,31 @@ export async function deleteComment(id: number): Promise<void> {
await db.delete(comments).where(eq(comments.id, id));
}
export async function countCommentsByStatus(): Promise<{
pending: number;
approved: number;
}> {
const rows = await db
export async function countCommentsByStatus(options?: {
postAuthorId?: number;
}): Promise<{ pending: number; approved: number }> {
const base = db
.select({ status: comments.status, value: count() })
.from(comments)
.groupBy(comments.status);
.innerJoin(posts, eq(posts.id, comments.postId));
const rows = await (options?.postAuthorId === undefined
? base
: base.where(eq(posts.authorId, options.postAuthorId))
).groupBy(comments.status);
const result = { pending: 0, approved: 0 };
for (const row of rows) result[row.status] = row.value;
return result;
}
/** The owning account of the post a comment sits on (for permission checks). */
export async function getCommentPostAuthorId(
commentId: number,
): Promise<{ postAuthorId: number | null } | null> {
const [row] = await db
.select({ postAuthorId: posts.authorId })
.from(comments)
.innerJoin(posts, eq(posts.id, comments.postId))
.where(eq(comments.id, commentId))
.limit(1);
return row ?? null;
}

View file

@ -1,4 +1,4 @@
import { and, asc, count, eq } from "drizzle-orm";
import { and, asc, count, eq, inArray } from "drizzle-orm";
import { db } from "@/db";
import { postTags, posts, type Tag, tags } from "@/db/schema";
@ -42,3 +42,8 @@ export async function getTagById(id: number): Promise<Tag | null> {
const [tag] = await db.select().from(tags).where(eq(tags.id, id)).limit(1);
return tag ?? null;
}
export async function getTagsBySlugs(slugs: string[]): Promise<Tag[]> {
if (slugs.length === 0) return [];
return db.select().from(tags).where(inArray(tags.slug, slugs));
}

View file

@ -1,7 +1,7 @@
import { asc, eq, inArray } from "drizzle-orm";
import { db } from "@/db";
import { type Tag, type User, tags, userTags, users } from "@/db/schema";
import { hashPassword } from "@/lib/auth/password";
import { hashPassword, verifyPassword } from "@/lib/auth/password";
/** User row without the password hash — safe to hand to pages. */
export type SafeUser = Omit<User, "passwordHash">;
@ -11,9 +11,24 @@ const safeColumns = {
id: users.id,
username: users.username,
role: users.role,
canCreateTags: users.canCreateTags,
canPublishPosts: users.canPublishPosts,
canUnpublishPosts: users.canUnpublishPosts,
canDeletePosts: users.canDeletePosts,
canApproveComments: users.canApproveComments,
createdAt: users.createdAt,
} as const;
/** The five grantable author permissions, as stored on the row. */
export type PermissionColumns = Pick<
User,
| "canCreateTags"
| "canPublishPosts"
| "canUnpublishPosts"
| "canDeletePosts"
| "canApproveComments"
>;
export async function listUsersWithTags(): Promise<UserWithTags[]> {
const [userRows, grantRows] = await Promise.all([
db.select(safeColumns).from(users).orderBy(asc(users.createdAt), asc(users.id)),
@ -74,23 +89,31 @@ export async function createUser(input: {
username: string;
password: string;
tagIds: number[];
/** Defaults to no permissions (write/edit own drafts only). */
permissions?: PermissionColumns;
}): Promise<SafeUser> {
const passwordHash = await hashPassword(input.password);
const [user] = await db
.insert(users)
.values({ username: input.username, passwordHash, role: "author" })
.values({
username: input.username,
passwordHash,
role: "author",
...(input.permissions ?? {}),
})
.returning(safeColumns);
await replaceTagGrants(user.id, input.tagIds);
return user;
}
/**
* Updates an account's tag grants and optionally its password.
* Roles are never changed here the single admin stays the admin.
* Updates an account's tag grants, permissions, and optionally its
* password. Roles are never changed the single admin stays the admin,
* and permission/grant edits are ignored for the admin account.
*/
export async function updateUser(
id: number,
input: { password: string | null; tagIds: number[] },
input: { password: string | null; tagIds: number[]; permissions?: PermissionColumns },
): Promise<SafeUser | null> {
const [existing] = await db.select(safeColumns).from(users).where(eq(users.id, id)).limit(1);
if (!existing) return null;
@ -99,13 +122,41 @@ export async function updateUser(
const passwordHash = await hashPassword(input.password);
await db.update(users).set({ passwordHash }).where(eq(users.id, id));
}
// Tag grants only mean something for authors.
// Grants and permissions only mean something for authors.
if (existing.role === "author") {
if (input.permissions) {
await db.update(users).set(input.permissions).where(eq(users.id, id));
}
await replaceTagGrants(id, input.tagIds);
}
return existing;
}
/** Verifies the current password, then replaces it. For any signed-in account. */
export async function changeOwnPassword(
userId: number,
currentPassword: string,
newPassword: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user) return { ok: false, error: "Your account no longer exists." };
if (!(await verifyPassword(user.passwordHash, currentPassword))) {
return { ok: false, error: "Your current password is incorrect." };
}
const passwordHash = await hashPassword(newPassword);
await db.update(users).set({ passwordHash }).where(eq(users.id, userId));
return { ok: true };
}
/** Adds tag grants without touching existing ones (used for author-created tags). */
export async function grantTags(userId: number, tagIds: number[]): Promise<void> {
if (tagIds.length === 0) return;
await db
.insert(userTags)
.values(tagIds.map((tagId) => ({ userId, tagId })))
.onConflictDoNothing();
}
/**
* Deletes an author account. The admin account cannot be deleted. The
* account's posts survive as unowned rows (posts.author_id -> NULL);

View file

@ -97,10 +97,22 @@ const newPasswordInput = z
.min(8, "Password must be at least 8 characters.")
.max(200, "Password is too long.");
/** Checkbox: present ("on") when ticked, absent otherwise. */
const checkboxInput = z.preprocess((v) => v === "on" || v === "true" || v === true, z.boolean());
const permissionsInput = z.object({
canCreateTags: checkboxInput,
canPublishPosts: checkboxInput,
canUnpublishPosts: checkboxInput,
canDeletePosts: checkboxInput,
canApproveComments: checkboxInput,
});
export const createUserFormSchema = z.object({
username: usernameInput,
password: newPasswordInput,
tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]),
permissions: permissionsInput,
});
export const updateUserFormSchema = z.object({
@ -110,6 +122,12 @@ export const updateUserFormSchema = z.object({
newPasswordInput.nullable(),
),
tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]),
permissions: permissionsInput,
});
export const changePasswordFormSchema = z.object({
currentPassword: z.string().min(1, "Enter your current password.").max(200),
newPassword: newPasswordInput,
});
export const navItemSchema = z

View file

@ -46,7 +46,18 @@ describe("sessions", () => {
expect(expiresAt.getTime()).toBeGreaterThan(Date.now());
const sessionUser = await validateSessionToken(token);
expect(sessionUser).toEqual({ id: user.id, username: "admin", role: "author" });
expect(sessionUser).toEqual({
id: user.id,
username: "admin",
role: "author",
permissions: {
createTags: false,
publishPosts: false,
unpublishPosts: false,
deletePosts: false,
approveComments: false,
},
});
// Only a hash of the token is stored.
const rows = await db.select().from(sessions);

View file

@ -5,10 +5,12 @@ import { verifyPassword } from "@/lib/auth/password";
import { buildSiteExport, importSiteExport } from "@/lib/services/import-export";
import { createPost, getPostById, listAllPosts, type PostInput } from "@/lib/services/posts";
import {
changeOwnPassword,
createUser,
deleteUser,
getAllowedTagIds,
getUserWithTags,
grantTags,
listUsersWithTags,
updateUser,
} from "@/lib/services/users";
@ -91,6 +93,70 @@ describe("account management", () => {
expect(await getAllowedTagIds(user.id)).toEqual([writing.id]);
});
it("stores permissions, defaults to none, and updates them", async () => {
const user = await createUser({
username: "casey",
password: "hunter2hunter2",
tagIds: [],
permissions: {
canCreateTags: false,
canPublishPosts: true,
canUnpublishPosts: false,
canDeletePosts: false,
canApproveComments: true,
},
});
expect(user.canPublishPosts).toBe(true);
expect(user.canApproveComments).toBe(true);
expect(user.canDeletePosts).toBe(false);
const bare = await createUser({ username: "dana", password: "hunter2hunter2", tagIds: [] });
expect(bare.canPublishPosts).toBe(false);
await updateUser(user.id, {
password: null,
tagIds: [],
permissions: {
canCreateTags: true,
canPublishPosts: false,
canUnpublishPosts: false,
canDeletePosts: true,
canApproveComments: false,
},
});
const reread = await getUserWithTags(user.id);
expect(reread?.canCreateTags).toBe(true);
expect(reread?.canPublishPosts).toBe(false);
expect(reread?.canDeletePosts).toBe(true);
});
it("changes own password only with the correct current password", async () => {
const user = await createUser({ username: "casey", password: "old-password-1", tagIds: [] });
const wrong = await changeOwnPassword(user.id, "not-the-password", "new-password-1");
expect(wrong.ok).toBe(false);
const right = await changeOwnPassword(user.id, "old-password-1", "new-password-1");
expect(right.ok).toBe(true);
const { users } = await import("@/db/schema");
const { eq } = await import("drizzle-orm");
const [row] = await db.select().from(users).where(eq(users.id, user.id));
expect(await verifyPassword(row.passwordHash, "new-password-1")).toBe(true);
expect(await verifyPassword(row.passwordHash, "old-password-1")).toBe(false);
});
it("grantTags adds without replacing and tolerates duplicates", async () => {
const [design, writing] = await seedTags("Design", "Writing");
const user = await createUser({
username: "casey",
password: "hunter2hunter2",
tagIds: [design.id],
});
await grantTags(user.id, [writing.id, design.id]);
expect((await getAllowedTagIds(user.id)).sort()).toEqual(
[design.id, writing.id].sort(),
);
});
it("refuses to delete the admin and keeps a deleted author's posts", async () => {
const admin = await seedAdmin();
expect((await deleteUser(admin.id)).ok).toBe(false);

View file

@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import type { Permissions } from "@/lib/auth/session";
import { resolveAuthorTagIds, statusChangeError } from "@/lib/permissions";
const perms = (overrides: Partial<Permissions> = {}): Permissions => ({
createTags: false,
publishPosts: false,
unpublishPosts: false,
deletePosts: false,
approveComments: false,
...overrides,
});
describe("statusChangeError", () => {
it("requires the publish permission to go draft -> published", () => {
expect(statusChangeError(perms(), null, "published")).toMatch(/publish/);
expect(statusChangeError(perms(), "draft", "published")).toMatch(/publish/);
expect(statusChangeError(perms({ publishPosts: true }), "draft", "published")).toBeNull();
});
it("requires the unpublish permission to go published -> draft", () => {
expect(statusChangeError(perms(), "published", "draft")).toMatch(/unpublish/);
expect(
statusChangeError(perms({ unpublishPosts: true }), "published", "draft"),
).toBeNull();
});
it("never blocks saves that keep the status", () => {
expect(statusChangeError(perms(), "draft", "draft")).toBeNull();
expect(statusChangeError(perms(), "published", "published")).toBeNull();
expect(statusChangeError(perms(), null, "draft")).toBeNull();
});
});
describe("resolveAuthorTagIds", () => {
const allowed = new Set([1, 2]);
it("rejects submissions outside the grants", () => {
const result = resolveAuthorTagIds({
submitted: [1, 3],
existing: [],
allowed,
creatingTags: false,
});
expect(result).toHaveProperty("error");
});
it("requires at least one granted tag unless creating one", () => {
expect(
resolveAuthorTagIds({ submitted: [], existing: [], allowed, creatingTags: false }),
).toHaveProperty("error");
expect(
resolveAuthorTagIds({ submitted: [], existing: [], allowed, creatingTags: true }),
).toEqual({ tagIds: [] });
});
it("preserves admin-added tags the author cannot see", () => {
// Post carries granted tag 1 and admin-added tag 9; the author's form
// resubmits only tag 2. Tag 9 must survive.
const result = resolveAuthorTagIds({
submitted: [2],
existing: [1, 9],
allowed,
creatingTags: false,
});
expect(result).toEqual({ tagIds: [2, 9] });
});
it("lets the author drop their own granted tags", () => {
const result = resolveAuthorTagIds({
submitted: [2],
existing: [1, 2],
allowed,
creatingTags: false,
});
expect(result).toEqual({ tagIds: [2] });
});
});