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:
commit
fcb3010f81
17
drizzle/0005_accounts.sql
Normal file
17
drizzle/0005_accounts.sql
Normal 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;
|
||||||
1007
drizzle/meta/0005_snapshot.json
Normal file
1007
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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) -------------------
|
||||||
|
|
|
||||||
|
|
@ -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
71
src/actions/users.ts
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
31
src/app/admin/(panel)/users/[id]/edit/page.tsx
Normal file
31
src/app/admin/(panel)/users/[id]/edit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/app/admin/(panel)/users/new/page.tsx
Normal file
19
src/app/admin/(panel)/users/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/app/admin/(panel)/users/page.tsx
Normal file
105
src/app/admin/(panel)/users/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
124
src/components/admin/UserForm.tsx
Normal file
124
src/components/admin/UserForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
122
src/lib/services/users.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
152
tests/integration/users.test.ts
Normal file
152
tests/integration/users.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue