Merge feature/author-permissions: granular author permissions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
f7906f99f7
8
drizzle/0006_author-permissions.sql
Normal file
8
drizzle/0006_author-permissions.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
ALTER TABLE "users" ADD COLUMN "can_create_tags" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "can_publish_posts" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "can_unpublish_posts" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "can_delete_posts" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "can_approve_comments" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
-- Existing authors could publish and unpublish before permissions existed;
|
||||||
|
-- keep that behavior for accounts created under the old rules.
|
||||||
|
UPDATE "users" SET "can_publish_posts" = true, "can_unpublish_posts" = true WHERE "role" = 'author';
|
||||||
1042
drizzle/meta/0006_snapshot.json
Normal file
1042
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -43,6 +43,13 @@
|
||||||
"when": 1783214024801,
|
"when": 1783214024801,
|
||||||
"tag": "0005_accounts",
|
"tag": "0005_accounts",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783266633118,
|
||||||
|
"tag": "0006_author-permissions",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireAdmin } from "@/lib/auth/dal";
|
import { requireAdmin, requireUser } from "@/lib/auth/dal";
|
||||||
import type { FormState } from "@/lib/forms";
|
import type { FormState } from "@/lib/forms";
|
||||||
import { zodErrorToFormState } from "@/lib/forms";
|
import { zodErrorToFormState } from "@/lib/forms";
|
||||||
import {
|
import {
|
||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
|
getCommentPostAuthorId,
|
||||||
setCommentStatus,
|
setCommentStatus,
|
||||||
} from "@/lib/services/comments";
|
} from "@/lib/services/comments";
|
||||||
import { commentFormSchema } from "@/lib/validation";
|
import { commentFormSchema } from "@/lib/validation";
|
||||||
|
|
@ -63,9 +64,16 @@ export async function setCommentStatusAction(
|
||||||
id: number,
|
id: number,
|
||||||
status: "pending" | "approved",
|
status: "pending" | "approved",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await requireAdmin();
|
const user = await requireUser();
|
||||||
const commentId = z.number().int().positive().parse(id);
|
const commentId = z.number().int().positive().parse(id);
|
||||||
const nextStatus = z.enum(["pending", "approved"]).parse(status);
|
const nextStatus = z.enum(["pending", "approved"]).parse(status);
|
||||||
|
if (user.role !== "admin") {
|
||||||
|
// Authors with the approve-comments permission moderate the
|
||||||
|
// comments sitting on their own posts, nothing else.
|
||||||
|
if (!user.permissions.approveComments) return;
|
||||||
|
const row = await getCommentPostAuthorId(commentId);
|
||||||
|
if (!row || row.postAuthorId !== user.id) return;
|
||||||
|
}
|
||||||
await setCommentStatus(commentId, nextStatus);
|
await setCommentStatus(commentId, nextStatus);
|
||||||
revalidatePath("/", "layout");
|
revalidatePath("/", "layout");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
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, requireUser } from "@/lib/auth/dal";
|
import { 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";
|
||||||
|
import { resolveAuthorTagIds, statusChangeError } from "@/lib/permissions";
|
||||||
import { isUniqueViolation, SlugConflictError } from "@/lib/services/errors";
|
import { isUniqueViolation, SlugConflictError } from "@/lib/services/errors";
|
||||||
import {
|
import {
|
||||||
createPost,
|
createPost,
|
||||||
|
|
@ -17,7 +17,9 @@ import {
|
||||||
setPostStatus,
|
setPostStatus,
|
||||||
updatePost,
|
updatePost,
|
||||||
} from "@/lib/services/posts";
|
} from "@/lib/services/posts";
|
||||||
import { getAllowedTagIds } from "@/lib/services/users";
|
import { getTagsBySlugs } from "@/lib/services/tags";
|
||||||
|
import { getAllowedTagIds, grantTags } from "@/lib/services/users";
|
||||||
|
import { slugify } from "@/lib/slug";
|
||||||
import { postFormSchema } from "@/lib/validation";
|
import { postFormSchema } from "@/lib/validation";
|
||||||
|
|
||||||
function readPostForm(formData: FormData) {
|
function readPostForm(formData: FormData) {
|
||||||
|
|
@ -50,51 +52,59 @@ function toPostInput(data: z.infer<typeof postFormSchema>): PostInput {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Author-role restrictions on a post's tags: no creating tags, and every
|
|
||||||
* tag must come from the account's grants — with at least one, so the
|
|
||||||
* post stays inside the author's sandbox. Admins skip this entirely.
|
|
||||||
*/
|
|
||||||
async function checkAuthorTagRules(
|
|
||||||
user: SessionUser,
|
|
||||||
input: PostInput,
|
|
||||||
): Promise<FormState | null> {
|
|
||||||
if (input.newTagNames.length > 0) {
|
|
||||||
return { fieldErrors: { newTags: ["Only the admin can create new tags."] } };
|
|
||||||
}
|
|
||||||
const allowed = new Set(await getAllowedTagIds(user.id));
|
|
||||||
if (input.tagIds.length === 0) {
|
|
||||||
return {
|
|
||||||
fieldErrors: {
|
|
||||||
tagIds: [
|
|
||||||
allowed.size === 0
|
|
||||||
? "You have not been given access to any tags yet — ask the admin."
|
|
||||||
: "Choose at least one of your tags.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (input.tagIds.some((id) => !allowed.has(id))) {
|
|
||||||
return { fieldErrors: { tagIds: ["You can only use tags you have been given access to."] } };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function savePost(id: number | null, formData: FormData): Promise<FormState> {
|
async function savePost(id: number | null, formData: FormData): Promise<FormState> {
|
||||||
const user = await requireUser();
|
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);
|
const input = toPostInput(parsed.data);
|
||||||
|
|
||||||
if (user.role !== "admin") {
|
if (user.role !== "admin") {
|
||||||
|
let existingTagIds: number[] = [];
|
||||||
|
let fromStatus: "draft" | "published" | null = null;
|
||||||
if (id !== null) {
|
if (id !== null) {
|
||||||
const existing = await getPostById(id);
|
const existing = await getPostById(id);
|
||||||
if (!existing) return { formError: "This post no longer exists." };
|
if (!existing) return { formError: "This post no longer exists." };
|
||||||
if (existing.authorId !== user.id) {
|
if (existing.authorId !== user.id) {
|
||||||
return { formError: "You can only edit your own posts." };
|
return { formError: "You can only edit your own posts." };
|
||||||
}
|
}
|
||||||
|
existingTagIds = existing.tags.map((t) => t.id);
|
||||||
|
fromStatus = existing.status;
|
||||||
}
|
}
|
||||||
const tagError = await checkAuthorTagRules(user, input);
|
|
||||||
if (tagError) return tagError;
|
const statusErr = statusChangeError(user.permissions, fromStatus, input.status);
|
||||||
|
if (statusErr) return { formError: statusErr };
|
||||||
|
|
||||||
|
const allowed = new Set(await getAllowedTagIds(user.id));
|
||||||
|
if (input.newTagNames.length > 0) {
|
||||||
|
if (!user.permissions.createTags) {
|
||||||
|
return {
|
||||||
|
fieldErrors: { newTags: ["You do not have permission to create new tags."] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// "Creating" a tag that already exists would silently self-grant
|
||||||
|
// access to it — refuse unless the author already has that grant.
|
||||||
|
const slugs = input.newTagNames.map(slugify).filter(Boolean);
|
||||||
|
const existingTags = await getTagsBySlugs(slugs);
|
||||||
|
const offLimits = existingTags.find((t) => !allowed.has(t.id));
|
||||||
|
if (offLimits) {
|
||||||
|
return {
|
||||||
|
fieldErrors: {
|
||||||
|
newTags: [
|
||||||
|
`The tag “${offLimits.name}” already exists — ask the admin for access to it.`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveAuthorTagIds({
|
||||||
|
submitted: input.tagIds,
|
||||||
|
existing: existingTagIds,
|
||||||
|
allowed,
|
||||||
|
creatingTags: input.newTagNames.length > 0,
|
||||||
|
});
|
||||||
|
if ("error" in resolved) return { fieldErrors: { tagIds: [resolved.error] } };
|
||||||
|
input.tagIds = resolved.tagIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
let postId: number;
|
let postId: number;
|
||||||
|
|
@ -118,6 +128,15 @@ async function savePost(id: number | null, formData: FormData): Promise<FormStat
|
||||||
return { formError: "Something went wrong while saving. Please try again." };
|
return { formError: "Something went wrong while saving. Please try again." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tags the author just created become part of their grants, so their
|
||||||
|
// next edit doesn't reject their own post.
|
||||||
|
if (user.role !== "admin" && input.newTagNames.length > 0) {
|
||||||
|
const saved = await getPostById(postId);
|
||||||
|
const known = new Set(input.tagIds);
|
||||||
|
const createdIds = (saved?.tags ?? []).filter((t) => !known.has(t.id)).map((t) => t.id);
|
||||||
|
await grantTags(user.id, createdIds);
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath("/", "layout");
|
revalidatePath("/", "layout");
|
||||||
redirect(`/admin/posts/${postId}/edit?saved=1`);
|
redirect(`/admin/posts/${postId}/edit?saved=1`);
|
||||||
}
|
}
|
||||||
|
|
@ -136,18 +155,25 @@ export async function setPostStatusAction(id: number, status: "draft" | "publish
|
||||||
const postId = z.number().int().positive().parse(id);
|
const 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") {
|
if (user.role !== "admin") {
|
||||||
|
// Authors may change status on their own posts, within their
|
||||||
|
// publish/unpublish permissions.
|
||||||
const post = await getPostById(postId);
|
const post = await getPostById(postId);
|
||||||
// Authors may publish/unpublish their own posts only.
|
|
||||||
if (!post || post.authorId !== user.id) return;
|
if (!post || post.authorId !== user.id) return;
|
||||||
|
if (statusChangeError(user.permissions, post.status, nextStatus) !== null) 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.
|
const user = await requireUser();
|
||||||
await requireAdmin();
|
|
||||||
const postId = z.number().int().positive().parse(id);
|
const postId = z.number().int().positive().parse(id);
|
||||||
|
if (user.role !== "admin") {
|
||||||
|
// Authors with the delete permission may delete their own posts.
|
||||||
|
if (!user.permissions.deletePosts) return;
|
||||||
|
const post = await getPostById(postId);
|
||||||
|
if (!post || post.authorId !== user.id) return;
|
||||||
|
}
|
||||||
await deletePost(postId);
|
await deletePost(postId);
|
||||||
revalidatePath("/", "layout");
|
revalidatePath("/", "layout");
|
||||||
redirect("/admin/posts?deleted=1");
|
redirect("/admin/posts?deleted=1");
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,31 @@
|
||||||
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 { FormState } from "@/lib/forms";
|
import type { FormState } from "@/lib/forms";
|
||||||
import { zodErrorToFormState } from "@/lib/forms";
|
import { zodErrorToFormState } from "@/lib/forms";
|
||||||
import { isUniqueViolation } from "@/lib/services/errors";
|
import { isUniqueViolation } from "@/lib/services/errors";
|
||||||
import { createUser, deleteUser, updateUser } from "@/lib/services/users";
|
import {
|
||||||
import { createUserFormSchema, updateUserFormSchema } from "@/lib/validation";
|
changeOwnPassword,
|
||||||
|
createUser,
|
||||||
|
deleteUser,
|
||||||
|
updateUser,
|
||||||
|
} from "@/lib/services/users";
|
||||||
|
import {
|
||||||
|
changePasswordFormSchema,
|
||||||
|
createUserFormSchema,
|
||||||
|
updateUserFormSchema,
|
||||||
|
} from "@/lib/validation";
|
||||||
|
|
||||||
|
function readPermissions(formData: FormData) {
|
||||||
|
return {
|
||||||
|
canCreateTags: formData.get("canCreateTags"),
|
||||||
|
canPublishPosts: formData.get("canPublishPosts"),
|
||||||
|
canUnpublishPosts: formData.get("canUnpublishPosts"),
|
||||||
|
canDeletePosts: formData.get("canDeletePosts"),
|
||||||
|
canApproveComments: formData.get("canApproveComments"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function createUserAction(
|
export async function createUserAction(
|
||||||
_prev: FormState,
|
_prev: FormState,
|
||||||
|
|
@ -19,6 +38,7 @@ export async function createUserAction(
|
||||||
username: formData.get("username"),
|
username: formData.get("username"),
|
||||||
password: formData.get("password"),
|
password: formData.get("password"),
|
||||||
tagIds: formData.getAll("tagIds"),
|
tagIds: formData.getAll("tagIds"),
|
||||||
|
permissions: readPermissions(formData),
|
||||||
});
|
});
|
||||||
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
||||||
|
|
||||||
|
|
@ -44,6 +64,7 @@ export async function updateUserAction(
|
||||||
const parsed = updateUserFormSchema.safeParse({
|
const parsed = updateUserFormSchema.safeParse({
|
||||||
password: formData.get("password"),
|
password: formData.get("password"),
|
||||||
tagIds: formData.getAll("tagIds"),
|
tagIds: formData.getAll("tagIds"),
|
||||||
|
permissions: readPermissions(formData),
|
||||||
});
|
});
|
||||||
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
||||||
|
|
||||||
|
|
@ -59,6 +80,32 @@ export async function updateUserAction(
|
||||||
return { status: "success" };
|
return { status: "success" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Any signed-in account may change its own password. */
|
||||||
|
export async function changeOwnPasswordAction(
|
||||||
|
_prev: FormState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<FormState> {
|
||||||
|
const user = await requireUser();
|
||||||
|
const parsed = changePasswordFormSchema.safeParse({
|
||||||
|
currentPassword: formData.get("currentPassword"),
|
||||||
|
newPassword: formData.get("newPassword"),
|
||||||
|
});
|
||||||
|
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await changeOwnPassword(
|
||||||
|
user.id,
|
||||||
|
parsed.data.currentPassword,
|
||||||
|
parsed.data.newPassword,
|
||||||
|
);
|
||||||
|
if (!result.ok) return { fieldErrors: { currentPassword: [result.error] } };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("changeOwnPasswordAction failed", error);
|
||||||
|
return { formError: "Something went wrong while changing your password." };
|
||||||
|
}
|
||||||
|
return { status: "success" };
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteUserAction(id: number): Promise<void> {
|
export async function deleteUserAction(id: number): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const userId = z.number().int().positive().parse(id);
|
const userId = z.number().int().positive().parse(id);
|
||||||
|
|
|
||||||
29
src/app/admin/(panel)/account/page.tsx
Normal file
29
src/app/admin/(panel)/account/page.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { changeOwnPasswordAction } from "@/actions/users";
|
||||||
|
import { ChangePasswordForm } from "@/components/admin/ChangePasswordForm";
|
||||||
|
import { requireUser } from "@/lib/auth/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Your account" };
|
||||||
|
|
||||||
|
/** Self-service account page — available to every signed-in account. */
|
||||||
|
export default async function AccountPage() {
|
||||||
|
const user = await requireUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-2 text-2xl font-bold tracking-tight text-ink-bright">Your account</h1>
|
||||||
|
<p className="mb-6 text-sm text-ink-muted">
|
||||||
|
Signed in as <span className="font-medium text-ink-strong">{user.username}</span>
|
||||||
|
{" · "}
|
||||||
|
{user.role === "admin" ? "administrator" : "author"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section aria-labelledby="password-heading">
|
||||||
|
<h2 id="password-heading" className="mb-4 text-lg font-semibold text-ink-strong">
|
||||||
|
Change password
|
||||||
|
</h2>
|
||||||
|
<ChangePasswordForm action={changeOwnPasswordAction} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { deleteCommentAction, setCommentStatusAction } from "@/actions/comments";
|
import { deleteCommentAction, setCommentStatusAction } from "@/actions/comments";
|
||||||
import { ConfirmButton } from "@/components/admin/ConfirmButton";
|
import { ConfirmButton } from "@/components/admin/ConfirmButton";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } 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 { type AdminComment, listCommentsForAdmin } from "@/lib/services/comments";
|
import { type AdminComment, listCommentsForAdmin } from "@/lib/services/comments";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Comments" };
|
export const metadata: Metadata = { title: "Comments" };
|
||||||
|
|
||||||
function CommentCard({ comment }: { comment: AdminComment }) {
|
function CommentCard({ comment, canDelete }: { comment: AdminComment; canDelete: boolean }) {
|
||||||
return (
|
return (
|
||||||
<li className="rounded-lg border border-edge bg-surface p-4">
|
<li className="rounded-lg border border-edge bg-surface p-4">
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
|
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||||
|
|
@ -53,6 +54,7 @@ function CommentCard({ comment }: { comment: AdminComment }) {
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
{canDelete && (
|
||||||
<form action={deleteCommentAction.bind(null, comment.id)}>
|
<form action={deleteCommentAction.bind(null, comment.id)}>
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
confirmMessage={`Delete this comment by ${comment.authorName}? Replies to it are deleted too. This cannot be undone.`}
|
confirmMessage={`Delete this comment by ${comment.authorName}? Replies to it are deleted too. This cannot be undone.`}
|
||||||
|
|
@ -61,14 +63,18 @@ function CommentCard({ comment }: { comment: AdminComment }) {
|
||||||
Delete
|
Delete
|
||||||
</ConfirmButton>
|
</ConfirmButton>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminCommentsPage() {
|
export default async function AdminCommentsPage() {
|
||||||
await requireAdmin();
|
const user = await requireUser();
|
||||||
const all = await listCommentsForAdmin();
|
const isAdmin = user.role === "admin";
|
||||||
|
// Authors with the approve-comments permission moderate their own posts.
|
||||||
|
if (!user.permissions.approveComments) redirect("/admin/posts");
|
||||||
|
const all = await listCommentsForAdmin(isAdmin ? undefined : { postAuthorId: user.id });
|
||||||
const pending = all.filter((c) => c.status === "pending");
|
const pending = all.filter((c) => c.status === "pending");
|
||||||
const approved = all.filter((c) => c.status === "approved");
|
const approved = all.filter((c) => c.status === "approved");
|
||||||
|
|
||||||
|
|
@ -87,7 +93,7 @@ export default async function AdminCommentsPage() {
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-3 space-y-3">
|
<ul className="mt-3 space-y-3">
|
||||||
{pending.map((comment) => (
|
{pending.map((comment) => (
|
||||||
<CommentCard key={comment.id} comment={comment} />
|
<CommentCard key={comment.id} comment={comment} canDelete={isAdmin} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
@ -102,7 +108,7 @@ export default async function AdminCommentsPage() {
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-3 space-y-3">
|
<ul className="mt-3 space-y-3">
|
||||||
{approved.map((comment) => (
|
{approved.map((comment) => (
|
||||||
<CommentCard key={comment.id} comment={comment} />
|
<CommentCard key={comment.id} comment={comment} canDelete={isAdmin} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,12 @@ const navLinkClasses =
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const isAdmin = user.role === "admin";
|
const isAdmin = user.role === "admin";
|
||||||
|
const canModerate = user.permissions.approveComments;
|
||||||
const [settings, commentCounts] = await Promise.all([
|
const [settings, commentCounts] = await Promise.all([
|
||||||
getSettings(),
|
getSettings(),
|
||||||
isAdmin ? countCommentsByStatus() : { pending: 0, approved: 0 },
|
canModerate
|
||||||
|
? countCommentsByStatus(isAdmin ? undefined : { postAuthorId: user.id })
|
||||||
|
: { pending: 0, approved: 0 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -38,8 +41,9 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||||
{isAdmin && <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 && (
|
{isAdmin && (
|
||||||
<>
|
|
||||||
<li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li>
|
<li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li>
|
||||||
|
)}
|
||||||
|
{canModerate && (
|
||||||
<li>
|
<li>
|
||||||
<Link href="/admin/comments" className={navLinkClasses}>
|
<Link href="/admin/comments" className={navLinkClasses}>
|
||||||
Comments
|
Comments
|
||||||
|
|
@ -50,6 +54,9 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
<li><Link href="/admin/users" className={navLinkClasses}>Users</Link></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>
|
||||||
</>
|
</>
|
||||||
|
|
@ -63,7 +70,14 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||||
</Link>
|
</Link>
|
||||||
<span aria-hidden="true" className="text-edge-strong">|</span>
|
<span aria-hidden="true" className="text-edge-strong">|</span>
|
||||||
<span className="text-ink-muted">
|
<span className="text-ink-muted">
|
||||||
Signed in as <span className="text-ink-strong">{user.username}</span>
|
Signed in as{" "}
|
||||||
|
<Link
|
||||||
|
href="/admin/account"
|
||||||
|
className="text-ink-strong underline-offset-4 transition-colors hover:text-link hover:underline"
|
||||||
|
title="Account settings"
|
||||||
|
>
|
||||||
|
{user.username}
|
||||||
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
<form action={logoutAction}>
|
<form action={logoutAction}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,19 @@ export default async function EditPostPage({
|
||||||
// Authors only reach their own posts; others 404 like unknown ids.
|
// Authors only reach their own posts; others 404 like unknown ids.
|
||||||
if (!isAdmin && post.authorId !== user.id) notFound();
|
if (!isAdmin && post.authorId !== user.id) notFound();
|
||||||
|
|
||||||
|
// Admin-added tags outside the author's grants are shown locked; the
|
||||||
|
// server preserves them across the author's saves.
|
||||||
|
const grantedIds = new Set(allTags.map((t) => t.id));
|
||||||
|
const lockedTags = isAdmin ? [] : post.tags.filter((t) => !grantedIds.has(t.id));
|
||||||
|
const statusOptions: Array<"draft" | "published"> =
|
||||||
|
post.status === "published"
|
||||||
|
? user.permissions.unpublishPosts
|
||||||
|
? ["draft", "published"]
|
||||||
|
: ["published"]
|
||||||
|
: user.permissions.publishPosts
|
||||||
|
? ["draft", "published"]
|
||||||
|
: ["draft"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
|
@ -61,8 +74,10 @@ export default async function EditPostPage({
|
||||||
<PostForm
|
<PostForm
|
||||||
post={post}
|
post={post}
|
||||||
allTags={allTags}
|
allTags={allTags}
|
||||||
|
lockedTags={lockedTags}
|
||||||
defaultAuthor={user.username}
|
defaultAuthor={user.username}
|
||||||
canCreateTags={isAdmin}
|
canCreateTags={user.permissions.createTags}
|
||||||
|
statusOptions={statusOptions}
|
||||||
action={updatePostAction.bind(null, post.id)}
|
action={updatePostAction.bind(null, post.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ export default async function NewPostPage() {
|
||||||
<PostForm
|
<PostForm
|
||||||
allTags={allTags}
|
allTags={allTags}
|
||||||
defaultAuthor={user.username}
|
defaultAuthor={user.username}
|
||||||
canCreateTags={isAdmin}
|
canCreateTags={user.permissions.createTags}
|
||||||
|
statusOptions={user.permissions.publishPosts ? ["draft", "published"] : ["draft"]}
|
||||||
action={createPostAction}
|
action={createPostAction}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export default async function AdminPostsPage({
|
||||||
}) {
|
}) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const isAdmin = user.role === "admin";
|
const isAdmin = user.role === "admin";
|
||||||
|
// Session permissions are normalized: the admin has all of them.
|
||||||
|
const { publishPosts, unpublishPosts, deletePosts } = user.permissions;
|
||||||
const [sp, posts] = await Promise.all([
|
const [sp, posts] = await Promise.all([
|
||||||
searchParams,
|
searchParams,
|
||||||
// Authors manage only their own posts; the admin manages everything.
|
// Authors manage only their own posts; the admin manages everything.
|
||||||
|
|
@ -81,6 +83,7 @@ export default async function AdminPostsPage({
|
||||||
>
|
>
|
||||||
Preview
|
Preview
|
||||||
</Link>
|
</Link>
|
||||||
|
{(post.status === "published" ? unpublishPosts : publishPosts) && (
|
||||||
<form
|
<form
|
||||||
action={setPostStatusAction.bind(
|
action={setPostStatusAction.bind(
|
||||||
null,
|
null,
|
||||||
|
|
@ -92,7 +95,8 @@ export default async function AdminPostsPage({
|
||||||
{post.status === "published" ? "Unpublish" : "Publish"}
|
{post.status === "published" ? "Unpublish" : "Publish"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{isAdmin && (
|
)}
|
||||||
|
{deletePosts && (
|
||||||
<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.`}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export default async function AdminUsersPage({
|
||||||
<th scope="col" className="px-4 py-3 font-medium">Username</th>
|
<th scope="col" className="px-4 py-3 font-medium">Username</th>
|
||||||
<th scope="col" className="px-4 py-3 font-medium">Role</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">Tag access</th>
|
||||||
|
<th scope="col" className="px-4 py-3 font-medium">Permissions</th>
|
||||||
<th scope="col" className="px-4 py-3 font-medium">Created</th>
|
<th scope="col" className="px-4 py-3 font-medium">Created</th>
|
||||||
<th scope="col" className="px-4 py-3 text-right font-medium">Actions</th>
|
<th scope="col" className="px-4 py-3 text-right font-medium">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -68,6 +69,19 @@ export default async function AdminUsersPage({
|
||||||
? "None yet"
|
? "None yet"
|
||||||
: user.tags.map((t) => t.name).join(", ")}
|
: user.tags.map((t) => t.name).join(", ")}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-ink-muted">
|
||||||
|
{user.role === "admin"
|
||||||
|
? "Everything"
|
||||||
|
: [
|
||||||
|
user.canPublishPosts && "publish",
|
||||||
|
user.canUnpublishPosts && "unpublish",
|
||||||
|
user.canDeletePosts && "delete",
|
||||||
|
user.canCreateTags && "create tags",
|
||||||
|
user.canApproveComments && "approve comments",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ") || "Write drafts only"}
|
||||||
|
</td>
|
||||||
<td className="whitespace-nowrap px-4 py-3 text-ink-muted">
|
<td className="whitespace-nowrap px-4 py-3 text-ink-muted">
|
||||||
{formatDate(user.createdAt)}
|
{formatDate(user.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
56
src/components/admin/ChangePasswordForm.tsx
Normal file
56
src/components/admin/ChangePasswordForm.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useId } from "react";
|
||||||
|
import { Flash, FormErrorBanner } from "@/components/admin/Flash";
|
||||||
|
import { SubmitButton } from "@/components/admin/SubmitButton";
|
||||||
|
import { ErrorText, HelpText, Input, Label } from "@/components/ui";
|
||||||
|
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
action: (prev: FormState, formData: FormData) => Promise<FormState>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChangePasswordForm({ action }: Props) {
|
||||||
|
const [state, formAction] = useActionState(action, initialFormState);
|
||||||
|
const ids = useId();
|
||||||
|
const err = (field: string) => firstFieldError(state, field);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="max-w-md space-y-5">
|
||||||
|
{state.status === "success" && <Flash>Password changed.</Flash>}
|
||||||
|
<FormErrorBanner>{state.formError}</FormErrorBanner>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={`${ids}-current`}>Current password</Label>
|
||||||
|
<Input
|
||||||
|
id={`${ids}-current`}
|
||||||
|
name="currentPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
maxLength={200}
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-invalid={err("currentPassword") ? true : undefined}
|
||||||
|
/>
|
||||||
|
<ErrorText>{err("currentPassword")}</ErrorText>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={`${ids}-new`}>New password</Label>
|
||||||
|
<Input
|
||||||
|
id={`${ids}-new`}
|
||||||
|
name="newPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
maxLength={200}
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-invalid={err("newPassword") ? true : undefined}
|
||||||
|
/>
|
||||||
|
<HelpText>At least 8 characters.</HelpText>
|
||||||
|
<ErrorText>{err("newPassword")}</ErrorText>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SubmitButton>Change password</SubmitButton>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -17,9 +17,20 @@ type Props = {
|
||||||
post?: PostWithTags;
|
post?: PostWithTags;
|
||||||
/** For authors this is just their granted tags, not every tag. */
|
/** For authors this is just their granted tags, not every tag. */
|
||||||
allTags: Tag[];
|
allTags: Tag[];
|
||||||
|
/**
|
||||||
|
* Tags on the post the current user cannot toggle (added by the admin
|
||||||
|
* outside the author's grants). Shown checked and disabled; the server
|
||||||
|
* preserves them regardless of what the form submits.
|
||||||
|
*/
|
||||||
|
lockedTags?: Tag[];
|
||||||
defaultAuthor: string;
|
defaultAuthor: string;
|
||||||
/** Admins can mint tags inline; authors cannot. */
|
/** Whether the user may mint new tags inline. */
|
||||||
canCreateTags: boolean;
|
canCreateTags: boolean;
|
||||||
|
/**
|
||||||
|
* Statuses this user may save the post as, given its current state
|
||||||
|
* (publish and unpublish are separate permissions).
|
||||||
|
*/
|
||||||
|
statusOptions: Array<"draft" | "published">;
|
||||||
action: (prev: FormState, formData: FormData) => Promise<FormState>;
|
action: (prev: FormState, formData: FormData) => Promise<FormState>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -42,7 +53,15 @@ const SETTINGS_FIELDS = [
|
||||||
* all fields regardless of the visible tab, and the sticky action bar
|
* 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, canCreateTags, action }: Props) {
|
export function PostForm({
|
||||||
|
post,
|
||||||
|
allTags,
|
||||||
|
lockedTags = [],
|
||||||
|
defaultAuthor,
|
||||||
|
canCreateTags,
|
||||||
|
statusOptions,
|
||||||
|
action,
|
||||||
|
}: Props) {
|
||||||
const [state, formAction] = useActionState(action, initialFormState);
|
const [state, formAction] = useActionState(action, initialFormState);
|
||||||
const ids = useId();
|
const ids = useId();
|
||||||
|
|
||||||
|
|
@ -114,10 +133,20 @@ export function PostForm({ post, allTags, defaultAuthor, canCreateTags, action }
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
className="w-32"
|
className="w-32"
|
||||||
|
disabled={statusOptions.length < 2}
|
||||||
|
title={
|
||||||
|
statusOptions.length < 2
|
||||||
|
? "You do not have permission to change this post's status."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<option value="draft">Draft</option>
|
{statusOptions.includes("draft") && <option value="draft">Draft</option>}
|
||||||
|
{statusOptions.includes("published") && (
|
||||||
<option value="published">Published</option>
|
<option value="published">Published</option>
|
||||||
|
)}
|
||||||
</Select>
|
</Select>
|
||||||
|
{/* A disabled select submits nothing — carry the status anyway. */}
|
||||||
|
{statusOptions.length < 2 && <input type="hidden" name="status" value={status} />}
|
||||||
<SubmitButton>Save post</SubmitButton>
|
<SubmitButton>Save post</SubmitButton>
|
||||||
<Link href="/admin/posts" className="text-sm text-ink-muted hover:text-ink-strong">
|
<Link href="/admin/posts" className="text-sm text-ink-muted hover:text-ink-strong">
|
||||||
Cancel
|
Cancel
|
||||||
|
|
@ -271,6 +300,19 @@ export function PostForm({ post, allTags, defaultAuthor, canCreateTags, action }
|
||||||
|
|
||||||
<fieldset className="rounded-lg border border-edge p-4">
|
<fieldset className="rounded-lg border border-edge p-4">
|
||||||
<legend className="px-1 text-sm font-medium text-ink-strong">Tags</legend>
|
<legend className="px-1 text-sm font-medium text-ink-strong">Tags</legend>
|
||||||
|
{lockedTags.length > 0 && (
|
||||||
|
<ul className="mb-2 flex flex-wrap gap-x-5 gap-y-2">
|
||||||
|
{lockedTags.map((tag) => (
|
||||||
|
<li key={tag.id}>
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-ink-muted">
|
||||||
|
<input type="checkbox" checked disabled className="size-4" />
|
||||||
|
{tag.name}
|
||||||
|
<span className="text-xs">(added by admin)</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
{allTags.length > 0 ? (
|
{allTags.length > 0 ? (
|
||||||
<ul className="flex flex-wrap gap-x-5 gap-y-2">
|
<ul className="flex flex-wrap gap-x-5 gap-y-2">
|
||||||
{allTags.map((tag) => (
|
{allTags.map((tag) => (
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,34 @@ type Props = {
|
||||||
action: (prev: FormState, formData: FormData) => Promise<FormState>;
|
action: (prev: FormState, formData: FormData) => Promise<FormState>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PERMISSION_OPTIONS = [
|
||||||
|
{
|
||||||
|
name: "canPublishPosts",
|
||||||
|
label: "Publish posts",
|
||||||
|
help: "Move their own posts from draft to published.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canUnpublishPosts",
|
||||||
|
label: "Unpublish posts",
|
||||||
|
help: "Move their own published posts back to draft.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canDeletePosts",
|
||||||
|
label: "Delete posts",
|
||||||
|
help: "Permanently delete their own posts.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canCreateTags",
|
||||||
|
label: "Create tags",
|
||||||
|
help: "Mint new tags from the post editor; created tags are granted to them.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canApproveComments",
|
||||||
|
label: "Approve comments",
|
||||||
|
help: "Moderate comments left on their own posts.",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function UserForm({ user, allTags, action }: Props) {
|
export function UserForm({ user, allTags, action }: Props) {
|
||||||
const [state, formAction] = useActionState(action, initialFormState);
|
const [state, formAction] = useActionState(action, initialFormState);
|
||||||
const ids = useId();
|
const ids = useId();
|
||||||
|
|
@ -83,6 +111,33 @@ export function UserForm({ user, allTags, action }: Props) {
|
||||||
<ErrorText>{err("password")}</ErrorText>
|
<ErrorText>{err("password")}</ErrorText>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isAdminAccount && (
|
||||||
|
<fieldset className="rounded-lg border border-edge p-4">
|
||||||
|
<legend className="px-1 text-sm font-medium text-ink-strong">Permissions</legend>
|
||||||
|
<p className="mb-3 text-sm text-ink-muted">
|
||||||
|
Without these, the author can only write and edit their own drafts.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2.5">
|
||||||
|
{PERMISSION_OPTIONS.map((perm) => (
|
||||||
|
<li key={perm.name}>
|
||||||
|
<label className="flex cursor-pointer items-start gap-2 text-sm text-ink">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name={perm.name}
|
||||||
|
defaultChecked={user?.[perm.name] ?? false}
|
||||||
|
className="mt-0.5 size-4 accent-(--link)"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{perm.label}
|
||||||
|
<span className="block text-xs text-ink-muted">{perm.help}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isAdminAccount && (
|
{!isAdminAccount && (
|
||||||
<fieldset className="rounded-lg border border-edge p-4">
|
<fieldset className="rounded-lg border border-edge p-4">
|
||||||
<legend className="px-1 text-sm font-medium text-ink-strong">Tag access</legend>
|
<legend className="px-1 text-sm font-medium text-ink-strong">Tag access</legend>
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,13 @@ export const users = pgTable("users", {
|
||||||
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"),
|
role: userRoleEnum("role").notNull().default("author"),
|
||||||
|
// Granular author permissions; irrelevant for the admin, who can do
|
||||||
|
// everything regardless. Post-scoped ones apply to the author's own posts.
|
||||||
|
canCreateTags: boolean("can_create_tags").notNull().default(false),
|
||||||
|
canPublishPosts: boolean("can_publish_posts").notNull().default(false),
|
||||||
|
canUnpublishPosts: boolean("can_unpublish_posts").notNull().default(false),
|
||||||
|
canDeletePosts: boolean("can_delete_posts").notNull().default(false),
|
||||||
|
canApproveComments: boolean("can_approve_comments").notNull().default(false),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,21 @@ import { sessions, type UserRole, users } from "@/db/schema";
|
||||||
|
|
||||||
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
export type SessionUser = { id: number; username: string; role: UserRole };
|
/** Author permissions, already normalized: the admin has all of them. */
|
||||||
|
export type Permissions = {
|
||||||
|
createTags: boolean;
|
||||||
|
publishPosts: boolean;
|
||||||
|
unpublishPosts: boolean;
|
||||||
|
deletePosts: boolean;
|
||||||
|
approveComments: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionUser = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: UserRole;
|
||||||
|
permissions: Permissions;
|
||||||
|
};
|
||||||
|
|
||||||
function hashToken(token: string): string {
|
function hashToken(token: string): string {
|
||||||
return createHash("sha256").update(token).digest("hex");
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
|
@ -33,6 +47,11 @@ export async function validateSessionToken(token: string): Promise<SessionUser |
|
||||||
userId: users.id,
|
userId: users.id,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
role: users.role,
|
role: users.role,
|
||||||
|
canCreateTags: users.canCreateTags,
|
||||||
|
canPublishPosts: users.canPublishPosts,
|
||||||
|
canUnpublishPosts: users.canUnpublishPosts,
|
||||||
|
canDeletePosts: users.canDeletePosts,
|
||||||
|
canApproveComments: users.canApproveComments,
|
||||||
expiresAt: sessions.expiresAt,
|
expiresAt: sessions.expiresAt,
|
||||||
})
|
})
|
||||||
.from(sessions)
|
.from(sessions)
|
||||||
|
|
@ -44,7 +63,19 @@ 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, role: row.role };
|
const isAdmin = row.role === "admin";
|
||||||
|
return {
|
||||||
|
id: row.userId,
|
||||||
|
username: row.username,
|
||||||
|
role: row.role,
|
||||||
|
permissions: {
|
||||||
|
createTags: isAdmin || row.canCreateTags,
|
||||||
|
publishPosts: isAdmin || row.canPublishPosts,
|
||||||
|
unpublishPosts: isAdmin || row.canUnpublishPosts,
|
||||||
|
deletePosts: isAdmin || row.canDeletePosts,
|
||||||
|
approveComments: isAdmin || row.canApproveComments,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSession(token: string): Promise<void> {
|
export async function deleteSession(token: string): Promise<void> {
|
||||||
|
|
|
||||||
57
src/lib/permissions.ts
Normal file
57
src/lib/permissions.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import type { ContentStatus } from "@/db/schema";
|
||||||
|
import type { Permissions } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishing and unpublishing are separate permissions, checked on the
|
||||||
|
* TRANSITION: saving an already-published post as published needs neither.
|
||||||
|
* `from` is null when the post is being created.
|
||||||
|
*/
|
||||||
|
export function statusChangeError(
|
||||||
|
permissions: Permissions,
|
||||||
|
from: ContentStatus | null,
|
||||||
|
to: ContentStatus,
|
||||||
|
): string | null {
|
||||||
|
if (from !== "published" && to === "published" && !permissions.publishPosts) {
|
||||||
|
return "You do not have permission to publish posts. Save as a draft instead.";
|
||||||
|
}
|
||||||
|
if (from === "published" && to === "draft" && !permissions.unpublishPosts) {
|
||||||
|
return "You do not have permission to unpublish posts.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the tags an author's save should leave on the post:
|
||||||
|
*
|
||||||
|
* - Submitted tags must all come from the author's grants (the form only
|
||||||
|
* offers granted tags; anything else is a forged request).
|
||||||
|
* - Tags already on the post that the author was never granted — e.g.
|
||||||
|
* added by the admin — are preserved untouched, since the author's
|
||||||
|
* form can't legitimately re-submit them.
|
||||||
|
* - The post must keep at least one granted tag, unless this save is
|
||||||
|
* also creating a new tag (which will be granted to the author).
|
||||||
|
*/
|
||||||
|
export function resolveAuthorTagIds(options: {
|
||||||
|
submitted: number[];
|
||||||
|
/** Tag ids currently on the post; empty when creating. */
|
||||||
|
existing: number[];
|
||||||
|
allowed: ReadonlySet<number>;
|
||||||
|
/** True when this save also creates new tags. */
|
||||||
|
creatingTags: boolean;
|
||||||
|
}): { tagIds: number[] } | { error: string } {
|
||||||
|
const { submitted, existing, allowed, creatingTags } = options;
|
||||||
|
|
||||||
|
if (submitted.some((id) => !allowed.has(id))) {
|
||||||
|
return { error: "You can only use tags you have been given access to." };
|
||||||
|
}
|
||||||
|
if (submitted.length === 0 && !creatingTags) {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
allowed.size === 0
|
||||||
|
? "You have not been given access to any tags yet — ask the admin."
|
||||||
|
: "Choose at least one of your tags.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const preserved = existing.filter((id) => !allowed.has(id));
|
||||||
|
return { tagIds: [...new Set([...submitted, ...preserved])] };
|
||||||
|
}
|
||||||
|
|
@ -117,10 +117,16 @@ export type AdminComment = Comment & {
|
||||||
parentAuthorName: string | null;
|
parentAuthorName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Admin moderation list: every comment with its post, newest first. */
|
/**
|
||||||
export async function listCommentsForAdmin(): Promise<AdminComment[]> {
|
* Moderation list: every comment with its post, newest first.
|
||||||
|
* `postAuthorId` scopes it to comments on one account's posts (authors
|
||||||
|
* with the approve-comments permission moderate only their own posts).
|
||||||
|
*/
|
||||||
|
export async function listCommentsForAdmin(options?: {
|
||||||
|
postAuthorId?: number;
|
||||||
|
}): Promise<AdminComment[]> {
|
||||||
const parent = alias(comments, "parent");
|
const parent = alias(comments, "parent");
|
||||||
const rows = await db
|
const base = db
|
||||||
.select({
|
.select({
|
||||||
comment: comments,
|
comment: comments,
|
||||||
postTitle: posts.title,
|
postTitle: posts.title,
|
||||||
|
|
@ -129,8 +135,11 @@ export async function listCommentsForAdmin(): Promise<AdminComment[]> {
|
||||||
})
|
})
|
||||||
.from(comments)
|
.from(comments)
|
||||||
.innerJoin(posts, eq(posts.id, comments.postId))
|
.innerJoin(posts, eq(posts.id, comments.postId))
|
||||||
.leftJoin(parent, eq(parent.id, comments.parentId))
|
.leftJoin(parent, eq(parent.id, comments.parentId));
|
||||||
.orderBy(desc(comments.createdAt), desc(comments.id));
|
const rows = await (options?.postAuthorId === undefined
|
||||||
|
? base
|
||||||
|
: base.where(eq(posts.authorId, options.postAuthorId))
|
||||||
|
).orderBy(desc(comments.createdAt), desc(comments.id));
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
...r.comment,
|
...r.comment,
|
||||||
|
|
@ -157,15 +166,31 @@ export async function deleteComment(id: number): Promise<void> {
|
||||||
await db.delete(comments).where(eq(comments.id, id));
|
await db.delete(comments).where(eq(comments.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function countCommentsByStatus(): Promise<{
|
export async function countCommentsByStatus(options?: {
|
||||||
pending: number;
|
postAuthorId?: number;
|
||||||
approved: number;
|
}): Promise<{ pending: number; approved: number }> {
|
||||||
}> {
|
const base = db
|
||||||
const rows = await db
|
|
||||||
.select({ status: comments.status, value: count() })
|
.select({ status: comments.status, value: count() })
|
||||||
.from(comments)
|
.from(comments)
|
||||||
.groupBy(comments.status);
|
.innerJoin(posts, eq(posts.id, comments.postId));
|
||||||
|
const rows = await (options?.postAuthorId === undefined
|
||||||
|
? base
|
||||||
|
: base.where(eq(posts.authorId, options.postAuthorId))
|
||||||
|
).groupBy(comments.status);
|
||||||
const result = { pending: 0, approved: 0 };
|
const result = { pending: 0, approved: 0 };
|
||||||
for (const row of rows) result[row.status] = row.value;
|
for (const row of rows) result[row.status] = row.value;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The owning account of the post a comment sits on (for permission checks). */
|
||||||
|
export async function getCommentPostAuthorId(
|
||||||
|
commentId: number,
|
||||||
|
): Promise<{ postAuthorId: number | null } | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ postAuthorId: posts.authorId })
|
||||||
|
.from(comments)
|
||||||
|
.innerJoin(posts, eq(posts.id, comments.postId))
|
||||||
|
.where(eq(comments.id, commentId))
|
||||||
|
.limit(1);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { and, asc, count, eq } from "drizzle-orm";
|
import { and, asc, count, eq, inArray } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { postTags, posts, type Tag, tags } from "@/db/schema";
|
import { postTags, posts, type Tag, tags } from "@/db/schema";
|
||||||
|
|
||||||
|
|
@ -42,3 +42,8 @@ export async function getTagById(id: number): Promise<Tag | null> {
|
||||||
const [tag] = await db.select().from(tags).where(eq(tags.id, id)).limit(1);
|
const [tag] = await db.select().from(tags).where(eq(tags.id, id)).limit(1);
|
||||||
return tag ?? null;
|
return tag ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTagsBySlugs(slugs: string[]): Promise<Tag[]> {
|
||||||
|
if (slugs.length === 0) return [];
|
||||||
|
return db.select().from(tags).where(inArray(tags.slug, slugs));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { asc, eq, inArray } from "drizzle-orm";
|
import { asc, eq, inArray } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { type Tag, type User, tags, userTags, users } from "@/db/schema";
|
import { type Tag, type User, tags, userTags, users } from "@/db/schema";
|
||||||
import { hashPassword } from "@/lib/auth/password";
|
import { hashPassword, verifyPassword } from "@/lib/auth/password";
|
||||||
|
|
||||||
/** User row without the password hash — safe to hand to pages. */
|
/** User row without the password hash — safe to hand to pages. */
|
||||||
export type SafeUser = Omit<User, "passwordHash">;
|
export type SafeUser = Omit<User, "passwordHash">;
|
||||||
|
|
@ -11,9 +11,24 @@ const safeColumns = {
|
||||||
id: users.id,
|
id: users.id,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
role: users.role,
|
role: users.role,
|
||||||
|
canCreateTags: users.canCreateTags,
|
||||||
|
canPublishPosts: users.canPublishPosts,
|
||||||
|
canUnpublishPosts: users.canUnpublishPosts,
|
||||||
|
canDeletePosts: users.canDeletePosts,
|
||||||
|
canApproveComments: users.canApproveComments,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** The five grantable author permissions, as stored on the row. */
|
||||||
|
export type PermissionColumns = Pick<
|
||||||
|
User,
|
||||||
|
| "canCreateTags"
|
||||||
|
| "canPublishPosts"
|
||||||
|
| "canUnpublishPosts"
|
||||||
|
| "canDeletePosts"
|
||||||
|
| "canApproveComments"
|
||||||
|
>;
|
||||||
|
|
||||||
export async function listUsersWithTags(): Promise<UserWithTags[]> {
|
export async function listUsersWithTags(): Promise<UserWithTags[]> {
|
||||||
const [userRows, grantRows] = await Promise.all([
|
const [userRows, grantRows] = await Promise.all([
|
||||||
db.select(safeColumns).from(users).orderBy(asc(users.createdAt), asc(users.id)),
|
db.select(safeColumns).from(users).orderBy(asc(users.createdAt), asc(users.id)),
|
||||||
|
|
@ -74,23 +89,31 @@ export async function createUser(input: {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
tagIds: number[];
|
tagIds: number[];
|
||||||
|
/** Defaults to no permissions (write/edit own drafts only). */
|
||||||
|
permissions?: PermissionColumns;
|
||||||
}): Promise<SafeUser> {
|
}): Promise<SafeUser> {
|
||||||
const passwordHash = await hashPassword(input.password);
|
const passwordHash = await hashPassword(input.password);
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.insert(users)
|
.insert(users)
|
||||||
.values({ username: input.username, passwordHash, role: "author" })
|
.values({
|
||||||
|
username: input.username,
|
||||||
|
passwordHash,
|
||||||
|
role: "author",
|
||||||
|
...(input.permissions ?? {}),
|
||||||
|
})
|
||||||
.returning(safeColumns);
|
.returning(safeColumns);
|
||||||
await replaceTagGrants(user.id, input.tagIds);
|
await replaceTagGrants(user.id, input.tagIds);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates an account's tag grants and optionally its password.
|
* Updates an account's tag grants, permissions, and optionally its
|
||||||
* Roles are never changed here — the single admin stays the admin.
|
* password. Roles are never changed — the single admin stays the admin,
|
||||||
|
* and permission/grant edits are ignored for the admin account.
|
||||||
*/
|
*/
|
||||||
export async function updateUser(
|
export async function updateUser(
|
||||||
id: number,
|
id: number,
|
||||||
input: { password: string | null; tagIds: number[] },
|
input: { password: string | null; tagIds: number[]; permissions?: PermissionColumns },
|
||||||
): Promise<SafeUser | null> {
|
): Promise<SafeUser | null> {
|
||||||
const [existing] = await db.select(safeColumns).from(users).where(eq(users.id, id)).limit(1);
|
const [existing] = await db.select(safeColumns).from(users).where(eq(users.id, id)).limit(1);
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
@ -99,13 +122,41 @@ export async function updateUser(
|
||||||
const passwordHash = await hashPassword(input.password);
|
const passwordHash = await hashPassword(input.password);
|
||||||
await db.update(users).set({ passwordHash }).where(eq(users.id, id));
|
await db.update(users).set({ passwordHash }).where(eq(users.id, id));
|
||||||
}
|
}
|
||||||
// Tag grants only mean something for authors.
|
// Grants and permissions only mean something for authors.
|
||||||
if (existing.role === "author") {
|
if (existing.role === "author") {
|
||||||
|
if (input.permissions) {
|
||||||
|
await db.update(users).set(input.permissions).where(eq(users.id, id));
|
||||||
|
}
|
||||||
await replaceTagGrants(id, input.tagIds);
|
await replaceTagGrants(id, input.tagIds);
|
||||||
}
|
}
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Verifies the current password, then replaces it. For any signed-in account. */
|
||||||
|
export async function changeOwnPassword(
|
||||||
|
userId: number,
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||||
|
if (!user) return { ok: false, error: "Your account no longer exists." };
|
||||||
|
if (!(await verifyPassword(user.passwordHash, currentPassword))) {
|
||||||
|
return { ok: false, error: "Your current password is incorrect." };
|
||||||
|
}
|
||||||
|
const passwordHash = await hashPassword(newPassword);
|
||||||
|
await db.update(users).set({ passwordHash }).where(eq(users.id, userId));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds tag grants without touching existing ones (used for author-created tags). */
|
||||||
|
export async function grantTags(userId: number, tagIds: number[]): Promise<void> {
|
||||||
|
if (tagIds.length === 0) return;
|
||||||
|
await db
|
||||||
|
.insert(userTags)
|
||||||
|
.values(tagIds.map((tagId) => ({ userId, tagId })))
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes an author account. The admin account cannot be deleted. The
|
* Deletes an author account. The admin account cannot be deleted. The
|
||||||
* account's posts survive as unowned rows (posts.author_id -> NULL);
|
* account's posts survive as unowned rows (posts.author_id -> NULL);
|
||||||
|
|
|
||||||
|
|
@ -97,10 +97,22 @@ const newPasswordInput = z
|
||||||
.min(8, "Password must be at least 8 characters.")
|
.min(8, "Password must be at least 8 characters.")
|
||||||
.max(200, "Password is too long.");
|
.max(200, "Password is too long.");
|
||||||
|
|
||||||
|
/** Checkbox: present ("on") when ticked, absent otherwise. */
|
||||||
|
const checkboxInput = z.preprocess((v) => v === "on" || v === "true" || v === true, z.boolean());
|
||||||
|
|
||||||
|
const permissionsInput = z.object({
|
||||||
|
canCreateTags: checkboxInput,
|
||||||
|
canPublishPosts: checkboxInput,
|
||||||
|
canUnpublishPosts: checkboxInput,
|
||||||
|
canDeletePosts: checkboxInput,
|
||||||
|
canApproveComments: checkboxInput,
|
||||||
|
});
|
||||||
|
|
||||||
export const createUserFormSchema = z.object({
|
export const createUserFormSchema = z.object({
|
||||||
username: usernameInput,
|
username: usernameInput,
|
||||||
password: newPasswordInput,
|
password: newPasswordInput,
|
||||||
tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]),
|
tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]),
|
||||||
|
permissions: permissionsInput,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateUserFormSchema = z.object({
|
export const updateUserFormSchema = z.object({
|
||||||
|
|
@ -110,6 +122,12 @@ export const updateUserFormSchema = z.object({
|
||||||
newPasswordInput.nullable(),
|
newPasswordInput.nullable(),
|
||||||
),
|
),
|
||||||
tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]),
|
tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]),
|
||||||
|
permissions: permissionsInput,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const changePasswordFormSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1, "Enter your current password.").max(200),
|
||||||
|
newPassword: newPasswordInput,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const navItemSchema = z
|
export const navItemSchema = z
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,18 @@ 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", role: "author" });
|
expect(sessionUser).toEqual({
|
||||||
|
id: user.id,
|
||||||
|
username: "admin",
|
||||||
|
role: "author",
|
||||||
|
permissions: {
|
||||||
|
createTags: false,
|
||||||
|
publishPosts: false,
|
||||||
|
unpublishPosts: false,
|
||||||
|
deletePosts: false,
|
||||||
|
approveComments: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Only a hash of the token is stored.
|
// Only a hash of the token is stored.
|
||||||
const rows = await db.select().from(sessions);
|
const rows = await db.select().from(sessions);
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@ import { verifyPassword } from "@/lib/auth/password";
|
||||||
import { buildSiteExport, importSiteExport } from "@/lib/services/import-export";
|
import { buildSiteExport, importSiteExport } from "@/lib/services/import-export";
|
||||||
import { createPost, getPostById, listAllPosts, type PostInput } from "@/lib/services/posts";
|
import { createPost, getPostById, listAllPosts, type PostInput } from "@/lib/services/posts";
|
||||||
import {
|
import {
|
||||||
|
changeOwnPassword,
|
||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
getAllowedTagIds,
|
getAllowedTagIds,
|
||||||
getUserWithTags,
|
getUserWithTags,
|
||||||
|
grantTags,
|
||||||
listUsersWithTags,
|
listUsersWithTags,
|
||||||
updateUser,
|
updateUser,
|
||||||
} from "@/lib/services/users";
|
} from "@/lib/services/users";
|
||||||
|
|
@ -91,6 +93,70 @@ describe("account management", () => {
|
||||||
expect(await getAllowedTagIds(user.id)).toEqual([writing.id]);
|
expect(await getAllowedTagIds(user.id)).toEqual([writing.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stores permissions, defaults to none, and updates them", async () => {
|
||||||
|
const user = await createUser({
|
||||||
|
username: "casey",
|
||||||
|
password: "hunter2hunter2",
|
||||||
|
tagIds: [],
|
||||||
|
permissions: {
|
||||||
|
canCreateTags: false,
|
||||||
|
canPublishPosts: true,
|
||||||
|
canUnpublishPosts: false,
|
||||||
|
canDeletePosts: false,
|
||||||
|
canApproveComments: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(user.canPublishPosts).toBe(true);
|
||||||
|
expect(user.canApproveComments).toBe(true);
|
||||||
|
expect(user.canDeletePosts).toBe(false);
|
||||||
|
|
||||||
|
const bare = await createUser({ username: "dana", password: "hunter2hunter2", tagIds: [] });
|
||||||
|
expect(bare.canPublishPosts).toBe(false);
|
||||||
|
|
||||||
|
await updateUser(user.id, {
|
||||||
|
password: null,
|
||||||
|
tagIds: [],
|
||||||
|
permissions: {
|
||||||
|
canCreateTags: true,
|
||||||
|
canPublishPosts: false,
|
||||||
|
canUnpublishPosts: false,
|
||||||
|
canDeletePosts: true,
|
||||||
|
canApproveComments: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const reread = await getUserWithTags(user.id);
|
||||||
|
expect(reread?.canCreateTags).toBe(true);
|
||||||
|
expect(reread?.canPublishPosts).toBe(false);
|
||||||
|
expect(reread?.canDeletePosts).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes own password only with the correct current password", async () => {
|
||||||
|
const user = await createUser({ username: "casey", password: "old-password-1", tagIds: [] });
|
||||||
|
const wrong = await changeOwnPassword(user.id, "not-the-password", "new-password-1");
|
||||||
|
expect(wrong.ok).toBe(false);
|
||||||
|
|
||||||
|
const right = await changeOwnPassword(user.id, "old-password-1", "new-password-1");
|
||||||
|
expect(right.ok).toBe(true);
|
||||||
|
const { users } = await import("@/db/schema");
|
||||||
|
const { eq } = await import("drizzle-orm");
|
||||||
|
const [row] = await db.select().from(users).where(eq(users.id, user.id));
|
||||||
|
expect(await verifyPassword(row.passwordHash, "new-password-1")).toBe(true);
|
||||||
|
expect(await verifyPassword(row.passwordHash, "old-password-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grantTags adds without replacing and tolerates duplicates", async () => {
|
||||||
|
const [design, writing] = await seedTags("Design", "Writing");
|
||||||
|
const user = await createUser({
|
||||||
|
username: "casey",
|
||||||
|
password: "hunter2hunter2",
|
||||||
|
tagIds: [design.id],
|
||||||
|
});
|
||||||
|
await grantTags(user.id, [writing.id, design.id]);
|
||||||
|
expect((await getAllowedTagIds(user.id)).sort()).toEqual(
|
||||||
|
[design.id, writing.id].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses to delete the admin and keeps a deleted author's posts", async () => {
|
it("refuses to delete the admin and keeps a deleted author's posts", async () => {
|
||||||
const admin = await seedAdmin();
|
const admin = await seedAdmin();
|
||||||
expect((await deleteUser(admin.id)).ok).toBe(false);
|
expect((await deleteUser(admin.id)).ok).toBe(false);
|
||||||
|
|
|
||||||
78
tests/unit/permissions.test.ts
Normal file
78
tests/unit/permissions.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Permissions } from "@/lib/auth/session";
|
||||||
|
import { resolveAuthorTagIds, statusChangeError } from "@/lib/permissions";
|
||||||
|
|
||||||
|
const perms = (overrides: Partial<Permissions> = {}): Permissions => ({
|
||||||
|
createTags: false,
|
||||||
|
publishPosts: false,
|
||||||
|
unpublishPosts: false,
|
||||||
|
deletePosts: false,
|
||||||
|
approveComments: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("statusChangeError", () => {
|
||||||
|
it("requires the publish permission to go draft -> published", () => {
|
||||||
|
expect(statusChangeError(perms(), null, "published")).toMatch(/publish/);
|
||||||
|
expect(statusChangeError(perms(), "draft", "published")).toMatch(/publish/);
|
||||||
|
expect(statusChangeError(perms({ publishPosts: true }), "draft", "published")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the unpublish permission to go published -> draft", () => {
|
||||||
|
expect(statusChangeError(perms(), "published", "draft")).toMatch(/unpublish/);
|
||||||
|
expect(
|
||||||
|
statusChangeError(perms({ unpublishPosts: true }), "published", "draft"),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never blocks saves that keep the status", () => {
|
||||||
|
expect(statusChangeError(perms(), "draft", "draft")).toBeNull();
|
||||||
|
expect(statusChangeError(perms(), "published", "published")).toBeNull();
|
||||||
|
expect(statusChangeError(perms(), null, "draft")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveAuthorTagIds", () => {
|
||||||
|
const allowed = new Set([1, 2]);
|
||||||
|
|
||||||
|
it("rejects submissions outside the grants", () => {
|
||||||
|
const result = resolveAuthorTagIds({
|
||||||
|
submitted: [1, 3],
|
||||||
|
existing: [],
|
||||||
|
allowed,
|
||||||
|
creatingTags: false,
|
||||||
|
});
|
||||||
|
expect(result).toHaveProperty("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires at least one granted tag unless creating one", () => {
|
||||||
|
expect(
|
||||||
|
resolveAuthorTagIds({ submitted: [], existing: [], allowed, creatingTags: false }),
|
||||||
|
).toHaveProperty("error");
|
||||||
|
expect(
|
||||||
|
resolveAuthorTagIds({ submitted: [], existing: [], allowed, creatingTags: true }),
|
||||||
|
).toEqual({ tagIds: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves admin-added tags the author cannot see", () => {
|
||||||
|
// Post carries granted tag 1 and admin-added tag 9; the author's form
|
||||||
|
// resubmits only tag 2. Tag 9 must survive.
|
||||||
|
const result = resolveAuthorTagIds({
|
||||||
|
submitted: [2],
|
||||||
|
existing: [1, 9],
|
||||||
|
allowed,
|
||||||
|
creatingTags: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ tagIds: [2, 9] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the author drop their own granted tags", () => {
|
||||||
|
const result = resolveAuthorTagIds({
|
||||||
|
submitted: [2],
|
||||||
|
existing: [1, 2],
|
||||||
|
allowed,
|
||||||
|
creatingTags: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ tagIds: [2] });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue