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,
|
||||
"tag": "0005_accounts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1783266633118,
|
||||
"tag": "0006_author-permissions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -2,12 +2,13 @@
|
|||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import { requireAdmin, requireUser } from "@/lib/auth/dal";
|
||||
import type { FormState } from "@/lib/forms";
|
||||
import { zodErrorToFormState } from "@/lib/forms";
|
||||
import {
|
||||
createComment,
|
||||
deleteComment,
|
||||
getCommentPostAuthorId,
|
||||
setCommentStatus,
|
||||
} from "@/lib/services/comments";
|
||||
import { commentFormSchema } from "@/lib/validation";
|
||||
|
|
@ -63,9 +64,16 @@ export async function setCommentStatusAction(
|
|||
id: number,
|
||||
status: "pending" | "approved",
|
||||
): Promise<void> {
|
||||
await requireAdmin();
|
||||
const user = await requireUser();
|
||||
const commentId = z.number().int().positive().parse(id);
|
||||
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);
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requireAdmin, requireUser } from "@/lib/auth/dal";
|
||||
import type { SessionUser } from "@/lib/auth/session";
|
||||
import { requireUser } from "@/lib/auth/dal";
|
||||
import type { FormState } from "@/lib/forms";
|
||||
import { zodErrorToFormState } from "@/lib/forms";
|
||||
import { sanitizeHtml } from "@/lib/html";
|
||||
import { resolveAuthorTagIds, statusChangeError } from "@/lib/permissions";
|
||||
import { isUniqueViolation, SlugConflictError } from "@/lib/services/errors";
|
||||
import {
|
||||
createPost,
|
||||
|
|
@ -17,7 +17,9 @@ import {
|
|||
setPostStatus,
|
||||
updatePost,
|
||||
} 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";
|
||||
|
||||
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> {
|
||||
const user = await requireUser();
|
||||
const parsed = readPostForm(formData);
|
||||
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
||||
const input = toPostInput(parsed.data);
|
||||
|
||||
if (user.role !== "admin") {
|
||||
let existingTagIds: number[] = [];
|
||||
let fromStatus: "draft" | "published" | null = null;
|
||||
if (id !== null) {
|
||||
const existing = await getPostById(id);
|
||||
if (!existing) return { formError: "This post no longer exists." };
|
||||
if (existing.authorId !== user.id) {
|
||||
return { formError: "You can only edit your own posts." };
|
||||
}
|
||||
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;
|
||||
|
|
@ -118,6 +128,15 @@ async function savePost(id: number | null, formData: FormData): Promise<FormStat
|
|||
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");
|
||||
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 nextStatus = z.enum(["draft", "published"]).parse(status);
|
||||
if (user.role !== "admin") {
|
||||
// Authors may change status on their own posts, within their
|
||||
// publish/unpublish permissions.
|
||||
const post = await getPostById(postId);
|
||||
// Authors may publish/unpublish their own posts only.
|
||||
if (!post || post.authorId !== user.id) return;
|
||||
if (statusChangeError(user.permissions, post.status, nextStatus) !== null) return;
|
||||
}
|
||||
await setPostStatus(postId, nextStatus);
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
export async function deletePostAction(id: number) {
|
||||
// Deleting is admin-only, even for a post the author owns.
|
||||
await requireAdmin();
|
||||
const user = await requireUser();
|
||||
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);
|
||||
revalidatePath("/", "layout");
|
||||
redirect("/admin/posts?deleted=1");
|
||||
|
|
|
|||
|
|
@ -3,12 +3,31 @@
|
|||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import { requireAdmin, requireUser } from "@/lib/auth/dal";
|
||||
import type { FormState } from "@/lib/forms";
|
||||
import { zodErrorToFormState } from "@/lib/forms";
|
||||
import { isUniqueViolation } from "@/lib/services/errors";
|
||||
import { createUser, deleteUser, updateUser } from "@/lib/services/users";
|
||||
import { createUserFormSchema, updateUserFormSchema } from "@/lib/validation";
|
||||
import {
|
||||
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(
|
||||
_prev: FormState,
|
||||
|
|
@ -19,6 +38,7 @@ export async function createUserAction(
|
|||
username: formData.get("username"),
|
||||
password: formData.get("password"),
|
||||
tagIds: formData.getAll("tagIds"),
|
||||
permissions: readPermissions(formData),
|
||||
});
|
||||
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
||||
|
||||
|
|
@ -44,6 +64,7 @@ export async function updateUserAction(
|
|||
const parsed = updateUserFormSchema.safeParse({
|
||||
password: formData.get("password"),
|
||||
tagIds: formData.getAll("tagIds"),
|
||||
permissions: readPermissions(formData),
|
||||
});
|
||||
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
||||
|
||||
|
|
@ -59,6 +80,32 @@ export async function updateUserAction(
|
|||
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> {
|
||||
const admin = await requireAdmin();
|
||||
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 Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { deleteCommentAction, setCommentStatusAction } from "@/actions/comments";
|
||||
import { ConfirmButton } from "@/components/admin/ConfirmButton";
|
||||
import { Button } from "@/components/ui";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import { requireUser } from "@/lib/auth/dal";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { type AdminComment, listCommentsForAdmin } from "@/lib/services/comments";
|
||||
|
||||
export const metadata: Metadata = { title: "Comments" };
|
||||
|
||||
function CommentCard({ comment }: { comment: AdminComment }) {
|
||||
function CommentCard({ comment, canDelete }: { comment: AdminComment; canDelete: boolean }) {
|
||||
return (
|
||||
<li className="rounded-lg border border-edge bg-surface p-4">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
|
|
@ -53,22 +54,27 @@ function CommentCard({ comment }: { comment: AdminComment }) {
|
|||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<form action={deleteCommentAction.bind(null, comment.id)}>
|
||||
<ConfirmButton
|
||||
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>
|
||||
</form>
|
||||
{canDelete && (
|
||||
<form action={deleteCommentAction.bind(null, comment.id)}>
|
||||
<ConfirmButton
|
||||
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>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function AdminCommentsPage() {
|
||||
await requireAdmin();
|
||||
const all = await listCommentsForAdmin();
|
||||
const user = await requireUser();
|
||||
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 approved = all.filter((c) => c.status === "approved");
|
||||
|
||||
|
|
@ -87,7 +93,7 @@ export default async function AdminCommentsPage() {
|
|||
) : (
|
||||
<ul className="mt-3 space-y-3">
|
||||
{pending.map((comment) => (
|
||||
<CommentCard key={comment.id} comment={comment} />
|
||||
<CommentCard key={comment.id} comment={comment} canDelete={isAdmin} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
|
@ -102,7 +108,7 @@ export default async function AdminCommentsPage() {
|
|||
) : (
|
||||
<ul className="mt-3 space-y-3">
|
||||
{approved.map((comment) => (
|
||||
<CommentCard key={comment.id} comment={comment} />
|
||||
<CommentCard key={comment.id} comment={comment} canDelete={isAdmin} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,12 @@ const navLinkClasses =
|
|||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await requireUser();
|
||||
const isAdmin = user.role === "admin";
|
||||
const canModerate = user.permissions.approveComments;
|
||||
const [settings, commentCounts] = await Promise.all([
|
||||
getSettings(),
|
||||
isAdmin ? countCommentsByStatus() : { pending: 0, approved: 0 },
|
||||
canModerate
|
||||
? countCommentsByStatus(isAdmin ? undefined : { postAuthorId: user.id })
|
||||
: { pending: 0, approved: 0 },
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
@ -37,19 +40,23 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||
<ul className="flex items-center gap-1">
|
||||
{isAdmin && <li><Link href="/admin" className={navLinkClasses}>Dashboard</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 && (
|
||||
<>
|
||||
<li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li>
|
||||
<li>
|
||||
<Link href="/admin/comments" className={navLinkClasses}>
|
||||
Comments
|
||||
{commentCounts.pending > 0 && (
|
||||
<span className="ml-1.5 inline-flex min-w-5 items-center justify-center rounded-full bg-warning/20 px-1.5 py-0.5 text-xs font-semibold text-warning">
|
||||
{commentCounts.pending}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
<li><Link href="/admin/users" className={navLinkClasses}>Users</Link></li>
|
||||
<li><Link href="/admin/settings" className={navLinkClasses}>Settings</Link></li>
|
||||
</>
|
||||
|
|
@ -63,7 +70,14 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||
</Link>
|
||||
<span aria-hidden="true" className="text-edge-strong">|</span>
|
||||
<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>
|
||||
<form action={logoutAction}>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -34,6 +34,19 @@ export default async function EditPostPage({
|
|||
// Authors only reach their own posts; others 404 like unknown ids.
|
||||
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 (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
|
|
@ -61,8 +74,10 @@ export default async function EditPostPage({
|
|||
<PostForm
|
||||
post={post}
|
||||
allTags={allTags}
|
||||
lockedTags={lockedTags}
|
||||
defaultAuthor={user.username}
|
||||
canCreateTags={isAdmin}
|
||||
canCreateTags={user.permissions.createTags}
|
||||
statusOptions={statusOptions}
|
||||
action={updatePostAction.bind(null, post.id)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ export default async function NewPostPage() {
|
|||
<PostForm
|
||||
allTags={allTags}
|
||||
defaultAuthor={user.username}
|
||||
canCreateTags={isAdmin}
|
||||
canCreateTags={user.permissions.createTags}
|
||||
statusOptions={user.permissions.publishPosts ? ["draft", "published"] : ["draft"]}
|
||||
action={createPostAction}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export default async function AdminPostsPage({
|
|||
}) {
|
||||
const user = await requireUser();
|
||||
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([
|
||||
searchParams,
|
||||
// Authors manage only their own posts; the admin manages everything.
|
||||
|
|
@ -81,18 +83,20 @@ export default async function AdminPostsPage({
|
|||
>
|
||||
Preview
|
||||
</Link>
|
||||
<form
|
||||
action={setPostStatusAction.bind(
|
||||
null,
|
||||
post.id,
|
||||
post.status === "published" ? "draft" : "published",
|
||||
)}
|
||||
>
|
||||
<button type="submit" className={actionButtonClasses}>
|
||||
{post.status === "published" ? "Unpublish" : "Publish"}
|
||||
</button>
|
||||
</form>
|
||||
{isAdmin && (
|
||||
{(post.status === "published" ? unpublishPosts : publishPosts) && (
|
||||
<form
|
||||
action={setPostStatusAction.bind(
|
||||
null,
|
||||
post.id,
|
||||
post.status === "published" ? "draft" : "published",
|
||||
)}
|
||||
>
|
||||
<button type="submit" className={actionButtonClasses}>
|
||||
{post.status === "published" ? "Unpublish" : "Publish"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{deletePosts && (
|
||||
<form action={deletePostAction.bind(null, post.id)}>
|
||||
<ConfirmButton
|
||||
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">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">Permissions</th>
|
||||
<th scope="col" className="px-4 py-3 font-medium">Created</th>
|
||||
<th scope="col" className="px-4 py-3 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
|
|
@ -68,6 +69,19 @@ export default async function AdminUsersPage({
|
|||
? "None yet"
|
||||
: user.tags.map((t) => t.name).join(", ")}
|
||||
</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">
|
||||
{formatDate(user.createdAt)}
|
||||
</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;
|
||||
/** For authors this is just their granted tags, not every 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;
|
||||
/** Admins can mint tags inline; authors cannot. */
|
||||
/** Whether the user may mint new tags inline. */
|
||||
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>;
|
||||
};
|
||||
|
||||
|
|
@ -42,7 +53,15 @@ const SETTINGS_FIELDS = [
|
|||
* all fields regardless of the visible tab, and the sticky action bar
|
||||
* keeps status + save reachable from either.
|
||||
*/
|
||||
export function PostForm({ post, allTags, defaultAuthor, canCreateTags, action }: Props) {
|
||||
export function PostForm({
|
||||
post,
|
||||
allTags,
|
||||
lockedTags = [],
|
||||
defaultAuthor,
|
||||
canCreateTags,
|
||||
statusOptions,
|
||||
action,
|
||||
}: Props) {
|
||||
const [state, formAction] = useActionState(action, initialFormState);
|
||||
const ids = useId();
|
||||
|
||||
|
|
@ -114,10 +133,20 @@ export function PostForm({ post, allTags, defaultAuthor, canCreateTags, action }
|
|||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
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>
|
||||
<option value="published">Published</option>
|
||||
{statusOptions.includes("draft") && <option value="draft">Draft</option>}
|
||||
{statusOptions.includes("published") && (
|
||||
<option value="published">Published</option>
|
||||
)}
|
||||
</Select>
|
||||
{/* A disabled select submits nothing — carry the status anyway. */}
|
||||
{statusOptions.length < 2 && <input type="hidden" name="status" value={status} />}
|
||||
<SubmitButton>Save post</SubmitButton>
|
||||
<Link href="/admin/posts" className="text-sm text-ink-muted hover:text-ink-strong">
|
||||
Cancel
|
||||
|
|
@ -271,6 +300,19 @@ export function PostForm({ post, allTags, defaultAuthor, canCreateTags, action }
|
|||
|
||||
<fieldset className="rounded-lg border border-edge p-4">
|
||||
<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 ? (
|
||||
<ul className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
{allTags.map((tag) => (
|
||||
|
|
|
|||
|
|
@ -15,6 +15,34 @@ type Props = {
|
|||
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) {
|
||||
const [state, formAction] = useActionState(action, initialFormState);
|
||||
const ids = useId();
|
||||
|
|
@ -83,6 +111,33 @@ export function UserForm({ user, allTags, action }: Props) {
|
|||
<ErrorText>{err("password")}</ErrorText>
|
||||
</div>
|
||||
|
||||
{!isAdminAccount && (
|
||||
<fieldset className="rounded-lg border border-edge p-4">
|
||||
<legend className="px-1 text-sm font-medium text-ink-strong">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 && (
|
||||
<fieldset className="rounded-lg border border-edge p-4">
|
||||
<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(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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 {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
|
|
@ -33,6 +47,11 @@ export async function validateSessionToken(token: string): Promise<SessionUser |
|
|||
userId: users.id,
|
||||
username: users.username,
|
||||
role: users.role,
|
||||
canCreateTags: users.canCreateTags,
|
||||
canPublishPosts: users.canPublishPosts,
|
||||
canUnpublishPosts: users.canUnpublishPosts,
|
||||
canDeletePosts: users.canDeletePosts,
|
||||
canApproveComments: users.canApproveComments,
|
||||
expiresAt: sessions.expiresAt,
|
||||
})
|
||||
.from(sessions)
|
||||
|
|
@ -44,7 +63,19 @@ export async function validateSessionToken(token: string): Promise<SessionUser |
|
|||
await db.delete(sessions).where(eq(sessions.id, id));
|
||||
return null;
|
||||
}
|
||||
return { id: row.userId, username: row.username, 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> {
|
||||
|
|
|
|||
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;
|
||||
};
|
||||
|
||||
/** 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 rows = await db
|
||||
const base = db
|
||||
.select({
|
||||
comment: comments,
|
||||
postTitle: posts.title,
|
||||
|
|
@ -129,8 +135,11 @@ export async function listCommentsForAdmin(): Promise<AdminComment[]> {
|
|||
})
|
||||
.from(comments)
|
||||
.innerJoin(posts, eq(posts.id, comments.postId))
|
||||
.leftJoin(parent, eq(parent.id, comments.parentId))
|
||||
.orderBy(desc(comments.createdAt), desc(comments.id));
|
||||
.leftJoin(parent, eq(parent.id, comments.parentId));
|
||||
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) => ({
|
||||
...r.comment,
|
||||
|
|
@ -157,15 +166,31 @@ export async function deleteComment(id: number): Promise<void> {
|
|||
await db.delete(comments).where(eq(comments.id, id));
|
||||
}
|
||||
|
||||
export async function countCommentsByStatus(): Promise<{
|
||||
pending: number;
|
||||
approved: number;
|
||||
}> {
|
||||
const rows = await db
|
||||
export async function countCommentsByStatus(options?: {
|
||||
postAuthorId?: number;
|
||||
}): Promise<{ pending: number; approved: number }> {
|
||||
const base = db
|
||||
.select({ status: comments.status, value: count() })
|
||||
.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 };
|
||||
for (const row of rows) result[row.status] = row.value;
|
||||
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 { 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);
|
||||
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 { db } from "@/db";
|
||||
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. */
|
||||
export type SafeUser = Omit<User, "passwordHash">;
|
||||
|
|
@ -11,9 +11,24 @@ const safeColumns = {
|
|||
id: users.id,
|
||||
username: users.username,
|
||||
role: users.role,
|
||||
canCreateTags: users.canCreateTags,
|
||||
canPublishPosts: users.canPublishPosts,
|
||||
canUnpublishPosts: users.canUnpublishPosts,
|
||||
canDeletePosts: users.canDeletePosts,
|
||||
canApproveComments: users.canApproveComments,
|
||||
createdAt: users.createdAt,
|
||||
} 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[]> {
|
||||
const [userRows, grantRows] = await Promise.all([
|
||||
db.select(safeColumns).from(users).orderBy(asc(users.createdAt), asc(users.id)),
|
||||
|
|
@ -74,23 +89,31 @@ export async function createUser(input: {
|
|||
username: string;
|
||||
password: string;
|
||||
tagIds: number[];
|
||||
/** Defaults to no permissions (write/edit own drafts only). */
|
||||
permissions?: PermissionColumns;
|
||||
}): Promise<SafeUser> {
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ username: input.username, passwordHash, role: "author" })
|
||||
.values({
|
||||
username: input.username,
|
||||
passwordHash,
|
||||
role: "author",
|
||||
...(input.permissions ?? {}),
|
||||
})
|
||||
.returning(safeColumns);
|
||||
await replaceTagGrants(user.id, input.tagIds);
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an account's tag grants and optionally its password.
|
||||
* Roles are never changed here — the single admin stays the admin.
|
||||
* Updates an account's tag grants, permissions, and optionally its
|
||||
* 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(
|
||||
id: number,
|
||||
input: { password: string | null; tagIds: number[] },
|
||||
input: { password: string | null; tagIds: number[]; permissions?: PermissionColumns },
|
||||
): Promise<SafeUser | null> {
|
||||
const [existing] = await db.select(safeColumns).from(users).where(eq(users.id, id)).limit(1);
|
||||
if (!existing) return null;
|
||||
|
|
@ -99,13 +122,41 @@ export async function updateUser(
|
|||
const passwordHash = await hashPassword(input.password);
|
||||
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 (input.permissions) {
|
||||
await db.update(users).set(input.permissions).where(eq(users.id, id));
|
||||
}
|
||||
await replaceTagGrants(id, input.tagIds);
|
||||
}
|
||||
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
|
||||
* 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.")
|
||||
.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({
|
||||
username: usernameInput,
|
||||
password: newPasswordInput,
|
||||
tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]),
|
||||
permissions: permissionsInput,
|
||||
});
|
||||
|
||||
export const updateUserFormSchema = z.object({
|
||||
|
|
@ -110,6 +122,12 @@ export const updateUserFormSchema = z.object({
|
|||
newPasswordInput.nullable(),
|
||||
),
|
||||
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
|
||||
|
|
|
|||
|
|
@ -46,7 +46,18 @@ describe("sessions", () => {
|
|||
expect(expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
|
||||
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.
|
||||
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 { createPost, getPostById, listAllPosts, type PostInput } from "@/lib/services/posts";
|
||||
import {
|
||||
changeOwnPassword,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getAllowedTagIds,
|
||||
getUserWithTags,
|
||||
grantTags,
|
||||
listUsersWithTags,
|
||||
updateUser,
|
||||
} from "@/lib/services/users";
|
||||
|
|
@ -91,6 +93,70 @@ describe("account management", () => {
|
|||
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 () => {
|
||||
const admin = await seedAdmin();
|
||||
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