Merge feature/author-permissions: granular author permissions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
matt 2026-07-05 12:58:19 -04:00
commit f7906f99f7
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] });
});
});