From 35fb33c5a741267138bc21415e0af7aab0a5b7b3 Mon Sep 17 00:00:00 2001 From: matt Date: Sun, 5 Jul 2026 12:58:19 -0400 Subject: [PATCH] Add granular author permissions, admin-tag persistence, own-password change Each author account now carries five grantable permissions, editable on the Users page: publish posts, unpublish posts, delete posts (all three scoped to the author's own posts), create tags, and approve comments (scoped to comments on the author's posts, without delete). A bare author writes and edits their own drafts only. Permission checks gate the status TRANSITION, so editing an already-published post never requires the publish permission, and the editor's status dropdown only offers what the account may do. Tags an author creates are granted to them automatically, and "creating" an existing off-grant tag is refused (it would be a self-grant loophole). Existing author accounts keep publish+unpublish via migration backfill. Tags the admin attaches outside an author's grants now survive the author's edits: the form shows them checked-and-locked and the server re-attaches them on every save. Every account can change its own password on the new /admin/account page (current password required); the username in the admin header links there. Co-Authored-By: Claude Fable 5 --- drizzle/0006_author-permissions.sql | 8 + drizzle/meta/0006_snapshot.json | 1042 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/actions/comments.ts | 12 +- src/actions/posts.ts | 102 +- src/actions/users.ts | 53 +- src/app/admin/(panel)/account/page.tsx | 29 + src/app/admin/(panel)/comments/page.tsx | 34 +- src/app/admin/(panel)/layout.tsx | 40 +- .../admin/(panel)/posts/[id]/edit/page.tsx | 17 +- src/app/admin/(panel)/posts/new/page.tsx | 3 +- src/app/admin/(panel)/posts/page.tsx | 28 +- src/app/admin/(panel)/users/page.tsx | 14 + src/components/admin/ChangePasswordForm.tsx | 56 + src/components/admin/PostForm.tsx | 50 +- src/components/admin/UserForm.tsx | 55 + src/db/schema.ts | 7 + src/lib/auth/session.ts | 35 +- src/lib/permissions.ts | 57 + src/lib/services/comments.ts | 47 +- src/lib/services/tags.ts | 7 +- src/lib/services/users.ts | 63 +- src/lib/validation.ts | 18 + tests/integration/auth.test.ts | 13 +- tests/integration/users.test.ts | 66 ++ tests/unit/permissions.test.ts | 78 ++ 26 files changed, 1832 insertions(+), 109 deletions(-) create mode 100644 drizzle/0006_author-permissions.sql create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 src/app/admin/(panel)/account/page.tsx create mode 100644 src/components/admin/ChangePasswordForm.tsx create mode 100644 src/lib/permissions.ts create mode 100644 tests/unit/permissions.test.ts 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] }); + }); +});