Add author accounts with per-tag posting rights

The seeded account is the single admin; it can create author accounts
on the new /admin/users page (username + password) and grant each one
access to specific tags. Authors sign in to a Posts-only panel where
they can write, edit, publish, and unpublish their own posts — every
post must carry at least one granted tag, tags outside the grants are
rejected server-side, and only the admin can create tags or delete
posts (or anything else: pages, comments, settings, and backups stay
admin-only). Admin-only URLs bounce authors to their post list, and
foreign post editors 404.

posts.author_id records ownership; deleting an account keeps its posts
as unowned, admin-managed rows and signs the account out everywhere.
Backups (export v3) store the owner's username per post and re-attach
ownership on import when the account still exists.

Also fixes a latent form bug: a missing newTags field (author forms
don't render it) failed zod validation with an invisible error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
matt 2026-07-04 22:09:49 -04:00
parent c0cf38671a
commit 6d84ae1224
26 changed files with 1948 additions and 78 deletions

17
drizzle/0005_accounts.sql Normal file
View file

@ -0,0 +1,17 @@
CREATE TYPE "public"."user_role" AS ENUM('admin', 'author');--> statement-breakpoint
CREATE TABLE "user_tags" (
"user_id" integer NOT NULL,
"tag_id" integer NOT NULL,
CONSTRAINT "user_tags_user_id_tag_id_pk" PRIMARY KEY("user_id","tag_id")
);
--> statement-breakpoint
ALTER TABLE "posts" ADD COLUMN "author_id" integer;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "role" "user_role" DEFAULT 'author' NOT NULL;--> statement-breakpoint
ALTER TABLE "user_tags" ADD CONSTRAINT "user_tags_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_tags" ADD CONSTRAINT "user_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "user_tags_tag_id_idx" ON "user_tags" USING btree ("tag_id");--> statement-breakpoint
ALTER TABLE "posts" ADD CONSTRAINT "posts_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
-- Backfill: every account that existed before roles was the admin account,
-- and every existing post was written by it.
UPDATE "users" SET "role" = 'admin';--> statement-breakpoint
UPDATE "posts" SET "author_id" = (SELECT "id" FROM "users" WHERE "role" = 'admin' ORDER BY "id" LIMIT 1) WHERE "author_id" IS NULL;

File diff suppressed because it is too large Load diff

View file

@ -36,6 +36,13 @@
"when": 1783213112664,
"tag": "0004_comments",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1783214024801,
"tag": "0005_accounts",
"breakpoints": true
}
]
}

View file

@ -535,8 +535,11 @@ export async function seed(databaseUrl: string, log: (msg: string) => void = ()
const passwordHash = await hashPassword(password);
await db
.insert(users)
.values({ username, passwordHash })
.onConflictDoUpdate({ target: users.username, set: { passwordHash } });
.values({ username, passwordHash, role: "admin" })
.onConflictDoUpdate({
target: users.username,
set: { passwordHash, role: "admin" },
});
log(`admin user “${username}” ready`);
// --- Site settings (only created, never overwritten) -------------------

View file

@ -3,7 +3,8 @@
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 { SessionUser } from "@/lib/auth/session";
import type { FormState } from "@/lib/forms";
import { zodErrorToFormState } from "@/lib/forms";
import { sanitizeHtml } from "@/lib/html";
@ -11,10 +12,12 @@ import { isUniqueViolation, SlugConflictError } from "@/lib/services/errors";
import {
createPost,
deletePost,
getPostById,
type PostInput,
setPostStatus,
updatePost,
} from "@/lib/services/posts";
import { getAllowedTagIds } from "@/lib/services/users";
import { postFormSchema } from "@/lib/validation";
function readPostForm(formData: FormData) {
@ -27,7 +30,8 @@ function readPostForm(formData: FormData) {
featuredImageAlt: formData.get("featuredImageAlt"),
status: formData.get("status"),
tagIds: formData.getAll("tagIds"),
newTags: formData.get("newTags"),
// Author forms omit the new-tags input entirely — treat missing as "".
newTags: formData.get("newTags") ?? "",
});
}
@ -46,18 +50,60 @@ 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> {
await requireAdmin();
const user = await requireUser();
const parsed = readPostForm(formData);
if (!parsed.success) return zodErrorToFormState(parsed.error);
const input = toPostInput(parsed.data);
if (user.role !== "admin") {
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." };
}
}
const tagError = await checkAuthorTagRules(user, input);
if (tagError) return tagError;
}
let postId: number;
try {
if (id === null) {
const post = await createPost(toPostInput(parsed.data));
const post = await createPost(input, user.id);
postId = post.id;
} else {
const post = await updatePost(id, toPostInput(parsed.data));
const post = await updatePost(id, input);
if (!post) return { formError: "This post no longer exists." };
postId = post.id;
}
@ -86,14 +132,20 @@ export async function updatePostAction(id: number, _prev: FormState, formData: F
}
export async function setPostStatusAction(id: number, status: "draft" | "published") {
await requireAdmin();
const user = await requireUser();
const postId = z.number().int().positive().parse(id);
const nextStatus = z.enum(["draft", "published"]).parse(status);
if (user.role !== "admin") {
const post = await getPostById(postId);
// Authors may publish/unpublish their own posts only.
if (!post || post.authorId !== user.id) 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 postId = z.number().int().positive().parse(id);
await deletePost(postId);

71
src/actions/users.ts Normal file
View file

@ -0,0 +1,71 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireAdmin } 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";
export async function createUserAction(
_prev: FormState,
formData: FormData,
): Promise<FormState> {
await requireAdmin();
const parsed = createUserFormSchema.safeParse({
username: formData.get("username"),
password: formData.get("password"),
tagIds: formData.getAll("tagIds"),
});
if (!parsed.success) return zodErrorToFormState(parsed.error);
try {
await createUser(parsed.data);
} catch (error) {
if (isUniqueViolation(error)) {
return { fieldErrors: { username: ["That username is already taken."] } };
}
console.error("createUserAction failed", error);
return { formError: "Something went wrong while creating the account." };
}
redirect("/admin/users?created=1");
}
export async function updateUserAction(
id: number,
_prev: FormState,
formData: FormData,
): Promise<FormState> {
await requireAdmin();
const userId = z.number().int().positive().parse(id);
const parsed = updateUserFormSchema.safeParse({
password: formData.get("password"),
tagIds: formData.getAll("tagIds"),
});
if (!parsed.success) return zodErrorToFormState(parsed.error);
try {
const user = await updateUser(userId, parsed.data);
if (!user) return { formError: "That account no longer exists." };
} catch (error) {
console.error("updateUserAction failed", error);
return { formError: "Something went wrong while saving the account." };
}
// Tag grants gate what authors can post — refresh admin pages.
revalidatePath("/admin", "layout");
return { status: "success" };
}
export async function deleteUserAction(id: number): Promise<void> {
const admin = await requireAdmin();
const userId = z.number().int().positive().parse(id);
// requireAdmin + the service's admin-role guard both protect the admin
// account; this guards the sillier accident of deleting yourself.
if (userId === admin.id) return;
await deleteUser(userId);
revalidatePath("/admin", "layout");
redirect("/admin/users?deleted=1");
}

View file

@ -1,6 +1,6 @@
import Link from "next/link";
import { logoutAction } from "@/actions/auth";
import { requireAdmin } from "@/lib/auth/dal";
import { requireUser } from "@/lib/auth/dal";
import { countCommentsByStatus } from "@/lib/services/comments";
import { getSettings } from "@/lib/services/settings";
@ -13,10 +13,11 @@ const navLinkClasses =
* depth), and every mutating server action re-checks on its own.
*/
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const user = await requireAdmin();
const user = await requireUser();
const isAdmin = user.role === "admin";
const [settings, commentCounts] = await Promise.all([
getSettings(),
countCommentsByStatus(),
isAdmin ? countCommentsByStatus() : { pending: 0, approved: 0 },
]);
return (
@ -32,21 +33,27 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<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">
<li><Link href="/admin" className={navLinkClasses}>Dashboard</Link></li>
{isAdmin && <li><Link href="/admin" className={navLinkClasses}>Dashboard</Link></li>}
<li><Link href="/admin/posts" className={navLinkClasses}>Posts</Link></li>
<li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li>
<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>
<li><Link href="/admin/settings" className={navLinkClasses}>Settings</Link></li>
{isAdmin && (
<>
<li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li>
<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>
<li><Link href="/admin/users" className={navLinkClasses}>Users</Link></li>
<li><Link href="/admin/settings" className={navLinkClasses}>Settings</Link></li>
</>
)}
</ul>
</nav>
</div>

View file

@ -4,10 +4,11 @@ import { notFound } from "next/navigation";
import { updatePostAction } from "@/actions/posts";
import { Flash } from "@/components/admin/Flash";
import { PostForm } from "@/components/admin/PostForm";
import { requireAdmin } from "@/lib/auth/dal";
import { requireUser } from "@/lib/auth/dal";
import { parseIdParam } from "@/lib/params";
import { getPostById } from "@/lib/services/posts";
import { listAllTags } from "@/lib/services/tags";
import { getUserWithTags } from "@/lib/services/users";
export const metadata: Metadata = { title: "Edit post" };
@ -18,14 +19,20 @@ export default async function EditPostPage({
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const user = await requireAdmin();
const user = await requireUser();
const isAdmin = user.role === "admin";
const [{ id: rawId }, sp] = await Promise.all([params, searchParams]);
const id = parseIdParam(rawId);
if (id === null) notFound();
const [post, allTags] = await Promise.all([getPostById(id), listAllTags()]);
const [post, allTags] = await Promise.all([
getPostById(id),
isAdmin ? listAllTags() : getUserWithTags(user.id).then((u) => u?.tags ?? []),
]);
if (!post) notFound();
// Authors only reach their own posts; others 404 like unknown ids.
if (!isAdmin && post.authorId !== user.id) notFound();
return (
<div>
@ -55,6 +62,7 @@ export default async function EditPostPage({
post={post}
allTags={allTags}
defaultAuthor={user.username}
canCreateTags={isAdmin}
action={updatePostAction.bind(null, post.id)}
/>
</div>

View file

@ -3,7 +3,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { PostArticle } from "@/components/public/PostArticle";
import { requireAdmin } from "@/lib/auth/dal";
import { requireUser } from "@/lib/auth/dal";
import { parseIdParam } from "@/lib/params";
import { getPostById } from "@/lib/services/posts";
@ -15,13 +15,14 @@ export default async function PostPreviewPage({
}: {
params: Promise<{ id: string }>;
}) {
await requireAdmin();
const user = await requireUser();
const { id: rawId } = await params;
const id = parseIdParam(rawId);
if (id === null) notFound();
const post = await getPostById(id);
if (!post) notFound();
if (user.role !== "admin" && post.authorId !== user.id) notFound();
return (
<div>

View file

@ -1,19 +1,29 @@
import type { Metadata } from "next";
import { createPostAction } from "@/actions/posts";
import { PostForm } from "@/components/admin/PostForm";
import { requireAdmin } from "@/lib/auth/dal";
import { requireUser } from "@/lib/auth/dal";
import { listAllTags } from "@/lib/services/tags";
import { getUserWithTags } from "@/lib/services/users";
export const metadata: Metadata = { title: "New post" };
export default async function NewPostPage() {
const user = await requireAdmin();
const allTags = await listAllTags();
const user = await requireUser();
const isAdmin = user.role === "admin";
// Authors only ever see (and can only use) their granted tags.
const allTags = isAdmin
? await listAllTags()
: ((await getUserWithTags(user.id))?.tags ?? []);
return (
<div>
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">New post</h1>
<PostForm allTags={allTags} defaultAuthor={user.username} action={createPostAction} />
<PostForm
allTags={allTags}
defaultAuthor={user.username}
canCreateTags={isAdmin}
action={createPostAction}
/>
</div>
);
}

View file

@ -5,7 +5,7 @@ import { ConfirmButton } from "@/components/admin/ConfirmButton";
import { Flash } from "@/components/admin/Flash";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { LinkButton } from "@/components/ui";
import { requireAdmin } from "@/lib/auth/dal";
import { requireUser } from "@/lib/auth/dal";
import { formatDate } from "@/lib/format";
import { listAllPosts } from "@/lib/services/posts";
@ -19,8 +19,13 @@ export default async function AdminPostsPage({
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
await requireAdmin();
const [sp, posts] = await Promise.all([searchParams, listAllPosts()]);
const user = await requireUser();
const isAdmin = user.role === "admin";
const [sp, posts] = await Promise.all([
searchParams,
// Authors manage only their own posts; the admin manages everything.
listAllPosts(isAdmin ? undefined : { authorId: user.id }),
]);
return (
<div>
@ -87,14 +92,16 @@ export default async function AdminPostsPage({
{post.status === "published" ? "Unpublish" : "Publish"}
</button>
</form>
<form action={deletePostAction.bind(null, post.id)}>
<ConfirmButton
confirmMessage={`Delete “${post.title}”? This cannot be undone.`}
className="border-none px-2 py-1 text-xs"
>
Delete
</ConfirmButton>
</form>
{isAdmin && (
<form action={deletePostAction.bind(null, post.id)}>
<ConfirmButton
confirmMessage={`Delete “${post.title}”? This cannot be undone.`}
className="border-none px-2 py-1 text-xs"
>
Delete
</ConfirmButton>
</form>
)}
</div>
</td>
</tr>

View file

@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { updateUserAction } from "@/actions/users";
import { UserForm } from "@/components/admin/UserForm";
import { requireAdmin } from "@/lib/auth/dal";
import { parseIdParam } from "@/lib/params";
import { listAllTags } from "@/lib/services/tags";
import { getUserWithTags } from "@/lib/services/users";
export const metadata: Metadata = { title: "Edit account" };
export default async function EditUserPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireAdmin();
const { id: rawId } = await params;
const id = parseIdParam(rawId);
if (id === null) notFound();
const [user, allTags] = await Promise.all([getUserWithTags(id), listAllTags()]);
if (!user) notFound();
return (
<div>
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">Edit account</h1>
<UserForm user={user} allTags={allTags} action={updateUserAction.bind(null, user.id)} />
</div>
);
}

View file

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { createUserAction } from "@/actions/users";
import { UserForm } from "@/components/admin/UserForm";
import { requireAdmin } from "@/lib/auth/dal";
import { listAllTags } from "@/lib/services/tags";
export const metadata: Metadata = { title: "New account" };
export default async function NewUserPage() {
await requireAdmin();
const allTags = await listAllTags();
return (
<div>
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">New account</h1>
<UserForm allTags={allTags} action={createUserAction} />
</div>
);
}

View file

@ -0,0 +1,105 @@
import type { Metadata } from "next";
import Link from "next/link";
import { deleteUserAction } from "@/actions/users";
import { ConfirmButton } from "@/components/admin/ConfirmButton";
import { Flash } from "@/components/admin/Flash";
import { LinkButton } from "@/components/ui";
import { requireAdmin } from "@/lib/auth/dal";
import { formatDate } from "@/lib/format";
import { listUsersWithTags } from "@/lib/services/users";
export const metadata: Metadata = { title: "Users" };
export default async function AdminUsersPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
await requireAdmin();
const [sp, users] = await Promise.all([searchParams, listUsersWithTags()]);
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight text-ink-bright">Users</h1>
<LinkButton href="/admin/users/new">New account</LinkButton>
</div>
{sp.created === "1" && <Flash>Account created.</Flash>}
{sp.deleted === "1" && <Flash>Account deleted.</Flash>}
<div className="overflow-x-auto rounded-lg border border-edge bg-surface">
<table className="w-full min-w-[38rem] border-collapse text-sm">
<thead>
<tr className="border-b border-edge text-left text-xs uppercase tracking-wider text-ink-muted">
<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">Created</th>
<th scope="col" className="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-edge">
{users.map((user) => (
<tr key={user.id}>
<td className="px-4 py-3">
<Link
href={`/admin/users/${user.id}/edit`}
className="font-medium text-ink-strong transition-colors hover:text-link"
>
{user.username}
</Link>
</td>
<td className="px-4 py-3">
{user.role === "admin" ? (
<span className="inline-flex items-center rounded-full border border-link/40 bg-link/10 px-2 py-0.5 text-xs font-medium text-link">
Admin
</span>
) : (
<span className="inline-flex items-center rounded-full border border-edge-strong px-2 py-0.5 text-xs font-medium text-ink-muted">
Author
</span>
)}
</td>
<td className="px-4 py-3 text-ink-muted">
{user.role === "admin"
? "All tags"
: user.tags.length === 0
? "None yet"
: user.tags.map((t) => t.name).join(", ")}
</td>
<td className="whitespace-nowrap px-4 py-3 text-ink-muted">
{formatDate(user.createdAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Link
href={`/admin/users/${user.id}/edit`}
className="rounded-md px-2 py-1 text-xs font-medium text-ink-muted transition-colors hover:bg-background hover:text-ink-strong"
>
Edit
</Link>
{user.role !== "admin" && (
<form action={deleteUserAction.bind(null, user.id)}>
<ConfirmButton
confirmMessage={`Delete the account “${user.username}”? Their posts are kept and become admin-managed. This cannot be undone.`}
className="border-none px-2 py-1 text-xs"
>
Delete
</ConfirmButton>
</form>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-3 text-sm text-ink-muted">
Authors can write, edit, and publish their own posts under the tags you grant them.
Only you can delete posts, manage pages, moderate comments, or change settings.
</p>
</div>
);
}

View file

@ -15,8 +15,11 @@ import { uploadImageFile } from "@/lib/upload-client";
type Props = {
post?: PostWithTags;
/** For authors this is just their granted tags, not every tag. */
allTags: Tag[];
defaultAuthor: string;
/** Admins can mint tags inline; authors cannot. */
canCreateTags: boolean;
action: (prev: FormState, formData: FormData) => Promise<FormState>;
};
@ -39,7 +42,7 @@ 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, action }: Props) {
export function PostForm({ post, allTags, defaultAuthor, canCreateTags, action }: Props) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
@ -287,22 +290,32 @@ export function PostForm({ post, allTags, defaultAuthor, action }: Props) {
))}
</ul>
) : (
<p className="text-sm text-ink-muted">No tags exist yet create some below.</p>
<p className="text-sm text-ink-muted">
{canCreateTags
? "No tags exist yet — create some below."
: "You have not been given access to any tags yet — ask the admin."}
</p>
)}
<ErrorText>{err("tagIds")}</ErrorText>
{canCreateTags ? (
<div className="mt-4">
<Label htmlFor={`${ids}-new-tags`}>New tags (optional)</Label>
<Input
id={`${ids}-new-tags`}
name="newTags"
value={newTags}
onChange={(e) => setNewTags(e.target.value)}
placeholder="design, typescript"
aria-describedby={`${ids}-new-tags-help`}
/>
<HelpText id={`${ids}-new-tags-help`}>
Comma-separated. Created and attached to this post on save.
</HelpText>
<ErrorText>{err("newTags")}</ErrorText>
</div>
) : (
<HelpText>Posts must carry at least one of your tags.</HelpText>
)}
<div className="mt-4">
<Label htmlFor={`${ids}-new-tags`}>New tags (optional)</Label>
<Input
id={`${ids}-new-tags`}
name="newTags"
value={newTags}
onChange={(e) => setNewTags(e.target.value)}
placeholder="design, typescript"
aria-describedby={`${ids}-new-tags-help`}
/>
<HelpText id={`${ids}-new-tags-help`}>
Comma-separated. Created and attached to this post on save.
</HelpText>
</div>
</fieldset>
</div>
</form>

View file

@ -0,0 +1,124 @@
"use client";
import { useActionState, useId, useState } 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 { Tag } from "@/db/schema";
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
import type { UserWithTags } from "@/lib/services/users";
type Props = {
/** Absent when creating a new account. */
user?: UserWithTags;
allTags: Tag[];
action: (prev: FormState, formData: FormData) => Promise<FormState>;
};
export function UserForm({ user, allTags, action }: Props) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
const isNew = user === undefined;
const isAdminAccount = user?.role === "admin";
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(
() => new Set(user?.tags.map((t) => t.id) ?? []),
);
const err = (field: string) => firstFieldError(state, field);
function toggleTag(id: number) {
setSelectedTagIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<form action={formAction} className="max-w-xl space-y-6">
{state.status === "success" && <Flash>Account saved.</Flash>}
<FormErrorBanner>{state.formError}</FormErrorBanner>
{isNew ? (
<div>
<Label htmlFor={`${ids}-username`}>Username</Label>
<Input
id={`${ids}-username`}
name="username"
required
maxLength={120}
autoComplete="off"
aria-invalid={err("username") ? true : undefined}
/>
<HelpText>Letters, numbers, dots, dashes, and underscores.</HelpText>
<ErrorText>{err("username")}</ErrorText>
</div>
) : (
<p className="text-sm text-ink">
<span className="font-semibold text-ink-strong">{user.username}</span>{" "}
<span className="text-ink-muted">
· {isAdminAccount ? "administrator" : "author"}
</span>
</p>
)}
<div>
<Label htmlFor={`${ids}-password`}>
{isNew ? "Password" : "New password (optional)"}
</Label>
<Input
id={`${ids}-password`}
name="password"
type="password"
required={isNew}
maxLength={200}
autoComplete="new-password"
aria-invalid={err("password") ? true : undefined}
/>
<HelpText>
{isNew
? "At least 8 characters. Share it with the author out of band."
: "Leave blank to keep the current password."}
</HelpText>
<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">Tag access</legend>
<p className="mb-3 text-sm text-ink-muted">
The author can write posts only under the tags checked here.
</p>
{allTags.length > 0 ? (
<ul className="flex flex-wrap gap-x-5 gap-y-2">
{allTags.map((tag) => (
<li key={tag.id}>
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-ink">
<input
type="checkbox"
name="tagIds"
value={tag.id}
checked={selectedTagIds.has(tag.id)}
onChange={() => toggleTag(tag.id)}
className="size-4 accent-(--link)"
/>
{tag.name}
</label>
</li>
))}
</ul>
) : (
<p className="text-sm text-ink-muted">
No tags exist yet create some from a post first.
</p>
)}
<ErrorText>{err("tagIds")}</ErrorText>
</fieldset>
)}
<div className="flex items-center gap-3 border-t border-edge pt-6">
<SubmitButton>{isNew ? "Create account" : "Save account"}</SubmitButton>
</div>
</form>
);
}

View file

@ -14,6 +14,7 @@ import {
export const contentStatusEnum = pgEnum("content_status", ["draft", "published"]);
export const commentStatusEnum = pgEnum("comment_status", ["pending", "approved"]);
export const userRoleEnum = pgEnum("user_role", ["admin", "author"]);
export const homeModeEnum = pgEnum("home_mode", ["posts", "tag", "page"]);
export const themeEnum = pgEnum("theme", [
"solarized-dark",
@ -51,9 +52,27 @@ export const users = pgTable("users", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
username: text("username").notNull().unique(),
passwordHash: text("password_hash").notNull(),
role: userRoleEnum("role").notNull().default("author"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
/** Tags an author account may publish under; irrelevant for admins. */
export const userTags = pgTable(
"user_tags",
{
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
tagId: integer("tag_id")
.notNull()
.references(() => tags.id, { onDelete: "cascade" }),
},
(t) => [
primaryKey({ columns: [t.userId, t.tagId] }),
index("user_tags_tag_id_idx").on(t.tagId),
],
);
export const sessions = pgTable("sessions", {
// sha-256 hex digest of the bearer token; the raw token never touches the DB.
id: text("id").primaryKey(),
@ -71,7 +90,10 @@ export const posts = pgTable(
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
body: text("body").notNull().default(""),
// Display byline (free text); authorId is the owning account. Posts
// survive account deletion as unowned (admin-managed) rows.
authorName: text("author_name").notNull(),
authorId: integer("author_id").references(() => users.id, { onDelete: "set null" }),
featuredImageUrl: text("featured_image_url"),
featuredImageAlt: text("featured_image_alt"),
status: contentStatusEnum("status").notNull().default("draft"),
@ -180,6 +202,7 @@ export const navItems = pgTable(
);
export type User = typeof users.$inferSelect;
export type UserRole = User["role"];
export type Session = typeof sessions.$inferSelect;
export type Post = typeof posts.$inferSelect;
export type Tag = typeof tags.$inferSelect;

View file

@ -14,8 +14,16 @@ export const getSessionUser = cache(async (): Promise<SessionUser | null> => {
return validateSessionToken(token);
});
export async function requireAdmin(): Promise<SessionUser> {
/** Any signed-in account (admin or author). */
export async function requireUser(): Promise<SessionUser> {
const user = await getSessionUser();
if (!user) redirect("/admin/login");
return user;
}
/** Admin only; signed-in authors are sent to their posts list. */
export async function requireAdmin(): Promise<SessionUser> {
const user = await requireUser();
if (user.role !== "admin") redirect("/admin/posts");
return user;
}

View file

@ -1,14 +1,14 @@
import { createHash, randomBytes } from "node:crypto";
import { eq, lt } from "drizzle-orm";
import { db } from "@/db";
import { sessions, users } from "@/db/schema";
import { sessions, type UserRole, users } from "@/db/schema";
// Deliberately free of next/* imports so it can be exercised directly by
// integration tests; cookie handling lives in cookies.ts.
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export type SessionUser = { id: number; username: string };
export type SessionUser = { id: number; username: string; role: UserRole };
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
@ -29,7 +29,12 @@ export async function validateSessionToken(token: string): Promise<SessionUser |
if (!token) return null;
const id = hashToken(token);
const [row] = await db
.select({ userId: users.id, username: users.username, expiresAt: sessions.expiresAt })
.select({
userId: users.id,
username: users.username,
role: users.role,
expiresAt: sessions.expiresAt,
})
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(eq(sessions.id, id))
@ -39,7 +44,7 @@ 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 };
return { id: row.userId, username: row.username, role: row.role };
}
export async function deleteSession(token: string): Promise<void> {

View file

@ -1,4 +1,4 @@
import { asc, eq } from "drizzle-orm";
import { asc, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/db";
import {
@ -11,6 +11,7 @@ import {
settings,
tags,
themeEnum,
users,
} from "@/db/schema";
import { sanitizeHtml } from "@/lib/html";
import { isValidLinkUrl } from "@/lib/validation";
@ -22,9 +23,10 @@ import { getSettings } from "./settings";
* columns are regenerated on import).
*/
export const SITE_EXPORT_FORMAT = "yap-blog-export";
// v1: posts/pages/tags/nav/settings. v2 adds comments. v1 files still
// import fine (they simply carry no comments).
export const SITE_EXPORT_VERSION = 2;
// v1: posts/pages/tags/nav/settings. v2 adds comments. v3 adds
// posts.authorUsername so post ownership survives a restore. Older
// files still import (missing fields default to empty/unowned).
export const SITE_EXPORT_VERSION = 3;
const slugValue = z.string().trim().min(1).max(120);
const statusValue = z.enum(["draft", "published"]);
@ -34,7 +36,7 @@ const timestampValue = z.coerce.date();
export const siteExportSchema = z
.object({
format: z.literal(SITE_EXPORT_FORMAT),
version: z.union([z.literal(1), z.literal(2)]),
version: z.union([z.literal(1), z.literal(2), z.literal(3)]),
exportedAt: timestampValue,
settings: z.object({
siteTitle: z.string().trim().min(1).max(120),
@ -79,6 +81,12 @@ export const siteExportSchema = z
slug: slugValue,
body: bodyValue,
authorName: z.string().trim().min(1).max(120),
/**
* Owning account's username. Accounts are not part of the export;
* on import this is matched against existing usernames and posts
* without a match become unowned (admin-managed).
*/
authorUsername: z.string().trim().min(1).max(120).nullable().default(null),
featuredImageUrl: z.string().trim().max(2000).nullable(),
featuredImageAlt: z.string().trim().max(300).nullable(),
status: statusValue,
@ -264,7 +272,11 @@ export async function buildSiteExport(): Promise<SiteExport> {
.orderBy(asc(navItems.sortOrder), asc(navItems.id)),
db.select().from(tags).orderBy(asc(tags.slug)),
db.select().from(pages).orderBy(asc(pages.id)),
db.select().from(posts).orderBy(asc(posts.id)),
db
.select({ post: posts, authorUsername: users.username })
.from(posts)
.leftJoin(users, eq(users.id, posts.authorId))
.orderBy(asc(posts.id)),
db
.select({ postId: postTags.postId, tagSlug: tags.slug })
.from(postTags)
@ -296,7 +308,8 @@ export async function buildSiteExport(): Promise<SiteExport> {
excerptWords: settingsRow.excerptWords,
homeMode: settingsRow.homeMode,
homeTagSlug: tagRows.find((t) => t.id === settingsRow.homeTagId)?.slug ?? null,
homePageSlug: pageRows.find((p) => p.id === settingsRow.homePageId)?.slug ?? null,
homePageSlug:
pageRows.find((p) => p.id === settingsRow.homePageId)?.slug ?? null,
theme: settingsRow.theme,
font: settingsRow.font,
},
@ -314,11 +327,12 @@ export async function buildSiteExport(): Promise<SiteExport> {
createdAt: p.createdAt,
updatedAt: p.updatedAt,
})),
posts: postRows.map((p) => ({
posts: postRows.map(({ post: p, authorUsername }) => ({
title: p.title,
slug: p.slug,
body: p.body,
authorName: p.authorName,
authorUsername,
featuredImageUrl: p.featuredImageUrl,
featuredImageAlt: p.featuredImageAlt,
status: p.status,
@ -392,6 +406,22 @@ export async function importSiteExport(data: SiteExport): Promise<void> {
for (const row of inserted) pageIdBySlug.set(row.slug, row.id);
}
// Accounts survive an import untouched; posts re-attach to them by
// username. Unknown usernames leave the post unowned (admin-managed).
const exportUsernames = [
...new Set(
data.posts.flatMap((p) => (p.authorUsername !== null ? [p.authorUsername] : [])),
),
];
const userIdByUsername = new Map<string, number>();
if (exportUsernames.length > 0) {
const userRows = await tx
.select({ id: users.id, username: users.username })
.from(users)
.where(inArray(users.username, exportUsernames));
for (const row of userRows) userIdByUsername.set(row.username, row.id);
}
const postIdBySlug = new Map<string, number>();
const links: Array<{ postId: number; tagId: number }> = [];
for (const batch of chunk(data.posts, 1000)) {
@ -403,6 +433,10 @@ export async function importSiteExport(data: SiteExport): Promise<void> {
slug: p.slug,
body: sanitizeHtml(p.body),
authorName: p.authorName,
authorId:
p.authorUsername !== null
? (userIdByUsername.get(p.authorUsername) ?? null)
: null,
featuredImageUrl: p.featuredImageUrl,
featuredImageAlt: p.featuredImageAlt,
status: p.status,

View file

@ -111,7 +111,11 @@ async function syncPostTags(
}
}
export async function createPost(input: PostInput): Promise<Post> {
/** `authorId` is the owning account; ownership never changes on edit. */
export async function createPost(
input: PostInput,
authorId: number | null = null,
): Promise<Post> {
const slug = await resolvePostSlug(input);
return db.transaction(async (tx) => {
const [post] = await tx
@ -121,6 +125,7 @@ export async function createPost(input: PostInput): Promise<Post> {
slug,
body: input.body,
authorName: input.authorName,
authorId,
featuredImageUrl: input.featuredImageUrl,
featuredImageAlt: input.featuredImageAlt,
status: input.status,
@ -265,9 +270,15 @@ export async function getPostById(id: number): Promise<PostWithTags | null> {
return { ...post, tags: tagMap.get(post.id) ?? [] };
}
/** Admin listing: all posts, most recently updated first. */
export async function listAllPosts(): Promise<Post[]> {
return db.select().from(posts).orderBy(desc(posts.updatedAt), desc(posts.id));
/**
* Admin listing: all posts, most recently updated first.
* Pass `authorId` to restrict to one account's posts (author view).
*/
export async function listAllPosts(options?: { authorId?: number }): Promise<Post[]> {
const base = db.select().from(posts);
const query =
options?.authorId === undefined ? base : base.where(eq(posts.authorId, options.authorId));
return query.orderBy(desc(posts.updatedAt), desc(posts.id));
}
export async function countPostsByStatus(): Promise<{ published: number; draft: number }> {

122
src/lib/services/users.ts Normal file
View file

@ -0,0 +1,122 @@
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";
/** User row without the password hash — safe to hand to pages. */
export type SafeUser = Omit<User, "passwordHash">;
export type UserWithTags = SafeUser & { tags: Tag[] };
const safeColumns = {
id: users.id,
username: users.username,
role: users.role,
createdAt: users.createdAt,
} as const;
export async function listUsersWithTags(): Promise<UserWithTags[]> {
const [userRows, grantRows] = await Promise.all([
db.select(safeColumns).from(users).orderBy(asc(users.createdAt), asc(users.id)),
db
.select({ userId: userTags.userId, tag: tags })
.from(userTags)
.innerJoin(tags, eq(tags.id, userTags.tagId))
.orderBy(asc(tags.name)),
]);
const tagsByUser = new Map<number, Tag[]>();
for (const row of grantRows) {
const list = tagsByUser.get(row.userId) ?? [];
list.push(row.tag);
tagsByUser.set(row.userId, list);
}
return userRows.map((u) => ({ ...u, tags: tagsByUser.get(u.id) ?? [] }));
}
export async function getUserWithTags(id: number): Promise<UserWithTags | null> {
const [user] = await db.select(safeColumns).from(users).where(eq(users.id, id)).limit(1);
if (!user) return null;
const grants = await db
.select({ tag: tags })
.from(userTags)
.innerJoin(tags, eq(tags.id, userTags.tagId))
.where(eq(userTags.userId, id))
.orderBy(asc(tags.name));
return { ...user, tags: grants.map((g) => g.tag) };
}
/** Tag ids an author may publish under. (Admins bypass this check.) */
export async function getAllowedTagIds(userId: number): Promise<number[]> {
const rows = await db
.select({ tagId: userTags.tagId })
.from(userTags)
.where(eq(userTags.userId, userId));
return rows.map((r) => r.tagId);
}
async function replaceTagGrants(userId: number, tagIds: number[]): Promise<void> {
await db.transaction(async (tx) => {
await tx.delete(userTags).where(eq(userTags.userId, userId));
if (tagIds.length > 0) {
// Silently drop ids for tags deleted since the form rendered.
const existing = await tx
.select({ id: tags.id })
.from(tags)
.where(inArray(tags.id, tagIds));
if (existing.length > 0) {
await tx.insert(userTags).values(existing.map((t) => ({ userId, tagId: t.id })));
}
}
});
}
/** New accounts are always authors — there is exactly one admin. */
export async function createUser(input: {
username: string;
password: string;
tagIds: number[];
}): Promise<SafeUser> {
const passwordHash = await hashPassword(input.password);
const [user] = await db
.insert(users)
.values({ username: input.username, passwordHash, role: "author" })
.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.
*/
export async function updateUser(
id: number,
input: { password: string | null; tagIds: number[] },
): Promise<SafeUser | null> {
const [existing] = await db.select(safeColumns).from(users).where(eq(users.id, id)).limit(1);
if (!existing) return null;
if (input.password !== null) {
const passwordHash = await hashPassword(input.password);
await db.update(users).set({ passwordHash }).where(eq(users.id, id));
}
// Tag grants only mean something for authors.
if (existing.role === "author") {
await replaceTagGrants(id, input.tagIds);
}
return existing;
}
/**
* Deletes an author account. The admin account cannot be deleted. The
* account's posts survive as unowned rows (posts.author_id -> NULL);
* its sessions cascade away, signing the account out everywhere.
*/
export async function deleteUser(id: number): Promise<{ ok: boolean; error?: string }> {
const [user] = await db.select(safeColumns).from(users).where(eq(users.id, id)).limit(1);
if (!user) return { ok: false, error: "That account no longer exists." };
if (user.role === "admin") {
return { ok: false, error: "The admin account cannot be deleted." };
}
await db.delete(users).where(eq(users.id, id));
return { ok: true };
}

View file

@ -82,6 +82,36 @@ export const loginFormSchema = z.object({
password: z.string().min(1, "Password is required.").max(200),
});
const usernameInput = z
.string()
.trim()
.min(1, "Username is required.")
.max(120, "Username is too long.")
.regex(
/^[a-zA-Z0-9._-]+$/,
"Usernames may only contain letters, numbers, dots, dashes, and underscores.",
);
const newPasswordInput = z
.string()
.min(8, "Password must be at least 8 characters.")
.max(200, "Password is too long.");
export const createUserFormSchema = z.object({
username: usernameInput,
password: newPasswordInput,
tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]),
});
export const updateUserFormSchema = z.object({
// Blank means "keep the current password".
password: z.preprocess(
(v) => (v === "" || v === null ? null : v),
newPasswordInput.nullable(),
),
tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]),
});
export const navItemSchema = z
.object({
label: z.string().trim().min(1, "Every navigation item needs a label.").max(80),

View file

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

View file

@ -170,6 +170,7 @@ describe("export/import round trip", () => {
slug: "sneaky",
body: '<p>ok</p><script>alert("x")</script>',
authorName: "Mallory",
authorUsername: null,
featuredImageUrl: null,
featuredImageAlt: null,
status: "published",
@ -198,6 +199,7 @@ describe("parseSiteExportJson", () => {
slug: "orphan",
body: "",
authorName: "A",
authorUsername: null,
featuredImageUrl: null,
featuredImageAlt: null,
status: "draft",
@ -228,6 +230,7 @@ describe("parseSiteExportJson", () => {
slug: "p",
body: "",
authorName: "A",
authorUsername: null,
featuredImageUrl: null,
featuredImageAlt: null,
status: "published",

View file

@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it } from "vitest";
import { db } from "@/db";
import { tags } from "@/db/schema";
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 {
createUser,
deleteUser,
getAllowedTagIds,
getUserWithTags,
listUsersWithTags,
updateUser,
} from "@/lib/services/users";
import { resetDb } from "../helpers/db";
const postInput = (overrides: Partial<PostInput> = {}): PostInput => ({
title: `Post ${Math.random().toString(36).slice(2, 8)}`,
slug: "",
body: "body",
authorName: "Someone",
featuredImageUrl: null,
featuredImageAlt: null,
status: "published",
tagIds: [],
newTagNames: [],
...overrides,
});
async function seedTags(...names: string[]) {
return db
.insert(tags)
.values(names.map((name) => ({ name, slug: name.toLowerCase() })))
.returning();
}
async function seedAdmin() {
const [admin] = await db
.insert((await import("@/db/schema")).users)
.values({ username: "boss", passwordHash: "x", role: "admin" })
.returning();
return admin;
}
beforeEach(resetDb);
describe("account management", () => {
it("creates author accounts with tag grants", async () => {
const [design, writing] = await seedTags("Design", "Writing");
const user = await createUser({
username: "casey",
password: "hunter2hunter2",
tagIds: [design.id, writing.id],
});
expect(user.role).toBe("author");
expect((await getAllowedTagIds(user.id)).sort()).toEqual(
[design.id, writing.id].sort(),
);
const listed = await listUsersWithTags();
expect(listed.find((u) => u.username === "casey")?.tags.map((t) => t.name).sort()).toEqual(
["Design", "Writing"],
);
});
it("hashes passwords and updates them only when provided", async () => {
const user = await createUser({ username: "casey", password: "first-password", tagIds: [] });
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(row.passwordHash).not.toContain("first-password");
expect(await verifyPassword(row.passwordHash, "first-password")).toBe(true);
await updateUser(user.id, { password: null, tagIds: [] });
const [same] = await db.select().from(users).where(eq(users.id, user.id));
expect(same.passwordHash).toBe(row.passwordHash);
await updateUser(user.id, { password: "second-password", tagIds: [] });
const [changed] = await db.select().from(users).where(eq(users.id, user.id));
expect(await verifyPassword(changed.passwordHash, "second-password")).toBe(true);
});
it("replaces tag grants on update", async () => {
const [design, writing] = await seedTags("Design", "Writing");
const user = await createUser({
username: "casey",
password: "hunter2hunter2",
tagIds: [design.id],
});
await updateUser(user.id, { password: null, tagIds: [writing.id] });
expect(await getAllowedTagIds(user.id)).toEqual([writing.id]);
});
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);
const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] });
const post = await createPost(postInput(), author.id);
expect((await deleteUser(author.id)).ok).toBe(true);
// Post survives, now unowned.
const kept = await getPostById(post.id);
expect(kept).not.toBeNull();
expect(kept?.authorId).toBeNull();
expect(await getUserWithTags(author.id)).toBeNull();
});
});
describe("post ownership", () => {
it("stamps the creating account and filters listAllPosts by author", async () => {
const admin = await seedAdmin();
const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] });
await createPost(postInput({ title: "Admin post" }), admin.id);
await createPost(postInput({ title: "Casey post" }), author.id);
const all = await listAllPosts();
expect(all).toHaveLength(2);
const mine = await listAllPosts({ authorId: author.id });
expect(mine.map((p) => p.title)).toEqual(["Casey post"]);
});
it("round-trips ownership through export/import by username", async () => {
await seedAdmin();
const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] });
const post = await createPost(postInput({ title: "Owned" }), author.id);
const snapshot = await buildSiteExport();
expect(snapshot.posts.find((p) => p.slug === post.slug)?.authorUsername).toBe("casey");
await importSiteExport(snapshot);
const restored = (await listAllPosts({ authorId: author.id })).find(
(p) => p.title === "Owned",
);
expect(restored).toBeDefined();
expect(restored?.authorId).toBe(author.id);
});
it("leaves posts unowned when the export references an unknown username", async () => {
await seedAdmin();
const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] });
await createPost(postInput({ title: "Orphan-to-be" }), author.id);
const snapshot = await buildSiteExport();
await deleteUser(author.id);
await importSiteExport(snapshot);
const [post] = await listAllPosts();
expect(post.authorId).toBeNull();
});
});