Add granular author permissions, admin-tag persistence, own-password change
Each author account now carries five grantable permissions, editable on the Users page: publish posts, unpublish posts, delete posts (all three scoped to the author's own posts), create tags, and approve comments (scoped to comments on the author's posts, without delete). A bare author writes and edits their own drafts only. Permission checks gate the status TRANSITION, so editing an already-published post never requires the publish permission, and the editor's status dropdown only offers what the account may do. Tags an author creates are granted to them automatically, and "creating" an existing off-grant tag is refused (it would be a self-grant loophole). Existing author accounts keep publish+unpublish via migration backfill. Tags the admin attaches outside an author's grants now survive the author's edits: the form shows them checked-and-locked and the server re-attaches them on every save. Every account can change its own password on the new /admin/account page (current password required); the username in the admin header links there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
fcb3010f81
commit
35fb33c5a7
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,22 +54,27 @@ function CommentCard({ comment }: { comment: AdminComment }) {
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
<form action={deleteCommentAction.bind(null, comment.id)}>
|
{canDelete && (
|
||||||
<ConfirmButton
|
<form action={deleteCommentAction.bind(null, comment.id)}>
|
||||||
confirmMessage={`Delete this comment by ${comment.authorName}? Replies to it are deleted too. This cannot be undone.`}
|
<ConfirmButton
|
||||||
className="px-3 py-1.5 text-xs"
|
confirmMessage={`Delete this comment by ${comment.authorName}? Replies to it are deleted too. This cannot be undone.`}
|
||||||
>
|
className="px-3 py-1.5 text-xs"
|
||||||
Delete
|
>
|
||||||
</ConfirmButton>
|
Delete
|
||||||
</form>
|
</ConfirmButton>
|
||||||
|
</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 (
|
||||||
|
|
@ -37,19 +40,23 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||||
<ul className="flex items-center gap-1">
|
<ul className="flex items-center gap-1">
|
||||||
{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 && (
|
||||||
|
<li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li>
|
||||||
|
)}
|
||||||
|
{canModerate && (
|
||||||
|
<li>
|
||||||
|
<Link href="/admin/comments" className={navLinkClasses}>
|
||||||
|
Comments
|
||||||
|
{commentCounts.pending > 0 && (
|
||||||
|
<span className="ml-1.5 inline-flex min-w-5 items-center justify-center rounded-full bg-warning/20 px-1.5 py-0.5 text-xs font-semibold text-warning">
|
||||||
|
{commentCounts.pending}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li>
|
|
||||||
<li>
|
|
||||||
<Link href="/admin/comments" className={navLinkClasses}>
|
|
||||||
Comments
|
|
||||||
{commentCounts.pending > 0 && (
|
|
||||||
<span className="ml-1.5 inline-flex min-w-5 items-center justify-center rounded-full bg-warning/20 px-1.5 py-0.5 text-xs font-semibold text-warning">
|
|
||||||
{commentCounts.pending}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li><Link href="/admin/users" className={navLinkClasses}>Users</Link></li>
|
<li><Link href="/admin/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,18 +83,20 @@ export default async function AdminPostsPage({
|
||||||
>
|
>
|
||||||
Preview
|
Preview
|
||||||
</Link>
|
</Link>
|
||||||
<form
|
{(post.status === "published" ? unpublishPosts : publishPosts) && (
|
||||||
action={setPostStatusAction.bind(
|
<form
|
||||||
null,
|
action={setPostStatusAction.bind(
|
||||||
post.id,
|
null,
|
||||||
post.status === "published" ? "draft" : "published",
|
post.id,
|
||||||
)}
|
post.status === "published" ? "draft" : "published",
|
||||||
>
|
)}
|
||||||
<button type="submit" className={actionButtonClasses}>
|
>
|
||||||
{post.status === "published" ? "Unpublish" : "Publish"}
|
<button type="submit" className={actionButtonClasses}>
|
||||||
</button>
|
{post.status === "published" ? "Unpublish" : "Publish"}
|
||||||
</form>
|
</button>
|
||||||
{isAdmin && (
|
</form>
|
||||||
|
)}
|
||||||
|
{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>}
|
||||||
<option value="published">Published</option>
|
{statusOptions.includes("published") && (
|
||||||
|
<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