diff --git a/drizzle/0006_author-permissions.sql b/drizzle/0006_author-permissions.sql new file mode 100644 index 0000000..16abdc8 --- /dev/null +++ b/drizzle/0006_author-permissions.sql @@ -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'; diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..16244bd --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1042 @@ +{ + "id": "cdae9fb6-cf03-47a9-a922-b6bd45ae9017", + "prevId": "32da6541-c2e9-4bd5-8747-d4ec83671204", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "comments_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_email": { + "name": "author_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_public": { + "name": "email_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "comment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_post_id_status_idx": { + "name": "comments_post_id_status_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_parent_id_idx": { + "name": "comments_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_status_idx": { + "name": "comments_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_post_id_posts_id_fk": { + "name": "comments_post_id_posts_id_fk", + "tableFrom": "comments", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_parent_id_comments_id_fk": { + "name": "comments_parent_id_comments_id_fk", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nav_items": { + "name": "nav_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "nav_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "nav_items_page_id_pages_id_fk": { + "name": "nav_items_page_id_pages_id_fk", + "tableFrom": "nav_items", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "nav_items_target_check": { + "name": "nav_items_target_check", + "value": "(\"nav_items\".\"url\" IS NULL) <> (\"nav_items\".\"page_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "pages_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "content_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pages_slug_unique": { + "name": "pages_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_tags": { + "name": "post_tags", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "post_tags_tag_id_idx": { + "name": "post_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_tags_post_id_posts_id_fk": { + "name": "post_tags_post_id_posts_id_fk", + "tableFrom": "post_tags", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_tags_tag_id_tags_id_fk": { + "name": "post_tags_tag_id_tags_id_fk", + "tableFrom": "post_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_tags_post_id_tag_id_pk": { + "name": "post_tags_post_id_tag_id_pk", + "columns": [ + "post_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "posts_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "featured_image_url": { + "name": "featured_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "featured_image_alt": { + "name": "featured_image_alt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "content_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "posts_status_published_at_idx": { + "name": "posts_status_published_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_author_id_users_id_fk": { + "name": "posts_author_id_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "posts_slug_unique": { + "name": "posts_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'My Blog'" + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "footer_text": { + "name": "footer_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "posts_per_page": { + "name": "posts_per_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "excerpt_words": { + "name": "excerpt_words", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 40 + }, + "home_mode": { + "name": "home_mode", + "type": "home_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'posts'" + }, + "home_tag_id": { + "name": "home_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "home_page_id": { + "name": "home_page_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "theme": { + "name": "theme", + "type": "theme", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'solarized-dark'" + }, + "font": { + "name": "font", + "type": "font", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'geist'" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_home_tag_id_tags_id_fk": { + "name": "settings_home_tag_id_tags_id_fk", + "tableFrom": "settings", + "tableTo": "tags", + "columnsFrom": [ + "home_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "settings_home_page_id_pages_id_fk": { + "name": "settings_home_page_id_pages_id_fk", + "tableFrom": "settings", + "tableTo": "pages", + "columnsFrom": [ + "home_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "settings_single_row_check": { + "name": "settings_single_row_check", + "value": "\"settings\".\"id\" = 1" + }, + "settings_posts_per_page_check": { + "name": "settings_posts_per_page_check", + "value": "\"settings\".\"posts_per_page\" BETWEEN 1 AND 50" + }, + "settings_excerpt_words_check": { + "name": "settings_excerpt_words_check", + "value": "\"settings\".\"excerpt_words\" BETWEEN 5 AND 200" + } + }, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tags_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "tags_slug_unique": { + "name": "tags_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tags": { + "name": "user_tags", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_tags_tag_id_idx": { + "name": "user_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_tags_user_id_users_id_fk": { + "name": "user_tags_user_id_users_id_fk", + "tableFrom": "user_tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_tags_tag_id_tags_id_fk": { + "name": "user_tags_tag_id_tags_id_fk", + "tableFrom": "user_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_tags_user_id_tag_id_pk": { + "name": "user_tags_user_id_tag_id_pk", + "columns": [ + "user_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "users_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'author'" + }, + "can_create_tags": { + "name": "can_create_tags", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_publish_posts": { + "name": "can_publish_posts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_unpublish_posts": { + "name": "can_unpublish_posts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_delete_posts": { + "name": "can_delete_posts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_approve_comments": { + "name": "can_approve_comments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.comment_status": { + "name": "comment_status", + "schema": "public", + "values": [ + "pending", + "approved" + ] + }, + "public.content_status": { + "name": "content_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.font": { + "name": "font", + "schema": "public", + "values": [ + "geist", + "inter", + "lora", + "merriweather", + "jetbrains-mono", + "source-serif", + "eb-garamond", + "playfair-display", + "open-sans", + "work-sans", + "atkinson-hyperlegible", + "space-grotesk" + ] + }, + "public.home_mode": { + "name": "home_mode", + "schema": "public", + "values": [ + "posts", + "tag", + "page" + ] + }, + "public.theme": { + "name": "theme", + "schema": "public", + "values": [ + "solarized-dark", + "solarized-light", + "dracula", + "nord", + "gruvbox-dark", + "mono", + "mono-dark", + "catppuccin-mocha", + "catppuccin-latte", + "tokyo-night", + "one-dark", + "rose-pine", + "everforest-dark", + "monokai", + "github-light" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "author" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 7007ce5..f968929 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1783214024801, "tag": "0005_accounts", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1783266633118, + "tag": "0006_author-permissions", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/actions/comments.ts b/src/actions/comments.ts index 1ea86fc..4342292 100644 --- a/src/actions/comments.ts +++ b/src/actions/comments.ts @@ -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 { - 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"); } diff --git a/src/actions/posts.ts b/src/actions/posts.ts index 78e32d5..56a1ae2 100644 --- a/src/actions/posts.ts +++ b/src/actions/posts.ts @@ -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): 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 { - 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 { 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 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"); diff --git a/src/actions/users.ts b/src/actions/users.ts index 1de5ae6..faacabd 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -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 { + 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 { const admin = await requireAdmin(); const userId = z.number().int().positive().parse(id); diff --git a/src/app/admin/(panel)/account/page.tsx b/src/app/admin/(panel)/account/page.tsx new file mode 100644 index 0000000..b8f169e --- /dev/null +++ b/src/app/admin/(panel)/account/page.tsx @@ -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 ( +
+

Your account

+

+ Signed in as {user.username} + {" · "} + {user.role === "admin" ? "administrator" : "author"} +

+ +
+

+ Change password +

+ +
+
+ ); +} diff --git a/src/app/admin/(panel)/comments/page.tsx b/src/app/admin/(panel)/comments/page.tsx index 1fa4152..ad0fecd 100644 --- a/src/app/admin/(panel)/comments/page.tsx +++ b/src/app/admin/(panel)/comments/page.tsx @@ -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 (
  • @@ -53,22 +54,27 @@ function CommentCard({ comment }: { comment: AdminComment }) { )} -
    - - Delete - -
    + {canDelete && ( +
    + + Delete + +
    + )}
  • ); } 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() { ) : (
      {pending.map((comment) => ( - + ))}
    )} @@ -102,7 +108,7 @@ export default async function AdminCommentsPage() { ) : (
      {approved.map((comment) => ( - + ))}
    )} diff --git a/src/app/admin/(panel)/layout.tsx b/src/app/admin/(panel)/layout.tsx index df06781..bcf1f3a 100644 --- a/src/app/admin/(panel)/layout.tsx +++ b/src/app/admin/(panel)/layout.tsx @@ -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
      {isAdmin &&
    • Dashboard
    • }
    • Posts
    • + {isAdmin && ( +
    • Pages
    • + )} + {canModerate && ( +
    • + + Comments + {commentCounts.pending > 0 && ( + + {commentCounts.pending} + + )} + +
    • + )} {isAdmin && ( <> -
    • Pages
    • -
    • - - Comments - {commentCounts.pending > 0 && ( - - {commentCounts.pending} - - )} - -
    • Users
    • Settings
    • @@ -63,7 +70,14 @@ export default async function AdminLayout({ children }: { children: React.ReactN - Signed in as {user.username} + Signed in as{" "} + + {user.username} +
      -
      - {isAdmin && ( + {(post.status === "published" ? unpublishPosts : publishPosts) && ( +
      + +
      + )} + {deletePosts && (
      Username Role Tag access + Permissions Created Actions @@ -68,6 +69,19 @@ export default async function AdminUsersPage({ ? "None yet" : user.tags.map((t) => t.name).join(", ")} + + {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"} + {formatDate(user.createdAt)} diff --git a/src/components/admin/ChangePasswordForm.tsx b/src/components/admin/ChangePasswordForm.tsx new file mode 100644 index 0000000..fb422f4 --- /dev/null +++ b/src/components/admin/ChangePasswordForm.tsx @@ -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; +}; + +export function ChangePasswordForm({ action }: Props) { + const [state, formAction] = useActionState(action, initialFormState); + const ids = useId(); + const err = (field: string) => firstFieldError(state, field); + + return ( + + {state.status === "success" && Password changed.} + {state.formError} + +
      + + + {err("currentPassword")} +
      + +
      + + + At least 8 characters. + {err("newPassword")} +
      + + Change password + + ); +} diff --git a/src/components/admin/PostForm.tsx b/src/components/admin/PostForm.tsx index 343ae2e..8aabe78 100644 --- a/src/components/admin/PostForm.tsx +++ b/src/components/admin/PostForm.tsx @@ -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; }; @@ -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 + } > - - + {statusOptions.includes("draft") && } + {statusOptions.includes("published") && ( + + )} + {/* A disabled select submits nothing — carry the status anyway. */} + {statusOptions.length < 2 && } Save post Cancel @@ -271,6 +300,19 @@ export function PostForm({ post, allTags, defaultAuthor, canCreateTags, action }
      Tags + {lockedTags.length > 0 && ( +
        + {lockedTags.map((tag) => ( +
      • + +
      • + ))} +
      + )} {allTags.length > 0 ? (
        {allTags.map((tag) => ( diff --git a/src/components/admin/UserForm.tsx b/src/components/admin/UserForm.tsx index aa49c7b..4844996 100644 --- a/src/components/admin/UserForm.tsx +++ b/src/components/admin/UserForm.tsx @@ -15,6 +15,34 @@ type Props = { action: (prev: FormState, formData: FormData) => Promise; }; +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) { {err("password")} + {!isAdminAccount && ( +
        + Permissions +

        + Without these, the author can only write and edit their own drafts. +

        +
          + {PERMISSION_OPTIONS.map((perm) => ( +
        • + +
        • + ))} +
        +
        + )} + {!isAdminAccount && (
        Tag access diff --git a/src/db/schema.ts b/src/db/schema.ts index 7948051..6f4215b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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(), }); diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 703d028..f824b30 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -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 { diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 0000000..e09fee0 --- /dev/null +++ b/src/lib/permissions.ts @@ -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; + /** 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])] }; +} diff --git a/src/lib/services/comments.ts b/src/lib/services/comments.ts index f235980..ddbe166 100644 --- a/src/lib/services/comments.ts +++ b/src/lib/services/comments.ts @@ -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 { +/** + * 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 { 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 { }) .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 { 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; +} diff --git a/src/lib/services/tags.ts b/src/lib/services/tags.ts index 498cbba..ee00e2c 100644 --- a/src/lib/services/tags.ts +++ b/src/lib/services/tags.ts @@ -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 { const [tag] = await db.select().from(tags).where(eq(tags.id, id)).limit(1); return tag ?? null; } + +export async function getTagsBySlugs(slugs: string[]): Promise { + if (slugs.length === 0) return []; + return db.select().from(tags).where(inArray(tags.slug, slugs)); +} diff --git a/src/lib/services/users.ts b/src/lib/services/users.ts index 0d87012..93fc0b7 100644 --- a/src/lib/services/users.ts +++ b/src/lib/services/users.ts @@ -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; @@ -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 { 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 { 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 { 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 { + 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); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 06bcf7a..88c52b0 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -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 diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts index b38421f..f1c6085 100644 --- a/tests/integration/auth.test.ts +++ b/tests/integration/auth.test.ts @@ -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); diff --git a/tests/integration/users.test.ts b/tests/integration/users.test.ts index 5a441c7..57a6d9a 100644 --- a/tests/integration/users.test.ts +++ b/tests/integration/users.test.ts @@ -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); diff --git a/tests/unit/permissions.test.ts b/tests/unit/permissions.test.ts new file mode 100644 index 0000000..fb551d8 --- /dev/null +++ b/tests/unit/permissions.test.ts @@ -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 => ({ + 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] }); + }); +});