Merge feature/multi-user: author accounts with per-tag posting rights

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
matt 2026-07-04 22:10:02 -04:00
commit fcb3010f81
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, "when": 1783213112664,
"tag": "0004_comments", "tag": "0004_comments",
"breakpoints": true "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); const passwordHash = await hashPassword(password);
await db await db
.insert(users) .insert(users)
.values({ username, passwordHash }) .values({ username, passwordHash, role: "admin" })
.onConflictDoUpdate({ target: users.username, set: { passwordHash } }); .onConflictDoUpdate({
target: users.username,
set: { passwordHash, role: "admin" },
});
log(`admin user “${username}” ready`); log(`admin user “${username}” ready`);
// --- Site settings (only created, never overwritten) ------------------- // --- Site settings (only created, never overwritten) -------------------

View file

@ -3,7 +3,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { z } from "zod"; 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 type { FormState } from "@/lib/forms";
import { zodErrorToFormState } from "@/lib/forms"; import { zodErrorToFormState } from "@/lib/forms";
import { sanitizeHtml } from "@/lib/html"; import { sanitizeHtml } from "@/lib/html";
@ -11,10 +12,12 @@ import { isUniqueViolation, SlugConflictError } from "@/lib/services/errors";
import { import {
createPost, createPost,
deletePost, deletePost,
getPostById,
type PostInput, type PostInput,
setPostStatus, setPostStatus,
updatePost, updatePost,
} from "@/lib/services/posts"; } from "@/lib/services/posts";
import { getAllowedTagIds } from "@/lib/services/users";
import { postFormSchema } from "@/lib/validation"; import { postFormSchema } from "@/lib/validation";
function readPostForm(formData: FormData) { function readPostForm(formData: FormData) {
@ -27,7 +30,8 @@ function readPostForm(formData: FormData) {
featuredImageAlt: formData.get("featuredImageAlt"), featuredImageAlt: formData.get("featuredImageAlt"),
status: formData.get("status"), status: formData.get("status"),
tagIds: formData.getAll("tagIds"), 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> { async function savePost(id: number | null, formData: FormData): Promise<FormState> {
await requireAdmin(); const user = await requireUser();
const parsed = readPostForm(formData); const parsed = readPostForm(formData);
if (!parsed.success) return zodErrorToFormState(parsed.error); 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; let postId: number;
try { try {
if (id === null) { if (id === null) {
const post = await createPost(toPostInput(parsed.data)); const post = await createPost(input, user.id);
postId = post.id; postId = post.id;
} else { } else {
const post = await updatePost(id, toPostInput(parsed.data)); const post = await updatePost(id, input);
if (!post) return { formError: "This post no longer exists." }; if (!post) return { formError: "This post no longer exists." };
postId = post.id; 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") { export async function setPostStatusAction(id: number, status: "draft" | "published") {
await requireAdmin(); const user = await requireUser();
const postId = z.number().int().positive().parse(id); const postId = z.number().int().positive().parse(id);
const nextStatus = z.enum(["draft", "published"]).parse(status); 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); await setPostStatus(postId, nextStatus);
revalidatePath("/", "layout"); revalidatePath("/", "layout");
} }
export async function deletePostAction(id: number) { export async function deletePostAction(id: number) {
// Deleting is admin-only, even for a post the author owns.
await requireAdmin(); await requireAdmin();
const postId = z.number().int().positive().parse(id); const postId = z.number().int().positive().parse(id);
await deletePost(postId); 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 Link from "next/link";
import { logoutAction } from "@/actions/auth"; import { logoutAction } from "@/actions/auth";
import { requireAdmin } from "@/lib/auth/dal"; import { requireUser } from "@/lib/auth/dal";
import { countCommentsByStatus } from "@/lib/services/comments"; import { countCommentsByStatus } from "@/lib/services/comments";
import { getSettings } from "@/lib/services/settings"; import { getSettings } from "@/lib/services/settings";
@ -13,10 +13,11 @@ const navLinkClasses =
* depth), and every mutating server action re-checks on its own. * depth), and every mutating server action re-checks on its own.
*/ */
export default async function AdminLayout({ children }: { children: React.ReactNode }) { 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([ const [settings, commentCounts] = await Promise.all([
getSettings(), getSettings(),
countCommentsByStatus(), isAdmin ? countCommentsByStatus() : { pending: 0, approved: 0 },
]); ]);
return ( return (
@ -32,9 +33,12 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<span className="font-normal text-ink-muted"> · Admin</span> <span className="font-normal text-ink-muted"> · Admin</span>
</Link> </Link>
<nav aria-label="Admin sections"> <nav aria-label="Admin sections">
{/* Authors only manage posts; everything else is the admin's. */}
<ul className="flex items-center gap-1"> <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/posts" className={navLinkClasses}>Posts</Link></li>
{isAdmin && (
<>
<li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li> <li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li>
<li> <li>
<Link href="/admin/comments" className={navLinkClasses}> <Link href="/admin/comments" className={navLinkClasses}>
@ -46,7 +50,10 @@ export default async function AdminLayout({ children }: { children: React.ReactN
)} )}
</Link> </Link>
</li> </li>
<li><Link href="/admin/users" className={navLinkClasses}>Users</Link></li>
<li><Link href="/admin/settings" className={navLinkClasses}>Settings</Link></li> <li><Link href="/admin/settings" className={navLinkClasses}>Settings</Link></li>
</>
)}
</ul> </ul>
</nav> </nav>
</div> </div>

View file

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

View file

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

View file

@ -1,19 +1,29 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { createPostAction } from "@/actions/posts"; import { createPostAction } from "@/actions/posts";
import { PostForm } from "@/components/admin/PostForm"; 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 { listAllTags } from "@/lib/services/tags";
import { getUserWithTags } from "@/lib/services/users";
export const metadata: Metadata = { title: "New post" }; export const metadata: Metadata = { title: "New post" };
export default async function NewPostPage() { export default async function NewPostPage() {
const user = await requireAdmin(); const user = await requireUser();
const allTags = await listAllTags(); 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 ( return (
<div> <div>
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">New post</h1> <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> </div>
); );
} }

View file

@ -5,7 +5,7 @@ import { ConfirmButton } from "@/components/admin/ConfirmButton";
import { Flash } from "@/components/admin/Flash"; import { Flash } from "@/components/admin/Flash";
import { StatusBadge } from "@/components/admin/StatusBadge"; import { StatusBadge } from "@/components/admin/StatusBadge";
import { LinkButton } from "@/components/ui"; import { LinkButton } from "@/components/ui";
import { requireAdmin } from "@/lib/auth/dal"; import { requireUser } from "@/lib/auth/dal";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { listAllPosts } from "@/lib/services/posts"; import { listAllPosts } from "@/lib/services/posts";
@ -19,8 +19,13 @@ export default async function AdminPostsPage({
}: { }: {
searchParams: Promise<Record<string, string | string[] | undefined>>; searchParams: Promise<Record<string, string | string[] | undefined>>;
}) { }) {
await requireAdmin(); const user = await requireUser();
const [sp, posts] = await Promise.all([searchParams, listAllPosts()]); 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 ( return (
<div> <div>
@ -87,6 +92,7 @@ export default async function AdminPostsPage({
{post.status === "published" ? "Unpublish" : "Publish"} {post.status === "published" ? "Unpublish" : "Publish"}
</button> </button>
</form> </form>
{isAdmin && (
<form action={deletePostAction.bind(null, post.id)}> <form action={deletePostAction.bind(null, post.id)}>
<ConfirmButton <ConfirmButton
confirmMessage={`Delete “${post.title}”? This cannot be undone.`} confirmMessage={`Delete “${post.title}”? This cannot be undone.`}
@ -95,6 +101,7 @@ export default async function AdminPostsPage({
Delete Delete
</ConfirmButton> </ConfirmButton>
</form> </form>
)}
</div> </div>
</td> </td>
</tr> </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 = { type Props = {
post?: PostWithTags; post?: PostWithTags;
/** For authors this is just their granted tags, not every tag. */
allTags: Tag[]; allTags: Tag[];
defaultAuthor: string; defaultAuthor: string;
/** Admins can mint tags inline; authors cannot. */
canCreateTags: boolean;
action: (prev: FormState, formData: FormData) => Promise<FormState>; 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 * all fields regardless of the visible tab, and the sticky action bar
* keeps status + save reachable from either. * 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 [state, formAction] = useActionState(action, initialFormState);
const ids = useId(); const ids = useId();
@ -287,8 +290,14 @@ export function PostForm({ post, allTags, defaultAuthor, action }: Props) {
))} ))}
</ul> </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"> <div className="mt-4">
<Label htmlFor={`${ids}-new-tags`}>New tags (optional)</Label> <Label htmlFor={`${ids}-new-tags`}>New tags (optional)</Label>
<Input <Input
@ -302,7 +311,11 @@ export function PostForm({ post, allTags, defaultAuthor, action }: Props) {
<HelpText id={`${ids}-new-tags-help`}> <HelpText id={`${ids}-new-tags-help`}>
Comma-separated. Created and attached to this post on save. Comma-separated. Created and attached to this post on save.
</HelpText> </HelpText>
<ErrorText>{err("newTags")}</ErrorText>
</div> </div>
) : (
<HelpText>Posts must carry at least one of your tags.</HelpText>
)}
</fieldset> </fieldset>
</div> </div>
</form> </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 contentStatusEnum = pgEnum("content_status", ["draft", "published"]);
export const commentStatusEnum = pgEnum("comment_status", ["pending", "approved"]); 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 homeModeEnum = pgEnum("home_mode", ["posts", "tag", "page"]);
export const themeEnum = pgEnum("theme", [ export const themeEnum = pgEnum("theme", [
"solarized-dark", "solarized-dark",
@ -51,9 +52,27 @@ export const users = pgTable("users", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(), id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
username: text("username").notNull().unique(), username: text("username").notNull().unique(),
passwordHash: text("password_hash").notNull(), passwordHash: text("password_hash").notNull(),
role: userRoleEnum("role").notNull().default("author"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), 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", { export const sessions = pgTable("sessions", {
// sha-256 hex digest of the bearer token; the raw token never touches the DB. // sha-256 hex digest of the bearer token; the raw token never touches the DB.
id: text("id").primaryKey(), id: text("id").primaryKey(),
@ -71,7 +90,10 @@ export const posts = pgTable(
title: text("title").notNull(), title: text("title").notNull(),
slug: text("slug").notNull().unique(), slug: text("slug").notNull().unique(),
body: text("body").notNull().default(""), 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(), authorName: text("author_name").notNull(),
authorId: integer("author_id").references(() => users.id, { onDelete: "set null" }),
featuredImageUrl: text("featured_image_url"), featuredImageUrl: text("featured_image_url"),
featuredImageAlt: text("featured_image_alt"), featuredImageAlt: text("featured_image_alt"),
status: contentStatusEnum("status").notNull().default("draft"), status: contentStatusEnum("status").notNull().default("draft"),
@ -180,6 +202,7 @@ export const navItems = pgTable(
); );
export type User = typeof users.$inferSelect; export type User = typeof users.$inferSelect;
export type UserRole = User["role"];
export type Session = typeof sessions.$inferSelect; export type Session = typeof sessions.$inferSelect;
export type Post = typeof posts.$inferSelect; export type Post = typeof posts.$inferSelect;
export type Tag = typeof tags.$inferSelect; export type Tag = typeof tags.$inferSelect;

View file

@ -14,8 +14,16 @@ export const getSessionUser = cache(async (): Promise<SessionUser | null> => {
return validateSessionToken(token); 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(); const user = await getSessionUser();
if (!user) redirect("/admin/login"); if (!user) redirect("/admin/login");
return user; 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 { createHash, randomBytes } from "node:crypto";
import { eq, lt } from "drizzle-orm"; import { eq, lt } from "drizzle-orm";
import { db } from "@/db"; 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 // Deliberately free of next/* imports so it can be exercised directly by
// integration tests; cookie handling lives in cookies.ts. // integration tests; cookie handling lives in cookies.ts.
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days 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 { function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex"); return createHash("sha256").update(token).digest("hex");
@ -29,7 +29,12 @@ export async function validateSessionToken(token: string): Promise<SessionUser |
if (!token) return null; if (!token) return null;
const id = hashToken(token); const id = hashToken(token);
const [row] = await db 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) .from(sessions)
.innerJoin(users, eq(users.id, sessions.userId)) .innerJoin(users, eq(users.id, sessions.userId))
.where(eq(sessions.id, id)) .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)); await db.delete(sessions).where(eq(sessions.id, id));
return null; 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> { 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 { z } from "zod";
import { db } from "@/db"; import { db } from "@/db";
import { import {
@ -11,6 +11,7 @@ import {
settings, settings,
tags, tags,
themeEnum, themeEnum,
users,
} from "@/db/schema"; } from "@/db/schema";
import { sanitizeHtml } from "@/lib/html"; import { sanitizeHtml } from "@/lib/html";
import { isValidLinkUrl } from "@/lib/validation"; import { isValidLinkUrl } from "@/lib/validation";
@ -22,9 +23,10 @@ import { getSettings } from "./settings";
* columns are regenerated on import). * columns are regenerated on import).
*/ */
export const SITE_EXPORT_FORMAT = "yap-blog-export"; export const SITE_EXPORT_FORMAT = "yap-blog-export";
// v1: posts/pages/tags/nav/settings. v2 adds comments. v1 files still // v1: posts/pages/tags/nav/settings. v2 adds comments. v3 adds
// import fine (they simply carry no comments). // posts.authorUsername so post ownership survives a restore. Older
export const SITE_EXPORT_VERSION = 2; // 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 slugValue = z.string().trim().min(1).max(120);
const statusValue = z.enum(["draft", "published"]); const statusValue = z.enum(["draft", "published"]);
@ -34,7 +36,7 @@ const timestampValue = z.coerce.date();
export const siteExportSchema = z export const siteExportSchema = z
.object({ .object({
format: z.literal(SITE_EXPORT_FORMAT), 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, exportedAt: timestampValue,
settings: z.object({ settings: z.object({
siteTitle: z.string().trim().min(1).max(120), siteTitle: z.string().trim().min(1).max(120),
@ -79,6 +81,12 @@ export const siteExportSchema = z
slug: slugValue, slug: slugValue,
body: bodyValue, body: bodyValue,
authorName: z.string().trim().min(1).max(120), 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(), featuredImageUrl: z.string().trim().max(2000).nullable(),
featuredImageAlt: z.string().trim().max(300).nullable(), featuredImageAlt: z.string().trim().max(300).nullable(),
status: statusValue, status: statusValue,
@ -264,7 +272,11 @@ export async function buildSiteExport(): Promise<SiteExport> {
.orderBy(asc(navItems.sortOrder), asc(navItems.id)), .orderBy(asc(navItems.sortOrder), asc(navItems.id)),
db.select().from(tags).orderBy(asc(tags.slug)), db.select().from(tags).orderBy(asc(tags.slug)),
db.select().from(pages).orderBy(asc(pages.id)), 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 db
.select({ postId: postTags.postId, tagSlug: tags.slug }) .select({ postId: postTags.postId, tagSlug: tags.slug })
.from(postTags) .from(postTags)
@ -296,7 +308,8 @@ export async function buildSiteExport(): Promise<SiteExport> {
excerptWords: settingsRow.excerptWords, excerptWords: settingsRow.excerptWords,
homeMode: settingsRow.homeMode, homeMode: settingsRow.homeMode,
homeTagSlug: tagRows.find((t) => t.id === settingsRow.homeTagId)?.slug ?? null, 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, theme: settingsRow.theme,
font: settingsRow.font, font: settingsRow.font,
}, },
@ -314,11 +327,12 @@ export async function buildSiteExport(): Promise<SiteExport> {
createdAt: p.createdAt, createdAt: p.createdAt,
updatedAt: p.updatedAt, updatedAt: p.updatedAt,
})), })),
posts: postRows.map((p) => ({ posts: postRows.map(({ post: p, authorUsername }) => ({
title: p.title, title: p.title,
slug: p.slug, slug: p.slug,
body: p.body, body: p.body,
authorName: p.authorName, authorName: p.authorName,
authorUsername,
featuredImageUrl: p.featuredImageUrl, featuredImageUrl: p.featuredImageUrl,
featuredImageAlt: p.featuredImageAlt, featuredImageAlt: p.featuredImageAlt,
status: p.status, 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); 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 postIdBySlug = new Map<string, number>();
const links: Array<{ postId: number; tagId: number }> = []; const links: Array<{ postId: number; tagId: number }> = [];
for (const batch of chunk(data.posts, 1000)) { for (const batch of chunk(data.posts, 1000)) {
@ -403,6 +433,10 @@ export async function importSiteExport(data: SiteExport): Promise<void> {
slug: p.slug, slug: p.slug,
body: sanitizeHtml(p.body), body: sanitizeHtml(p.body),
authorName: p.authorName, authorName: p.authorName,
authorId:
p.authorUsername !== null
? (userIdByUsername.get(p.authorUsername) ?? null)
: null,
featuredImageUrl: p.featuredImageUrl, featuredImageUrl: p.featuredImageUrl,
featuredImageAlt: p.featuredImageAlt, featuredImageAlt: p.featuredImageAlt,
status: p.status, 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); const slug = await resolvePostSlug(input);
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const [post] = await tx const [post] = await tx
@ -121,6 +125,7 @@ export async function createPost(input: PostInput): Promise<Post> {
slug, slug,
body: input.body, body: input.body,
authorName: input.authorName, authorName: input.authorName,
authorId,
featuredImageUrl: input.featuredImageUrl, featuredImageUrl: input.featuredImageUrl,
featuredImageAlt: input.featuredImageAlt, featuredImageAlt: input.featuredImageAlt,
status: input.status, status: input.status,
@ -265,9 +270,15 @@ export async function getPostById(id: number): Promise<PostWithTags | null> {
return { ...post, tags: tagMap.get(post.id) ?? [] }; return { ...post, tags: tagMap.get(post.id) ?? [] };
} }
/** Admin listing: all posts, most recently updated first. */ /**
export async function listAllPosts(): Promise<Post[]> { * Admin listing: all posts, most recently updated first.
return db.select().from(posts).orderBy(desc(posts.updatedAt), desc(posts.id)); * 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 }> { 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), 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 export const navItemSchema = z
.object({ .object({
label: z.string().trim().min(1, "Every navigation item needs a label.").max(80), 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()); expect(expiresAt.getTime()).toBeGreaterThan(Date.now());
const sessionUser = await validateSessionToken(token); 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. // Only a hash of the token is stored.
const rows = await db.select().from(sessions); const rows = await db.select().from(sessions);

View file

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