diff --git a/drizzle/0005_accounts.sql b/drizzle/0005_accounts.sql new file mode 100644 index 0000000..8a3dcd1 --- /dev/null +++ b/drizzle/0005_accounts.sql @@ -0,0 +1,17 @@ +CREATE TYPE "public"."user_role" AS ENUM('admin', 'author');--> statement-breakpoint +CREATE TABLE "user_tags" ( + "user_id" integer NOT NULL, + "tag_id" integer NOT NULL, + CONSTRAINT "user_tags_user_id_tag_id_pk" PRIMARY KEY("user_id","tag_id") +); +--> statement-breakpoint +ALTER TABLE "posts" ADD COLUMN "author_id" integer;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "role" "user_role" DEFAULT 'author' NOT NULL;--> statement-breakpoint +ALTER TABLE "user_tags" ADD CONSTRAINT "user_tags_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_tags" ADD CONSTRAINT "user_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "user_tags_tag_id_idx" ON "user_tags" USING btree ("tag_id");--> statement-breakpoint +ALTER TABLE "posts" ADD CONSTRAINT "posts_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +-- Backfill: every account that existed before roles was the admin account, +-- and every existing post was written by it. +UPDATE "users" SET "role" = 'admin';--> statement-breakpoint +UPDATE "posts" SET "author_id" = (SELECT "id" FROM "users" WHERE "role" = 'admin' ORDER BY "id" LIMIT 1) WHERE "author_id" IS NULL; diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..af34adb --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1007 @@ +{ + "id": "32da6541-c2e9-4bd5-8747-d4ec83671204", + "prevId": "b1253e7e-39b6-4405-b99a-427b27e0266f", + "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'" + }, + "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 af87bbb..7007ce5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1783213112664, "tag": "0004_comments", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1783214024801, + "tag": "0005_accounts", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/seed.ts b/scripts/seed.ts index 66581d1..0e98cf6 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -535,8 +535,11 @@ export async function seed(databaseUrl: string, log: (msg: string) => void = () const passwordHash = await hashPassword(password); await db .insert(users) - .values({ username, passwordHash }) - .onConflictDoUpdate({ target: users.username, set: { passwordHash } }); + .values({ username, passwordHash, role: "admin" }) + .onConflictDoUpdate({ + target: users.username, + set: { passwordHash, role: "admin" }, + }); log(`admin user “${username}” ready`); // --- Site settings (only created, never overwritten) ------------------- diff --git a/src/actions/posts.ts b/src/actions/posts.ts index b893878..78e32d5 100644 --- a/src/actions/posts.ts +++ b/src/actions/posts.ts @@ -3,7 +3,8 @@ 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 { SessionUser } from "@/lib/auth/session"; import type { FormState } from "@/lib/forms"; import { zodErrorToFormState } from "@/lib/forms"; import { sanitizeHtml } from "@/lib/html"; @@ -11,10 +12,12 @@ import { isUniqueViolation, SlugConflictError } from "@/lib/services/errors"; import { createPost, deletePost, + getPostById, type PostInput, setPostStatus, updatePost, } from "@/lib/services/posts"; +import { getAllowedTagIds } from "@/lib/services/users"; import { postFormSchema } from "@/lib/validation"; function readPostForm(formData: FormData) { @@ -27,7 +30,8 @@ function readPostForm(formData: FormData) { featuredImageAlt: formData.get("featuredImageAlt"), status: formData.get("status"), tagIds: formData.getAll("tagIds"), - newTags: formData.get("newTags"), + // Author forms omit the new-tags input entirely — treat missing as "". + newTags: formData.get("newTags") ?? "", }); } @@ -46,18 +50,60 @@ 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 { - await requireAdmin(); + const user = await requireUser(); const parsed = readPostForm(formData); if (!parsed.success) return zodErrorToFormState(parsed.error); + const input = toPostInput(parsed.data); + if (user.role !== "admin") { + 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." }; + } + } + const tagError = await checkAuthorTagRules(user, input); + if (tagError) return tagError; + } let postId: number; try { if (id === null) { - const post = await createPost(toPostInput(parsed.data)); + const post = await createPost(input, user.id); postId = post.id; } else { - const post = await updatePost(id, toPostInput(parsed.data)); + const post = await updatePost(id, input); if (!post) return { formError: "This post no longer exists." }; postId = post.id; } @@ -86,14 +132,20 @@ export async function updatePostAction(id: number, _prev: FormState, formData: F } export async function setPostStatusAction(id: number, status: "draft" | "published") { - await requireAdmin(); + const user = await requireUser(); const postId = z.number().int().positive().parse(id); const nextStatus = z.enum(["draft", "published"]).parse(status); + if (user.role !== "admin") { + const post = await getPostById(postId); + // Authors may publish/unpublish their own posts only. + if (!post || post.authorId !== user.id) 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 postId = z.number().int().positive().parse(id); await deletePost(postId); diff --git a/src/actions/users.ts b/src/actions/users.ts new file mode 100644 index 0000000..1de5ae6 --- /dev/null +++ b/src/actions/users.ts @@ -0,0 +1,71 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { z } from "zod"; +import { requireAdmin } 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"; + +export async function createUserAction( + _prev: FormState, + formData: FormData, +): Promise { + await requireAdmin(); + const parsed = createUserFormSchema.safeParse({ + username: formData.get("username"), + password: formData.get("password"), + tagIds: formData.getAll("tagIds"), + }); + if (!parsed.success) return zodErrorToFormState(parsed.error); + + try { + await createUser(parsed.data); + } catch (error) { + if (isUniqueViolation(error)) { + return { fieldErrors: { username: ["That username is already taken."] } }; + } + console.error("createUserAction failed", error); + return { formError: "Something went wrong while creating the account." }; + } + redirect("/admin/users?created=1"); +} + +export async function updateUserAction( + id: number, + _prev: FormState, + formData: FormData, +): Promise { + await requireAdmin(); + const userId = z.number().int().positive().parse(id); + const parsed = updateUserFormSchema.safeParse({ + password: formData.get("password"), + tagIds: formData.getAll("tagIds"), + }); + if (!parsed.success) return zodErrorToFormState(parsed.error); + + try { + const user = await updateUser(userId, parsed.data); + if (!user) return { formError: "That account no longer exists." }; + } catch (error) { + console.error("updateUserAction failed", error); + return { formError: "Something went wrong while saving the account." }; + } + // Tag grants gate what authors can post — refresh admin pages. + revalidatePath("/admin", "layout"); + return { status: "success" }; +} + +export async function deleteUserAction(id: number): Promise { + const admin = await requireAdmin(); + const userId = z.number().int().positive().parse(id); + // requireAdmin + the service's admin-role guard both protect the admin + // account; this guards the sillier accident of deleting yourself. + if (userId === admin.id) return; + await deleteUser(userId); + revalidatePath("/admin", "layout"); + redirect("/admin/users?deleted=1"); +} diff --git a/src/app/admin/(panel)/layout.tsx b/src/app/admin/(panel)/layout.tsx index 3b7876f..df06781 100644 --- a/src/app/admin/(panel)/layout.tsx +++ b/src/app/admin/(panel)/layout.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { logoutAction } from "@/actions/auth"; -import { requireAdmin } from "@/lib/auth/dal"; +import { requireUser } from "@/lib/auth/dal"; import { countCommentsByStatus } from "@/lib/services/comments"; import { getSettings } from "@/lib/services/settings"; @@ -13,10 +13,11 @@ const navLinkClasses = * depth), and every mutating server action re-checks on its own. */ export default async function AdminLayout({ children }: { children: React.ReactNode }) { - const user = await requireAdmin(); + const user = await requireUser(); + const isAdmin = user.role === "admin"; const [settings, commentCounts] = await Promise.all([ getSettings(), - countCommentsByStatus(), + isAdmin ? countCommentsByStatus() : { pending: 0, approved: 0 }, ]); return ( @@ -32,21 +33,27 @@ export default async function AdminLayout({ children }: { children: React.ReactN · Admin diff --git a/src/app/admin/(panel)/posts/[id]/edit/page.tsx b/src/app/admin/(panel)/posts/[id]/edit/page.tsx index 956387e..b17734d 100644 --- a/src/app/admin/(panel)/posts/[id]/edit/page.tsx +++ b/src/app/admin/(panel)/posts/[id]/edit/page.tsx @@ -4,10 +4,11 @@ import { notFound } from "next/navigation"; import { updatePostAction } from "@/actions/posts"; import { Flash } from "@/components/admin/Flash"; import { PostForm } from "@/components/admin/PostForm"; -import { requireAdmin } from "@/lib/auth/dal"; +import { requireUser } from "@/lib/auth/dal"; import { parseIdParam } from "@/lib/params"; import { getPostById } from "@/lib/services/posts"; import { listAllTags } from "@/lib/services/tags"; +import { getUserWithTags } from "@/lib/services/users"; export const metadata: Metadata = { title: "Edit post" }; @@ -18,14 +19,20 @@ export default async function EditPostPage({ params: Promise<{ id: string }>; searchParams: Promise>; }) { - const user = await requireAdmin(); + const user = await requireUser(); + const isAdmin = user.role === "admin"; const [{ id: rawId }, sp] = await Promise.all([params, searchParams]); const id = parseIdParam(rawId); if (id === null) notFound(); - const [post, allTags] = await Promise.all([getPostById(id), listAllTags()]); + const [post, allTags] = await Promise.all([ + getPostById(id), + isAdmin ? listAllTags() : getUserWithTags(user.id).then((u) => u?.tags ?? []), + ]); if (!post) notFound(); + // Authors only reach their own posts; others 404 like unknown ids. + if (!isAdmin && post.authorId !== user.id) notFound(); return (
@@ -55,6 +62,7 @@ export default async function EditPostPage({ post={post} allTags={allTags} defaultAuthor={user.username} + canCreateTags={isAdmin} action={updatePostAction.bind(null, post.id)} />
diff --git a/src/app/admin/(panel)/posts/[id]/preview/page.tsx b/src/app/admin/(panel)/posts/[id]/preview/page.tsx index aaf9dc8..cef6ee6 100644 --- a/src/app/admin/(panel)/posts/[id]/preview/page.tsx +++ b/src/app/admin/(panel)/posts/[id]/preview/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { StatusBadge } from "@/components/admin/StatusBadge"; import { PostArticle } from "@/components/public/PostArticle"; -import { requireAdmin } from "@/lib/auth/dal"; +import { requireUser } from "@/lib/auth/dal"; import { parseIdParam } from "@/lib/params"; import { getPostById } from "@/lib/services/posts"; @@ -15,13 +15,14 @@ export default async function PostPreviewPage({ }: { params: Promise<{ id: string }>; }) { - await requireAdmin(); + const user = await requireUser(); const { id: rawId } = await params; const id = parseIdParam(rawId); if (id === null) notFound(); const post = await getPostById(id); if (!post) notFound(); + if (user.role !== "admin" && post.authorId !== user.id) notFound(); return (
diff --git a/src/app/admin/(panel)/posts/new/page.tsx b/src/app/admin/(panel)/posts/new/page.tsx index 9fc76ef..1646004 100644 --- a/src/app/admin/(panel)/posts/new/page.tsx +++ b/src/app/admin/(panel)/posts/new/page.tsx @@ -1,19 +1,29 @@ import type { Metadata } from "next"; import { createPostAction } from "@/actions/posts"; import { PostForm } from "@/components/admin/PostForm"; -import { requireAdmin } from "@/lib/auth/dal"; +import { requireUser } from "@/lib/auth/dal"; import { listAllTags } from "@/lib/services/tags"; +import { getUserWithTags } from "@/lib/services/users"; export const metadata: Metadata = { title: "New post" }; export default async function NewPostPage() { - const user = await requireAdmin(); - const allTags = await listAllTags(); + const user = await requireUser(); + const isAdmin = user.role === "admin"; + // Authors only ever see (and can only use) their granted tags. + const allTags = isAdmin + ? await listAllTags() + : ((await getUserWithTags(user.id))?.tags ?? []); return (

New post

- +
); } diff --git a/src/app/admin/(panel)/posts/page.tsx b/src/app/admin/(panel)/posts/page.tsx index 7c1c50b..fc39747 100644 --- a/src/app/admin/(panel)/posts/page.tsx +++ b/src/app/admin/(panel)/posts/page.tsx @@ -5,7 +5,7 @@ import { ConfirmButton } from "@/components/admin/ConfirmButton"; import { Flash } from "@/components/admin/Flash"; import { StatusBadge } from "@/components/admin/StatusBadge"; import { LinkButton } from "@/components/ui"; -import { requireAdmin } from "@/lib/auth/dal"; +import { requireUser } from "@/lib/auth/dal"; import { formatDate } from "@/lib/format"; import { listAllPosts } from "@/lib/services/posts"; @@ -19,8 +19,13 @@ export default async function AdminPostsPage({ }: { searchParams: Promise>; }) { - await requireAdmin(); - const [sp, posts] = await Promise.all([searchParams, listAllPosts()]); + const user = await requireUser(); + const isAdmin = user.role === "admin"; + const [sp, posts] = await Promise.all([ + searchParams, + // Authors manage only their own posts; the admin manages everything. + listAllPosts(isAdmin ? undefined : { authorId: user.id }), + ]); return (
@@ -87,14 +92,16 @@ export default async function AdminPostsPage({ {post.status === "published" ? "Unpublish" : "Publish"} -
- - Delete - -
+ {isAdmin && ( +
+ + Delete + +
+ )}
diff --git a/src/app/admin/(panel)/users/[id]/edit/page.tsx b/src/app/admin/(panel)/users/[id]/edit/page.tsx new file mode 100644 index 0000000..1fbb3ee --- /dev/null +++ b/src/app/admin/(panel)/users/[id]/edit/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { updateUserAction } from "@/actions/users"; +import { UserForm } from "@/components/admin/UserForm"; +import { requireAdmin } from "@/lib/auth/dal"; +import { parseIdParam } from "@/lib/params"; +import { listAllTags } from "@/lib/services/tags"; +import { getUserWithTags } from "@/lib/services/users"; + +export const metadata: Metadata = { title: "Edit account" }; + +export default async function EditUserPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + await requireAdmin(); + const { id: rawId } = await params; + const id = parseIdParam(rawId); + if (id === null) notFound(); + + const [user, allTags] = await Promise.all([getUserWithTags(id), listAllTags()]); + if (!user) notFound(); + + return ( +
+

Edit account

+ +
+ ); +} diff --git a/src/app/admin/(panel)/users/new/page.tsx b/src/app/admin/(panel)/users/new/page.tsx new file mode 100644 index 0000000..f187f39 --- /dev/null +++ b/src/app/admin/(panel)/users/new/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import { createUserAction } from "@/actions/users"; +import { UserForm } from "@/components/admin/UserForm"; +import { requireAdmin } from "@/lib/auth/dal"; +import { listAllTags } from "@/lib/services/tags"; + +export const metadata: Metadata = { title: "New account" }; + +export default async function NewUserPage() { + await requireAdmin(); + const allTags = await listAllTags(); + + return ( +
+

New account

+ +
+ ); +} diff --git a/src/app/admin/(panel)/users/page.tsx b/src/app/admin/(panel)/users/page.tsx new file mode 100644 index 0000000..66b8c7d --- /dev/null +++ b/src/app/admin/(panel)/users/page.tsx @@ -0,0 +1,105 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { deleteUserAction } from "@/actions/users"; +import { ConfirmButton } from "@/components/admin/ConfirmButton"; +import { Flash } from "@/components/admin/Flash"; +import { LinkButton } from "@/components/ui"; +import { requireAdmin } from "@/lib/auth/dal"; +import { formatDate } from "@/lib/format"; +import { listUsersWithTags } from "@/lib/services/users"; + +export const metadata: Metadata = { title: "Users" }; + +export default async function AdminUsersPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + await requireAdmin(); + const [sp, users] = await Promise.all([searchParams, listUsersWithTags()]); + + return ( +
+
+

Users

+ New account +
+ + {sp.created === "1" && Account created.} + {sp.deleted === "1" && Account deleted.} + +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
UsernameRoleTag accessCreatedActions
+ + {user.username} + + + {user.role === "admin" ? ( + + Admin + + ) : ( + + Author + + )} + + {user.role === "admin" + ? "All tags" + : user.tags.length === 0 + ? "None yet" + : user.tags.map((t) => t.name).join(", ")} + + {formatDate(user.createdAt)} + +
+ + Edit + + {user.role !== "admin" && ( +
+ + Delete + +
+ )} +
+
+
+

+ Authors can write, edit, and publish their own posts under the tags you grant them. + Only you can delete posts, manage pages, moderate comments, or change settings. +

+
+ ); +} diff --git a/src/components/admin/PostForm.tsx b/src/components/admin/PostForm.tsx index 9c55454..343ae2e 100644 --- a/src/components/admin/PostForm.tsx +++ b/src/components/admin/PostForm.tsx @@ -15,8 +15,11 @@ import { uploadImageFile } from "@/lib/upload-client"; type Props = { post?: PostWithTags; + /** For authors this is just their granted tags, not every tag. */ allTags: Tag[]; defaultAuthor: string; + /** Admins can mint tags inline; authors cannot. */ + canCreateTags: boolean; action: (prev: FormState, formData: FormData) => Promise; }; @@ -39,7 +42,7 @@ 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, action }: Props) { +export function PostForm({ post, allTags, defaultAuthor, canCreateTags, action }: Props) { const [state, formAction] = useActionState(action, initialFormState); const ids = useId(); @@ -287,22 +290,32 @@ export function PostForm({ post, allTags, defaultAuthor, action }: Props) { ))} ) : ( -

No tags exist yet — create some below.

+

+ {canCreateTags + ? "No tags exist yet — create some below." + : "You have not been given access to any tags yet — ask the admin."} +

+ )} + {err("tagIds")} + {canCreateTags ? ( +
+ + setNewTags(e.target.value)} + placeholder="design, typescript" + aria-describedby={`${ids}-new-tags-help`} + /> + + Comma-separated. Created and attached to this post on save. + + {err("newTags")} +
+ ) : ( + Posts must carry at least one of your tags. )} -
- - setNewTags(e.target.value)} - placeholder="design, typescript" - aria-describedby={`${ids}-new-tags-help`} - /> - - Comma-separated. Created and attached to this post on save. - -
diff --git a/src/components/admin/UserForm.tsx b/src/components/admin/UserForm.tsx new file mode 100644 index 0000000..aa49c7b --- /dev/null +++ b/src/components/admin/UserForm.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useActionState, useId, useState } 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 { Tag } from "@/db/schema"; +import { type FormState, firstFieldError, initialFormState } from "@/lib/forms"; +import type { UserWithTags } from "@/lib/services/users"; + +type Props = { + /** Absent when creating a new account. */ + user?: UserWithTags; + allTags: Tag[]; + action: (prev: FormState, formData: FormData) => Promise; +}; + +export function UserForm({ user, allTags, action }: Props) { + const [state, formAction] = useActionState(action, initialFormState); + const ids = useId(); + const isNew = user === undefined; + const isAdminAccount = user?.role === "admin"; + const [selectedTagIds, setSelectedTagIds] = useState>( + () => new Set(user?.tags.map((t) => t.id) ?? []), + ); + const err = (field: string) => firstFieldError(state, field); + + function toggleTag(id: number) { + setSelectedTagIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + return ( +
+ {state.status === "success" && Account saved.} + {state.formError} + + {isNew ? ( +
+ + + Letters, numbers, dots, dashes, and underscores. + {err("username")} +
+ ) : ( +

+ {user.username}{" "} + + · {isAdminAccount ? "administrator" : "author"} + +

+ )} + +
+ + + + {isNew + ? "At least 8 characters. Share it with the author out of band." + : "Leave blank to keep the current password."} + + {err("password")} +
+ + {!isAdminAccount && ( +
+ Tag access +

+ The author can write posts only under the tags checked here. +

+ {allTags.length > 0 ? ( +
    + {allTags.map((tag) => ( +
  • + +
  • + ))} +
+ ) : ( +

+ No tags exist yet — create some from a post first. +

+ )} + {err("tagIds")} +
+ )} + +
+ {isNew ? "Create account" : "Save account"} +
+
+ ); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index f102a73..7948051 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -14,6 +14,7 @@ import { export const contentStatusEnum = pgEnum("content_status", ["draft", "published"]); export const commentStatusEnum = pgEnum("comment_status", ["pending", "approved"]); +export const userRoleEnum = pgEnum("user_role", ["admin", "author"]); export const homeModeEnum = pgEnum("home_mode", ["posts", "tag", "page"]); export const themeEnum = pgEnum("theme", [ "solarized-dark", @@ -51,9 +52,27 @@ export const users = pgTable("users", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), username: text("username").notNull().unique(), passwordHash: text("password_hash").notNull(), + role: userRoleEnum("role").notNull().default("author"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); +/** Tags an author account may publish under; irrelevant for admins. */ +export const userTags = pgTable( + "user_tags", + { + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tagId: integer("tag_id") + .notNull() + .references(() => tags.id, { onDelete: "cascade" }), + }, + (t) => [ + primaryKey({ columns: [t.userId, t.tagId] }), + index("user_tags_tag_id_idx").on(t.tagId), + ], +); + export const sessions = pgTable("sessions", { // sha-256 hex digest of the bearer token; the raw token never touches the DB. id: text("id").primaryKey(), @@ -71,7 +90,10 @@ export const posts = pgTable( title: text("title").notNull(), slug: text("slug").notNull().unique(), body: text("body").notNull().default(""), + // Display byline (free text); authorId is the owning account. Posts + // survive account deletion as unowned (admin-managed) rows. authorName: text("author_name").notNull(), + authorId: integer("author_id").references(() => users.id, { onDelete: "set null" }), featuredImageUrl: text("featured_image_url"), featuredImageAlt: text("featured_image_alt"), status: contentStatusEnum("status").notNull().default("draft"), @@ -180,6 +202,7 @@ export const navItems = pgTable( ); export type User = typeof users.$inferSelect; +export type UserRole = User["role"]; export type Session = typeof sessions.$inferSelect; export type Post = typeof posts.$inferSelect; export type Tag = typeof tags.$inferSelect; diff --git a/src/lib/auth/dal.ts b/src/lib/auth/dal.ts index 6f32bb6..4b6fb75 100644 --- a/src/lib/auth/dal.ts +++ b/src/lib/auth/dal.ts @@ -14,8 +14,16 @@ export const getSessionUser = cache(async (): Promise => { return validateSessionToken(token); }); -export async function requireAdmin(): Promise { +/** Any signed-in account (admin or author). */ +export async function requireUser(): Promise { const user = await getSessionUser(); if (!user) redirect("/admin/login"); return user; } + +/** Admin only; signed-in authors are sent to their posts list. */ +export async function requireAdmin(): Promise { + const user = await requireUser(); + if (user.role !== "admin") redirect("/admin/posts"); + return user; +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 96a1e8f..703d028 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -1,14 +1,14 @@ import { createHash, randomBytes } from "node:crypto"; import { eq, lt } from "drizzle-orm"; import { db } from "@/db"; -import { sessions, users } from "@/db/schema"; +import { sessions, type UserRole, users } from "@/db/schema"; // Deliberately free of next/* imports so it can be exercised directly by // integration tests; cookie handling lives in cookies.ts. export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days -export type SessionUser = { id: number; username: string }; +export type SessionUser = { id: number; username: string; role: UserRole }; function hashToken(token: string): string { return createHash("sha256").update(token).digest("hex"); @@ -29,7 +29,12 @@ export async function validateSessionToken(token: string): Promise { diff --git a/src/lib/services/import-export.ts b/src/lib/services/import-export.ts index c5523d7..494c9c2 100644 --- a/src/lib/services/import-export.ts +++ b/src/lib/services/import-export.ts @@ -1,4 +1,4 @@ -import { asc, eq } from "drizzle-orm"; +import { asc, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/db"; import { @@ -11,6 +11,7 @@ import { settings, tags, themeEnum, + users, } from "@/db/schema"; import { sanitizeHtml } from "@/lib/html"; import { isValidLinkUrl } from "@/lib/validation"; @@ -22,9 +23,10 @@ import { getSettings } from "./settings"; * columns are regenerated on import). */ export const SITE_EXPORT_FORMAT = "yap-blog-export"; -// v1: posts/pages/tags/nav/settings. v2 adds comments. v1 files still -// import fine (they simply carry no comments). -export const SITE_EXPORT_VERSION = 2; +// v1: posts/pages/tags/nav/settings. v2 adds comments. v3 adds +// posts.authorUsername so post ownership survives a restore. Older +// files still import (missing fields default to empty/unowned). +export const SITE_EXPORT_VERSION = 3; const slugValue = z.string().trim().min(1).max(120); const statusValue = z.enum(["draft", "published"]); @@ -34,7 +36,7 @@ const timestampValue = z.coerce.date(); export const siteExportSchema = z .object({ format: z.literal(SITE_EXPORT_FORMAT), - version: z.union([z.literal(1), z.literal(2)]), + version: z.union([z.literal(1), z.literal(2), z.literal(3)]), exportedAt: timestampValue, settings: z.object({ siteTitle: z.string().trim().min(1).max(120), @@ -79,6 +81,12 @@ export const siteExportSchema = z slug: slugValue, body: bodyValue, authorName: z.string().trim().min(1).max(120), + /** + * Owning account's username. Accounts are not part of the export; + * on import this is matched against existing usernames and posts + * without a match become unowned (admin-managed). + */ + authorUsername: z.string().trim().min(1).max(120).nullable().default(null), featuredImageUrl: z.string().trim().max(2000).nullable(), featuredImageAlt: z.string().trim().max(300).nullable(), status: statusValue, @@ -264,7 +272,11 @@ export async function buildSiteExport(): Promise { .orderBy(asc(navItems.sortOrder), asc(navItems.id)), db.select().from(tags).orderBy(asc(tags.slug)), db.select().from(pages).orderBy(asc(pages.id)), - db.select().from(posts).orderBy(asc(posts.id)), + db + .select({ post: posts, authorUsername: users.username }) + .from(posts) + .leftJoin(users, eq(users.id, posts.authorId)) + .orderBy(asc(posts.id)), db .select({ postId: postTags.postId, tagSlug: tags.slug }) .from(postTags) @@ -296,7 +308,8 @@ export async function buildSiteExport(): Promise { excerptWords: settingsRow.excerptWords, homeMode: settingsRow.homeMode, homeTagSlug: tagRows.find((t) => t.id === settingsRow.homeTagId)?.slug ?? null, - homePageSlug: pageRows.find((p) => p.id === settingsRow.homePageId)?.slug ?? null, + homePageSlug: + pageRows.find((p) => p.id === settingsRow.homePageId)?.slug ?? null, theme: settingsRow.theme, font: settingsRow.font, }, @@ -314,11 +327,12 @@ export async function buildSiteExport(): Promise { createdAt: p.createdAt, updatedAt: p.updatedAt, })), - posts: postRows.map((p) => ({ + posts: postRows.map(({ post: p, authorUsername }) => ({ title: p.title, slug: p.slug, body: p.body, authorName: p.authorName, + authorUsername, featuredImageUrl: p.featuredImageUrl, featuredImageAlt: p.featuredImageAlt, status: p.status, @@ -392,6 +406,22 @@ export async function importSiteExport(data: SiteExport): Promise { for (const row of inserted) pageIdBySlug.set(row.slug, row.id); } + // Accounts survive an import untouched; posts re-attach to them by + // username. Unknown usernames leave the post unowned (admin-managed). + const exportUsernames = [ + ...new Set( + data.posts.flatMap((p) => (p.authorUsername !== null ? [p.authorUsername] : [])), + ), + ]; + const userIdByUsername = new Map(); + if (exportUsernames.length > 0) { + const userRows = await tx + .select({ id: users.id, username: users.username }) + .from(users) + .where(inArray(users.username, exportUsernames)); + for (const row of userRows) userIdByUsername.set(row.username, row.id); + } + const postIdBySlug = new Map(); const links: Array<{ postId: number; tagId: number }> = []; for (const batch of chunk(data.posts, 1000)) { @@ -403,6 +433,10 @@ export async function importSiteExport(data: SiteExport): Promise { slug: p.slug, body: sanitizeHtml(p.body), authorName: p.authorName, + authorId: + p.authorUsername !== null + ? (userIdByUsername.get(p.authorUsername) ?? null) + : null, featuredImageUrl: p.featuredImageUrl, featuredImageAlt: p.featuredImageAlt, status: p.status, diff --git a/src/lib/services/posts.ts b/src/lib/services/posts.ts index ade62c6..52acaf8 100644 --- a/src/lib/services/posts.ts +++ b/src/lib/services/posts.ts @@ -111,7 +111,11 @@ async function syncPostTags( } } -export async function createPost(input: PostInput): Promise { +/** `authorId` is the owning account; ownership never changes on edit. */ +export async function createPost( + input: PostInput, + authorId: number | null = null, +): Promise { const slug = await resolvePostSlug(input); return db.transaction(async (tx) => { const [post] = await tx @@ -121,6 +125,7 @@ export async function createPost(input: PostInput): Promise { slug, body: input.body, authorName: input.authorName, + authorId, featuredImageUrl: input.featuredImageUrl, featuredImageAlt: input.featuredImageAlt, status: input.status, @@ -265,9 +270,15 @@ export async function getPostById(id: number): Promise { return { ...post, tags: tagMap.get(post.id) ?? [] }; } -/** Admin listing: all posts, most recently updated first. */ -export async function listAllPosts(): Promise { - return db.select().from(posts).orderBy(desc(posts.updatedAt), desc(posts.id)); +/** + * Admin listing: all posts, most recently updated first. + * Pass `authorId` to restrict to one account's posts (author view). + */ +export async function listAllPosts(options?: { authorId?: number }): Promise { + const base = db.select().from(posts); + const query = + options?.authorId === undefined ? base : base.where(eq(posts.authorId, options.authorId)); + return query.orderBy(desc(posts.updatedAt), desc(posts.id)); } export async function countPostsByStatus(): Promise<{ published: number; draft: number }> { diff --git a/src/lib/services/users.ts b/src/lib/services/users.ts new file mode 100644 index 0000000..0d87012 --- /dev/null +++ b/src/lib/services/users.ts @@ -0,0 +1,122 @@ +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"; + +/** User row without the password hash — safe to hand to pages. */ +export type SafeUser = Omit; +export type UserWithTags = SafeUser & { tags: Tag[] }; + +const safeColumns = { + id: users.id, + username: users.username, + role: users.role, + createdAt: users.createdAt, +} as const; + +export async function listUsersWithTags(): Promise { + const [userRows, grantRows] = await Promise.all([ + db.select(safeColumns).from(users).orderBy(asc(users.createdAt), asc(users.id)), + db + .select({ userId: userTags.userId, tag: tags }) + .from(userTags) + .innerJoin(tags, eq(tags.id, userTags.tagId)) + .orderBy(asc(tags.name)), + ]); + const tagsByUser = new Map(); + for (const row of grantRows) { + const list = tagsByUser.get(row.userId) ?? []; + list.push(row.tag); + tagsByUser.set(row.userId, list); + } + return userRows.map((u) => ({ ...u, tags: tagsByUser.get(u.id) ?? [] })); +} + +export async function getUserWithTags(id: number): Promise { + const [user] = await db.select(safeColumns).from(users).where(eq(users.id, id)).limit(1); + if (!user) return null; + const grants = await db + .select({ tag: tags }) + .from(userTags) + .innerJoin(tags, eq(tags.id, userTags.tagId)) + .where(eq(userTags.userId, id)) + .orderBy(asc(tags.name)); + return { ...user, tags: grants.map((g) => g.tag) }; +} + +/** Tag ids an author may publish under. (Admins bypass this check.) */ +export async function getAllowedTagIds(userId: number): Promise { + const rows = await db + .select({ tagId: userTags.tagId }) + .from(userTags) + .where(eq(userTags.userId, userId)); + return rows.map((r) => r.tagId); +} + +async function replaceTagGrants(userId: number, tagIds: number[]): Promise { + await db.transaction(async (tx) => { + await tx.delete(userTags).where(eq(userTags.userId, userId)); + if (tagIds.length > 0) { + // Silently drop ids for tags deleted since the form rendered. + const existing = await tx + .select({ id: tags.id }) + .from(tags) + .where(inArray(tags.id, tagIds)); + if (existing.length > 0) { + await tx.insert(userTags).values(existing.map((t) => ({ userId, tagId: t.id }))); + } + } + }); +} + +/** New accounts are always authors — there is exactly one admin. */ +export async function createUser(input: { + username: string; + password: string; + tagIds: number[]; +}): Promise { + const passwordHash = await hashPassword(input.password); + const [user] = await db + .insert(users) + .values({ username: input.username, passwordHash, role: "author" }) + .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. + */ +export async function updateUser( + id: number, + input: { password: string | null; tagIds: number[] }, +): Promise { + const [existing] = await db.select(safeColumns).from(users).where(eq(users.id, id)).limit(1); + if (!existing) return null; + + if (input.password !== null) { + const passwordHash = await hashPassword(input.password); + await db.update(users).set({ passwordHash }).where(eq(users.id, id)); + } + // Tag grants only mean something for authors. + if (existing.role === "author") { + await replaceTagGrants(id, input.tagIds); + } + return existing; +} + +/** + * Deletes an author account. The admin account cannot be deleted. The + * account's posts survive as unowned rows (posts.author_id -> NULL); + * its sessions cascade away, signing the account out everywhere. + */ +export async function deleteUser(id: number): Promise<{ ok: boolean; error?: string }> { + const [user] = await db.select(safeColumns).from(users).where(eq(users.id, id)).limit(1); + if (!user) return { ok: false, error: "That account no longer exists." }; + if (user.role === "admin") { + return { ok: false, error: "The admin account cannot be deleted." }; + } + await db.delete(users).where(eq(users.id, id)); + return { ok: true }; +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index f1b6445..06bcf7a 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -82,6 +82,36 @@ export const loginFormSchema = z.object({ password: z.string().min(1, "Password is required.").max(200), }); +const usernameInput = z + .string() + .trim() + .min(1, "Username is required.") + .max(120, "Username is too long.") + .regex( + /^[a-zA-Z0-9._-]+$/, + "Usernames may only contain letters, numbers, dots, dashes, and underscores.", + ); + +const newPasswordInput = z + .string() + .min(8, "Password must be at least 8 characters.") + .max(200, "Password is too long."); + +export const createUserFormSchema = z.object({ + username: usernameInput, + password: newPasswordInput, + tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]), +}); + +export const updateUserFormSchema = z.object({ + // Blank means "keep the current password". + password: z.preprocess( + (v) => (v === "" || v === null ? null : v), + newPasswordInput.nullable(), + ), + tagIds: z.array(z.coerce.number().int().positive()).max(200).default([]), +}); + export const navItemSchema = z .object({ label: z.string().trim().min(1, "Every navigation item needs a label.").max(80), diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts index bd5e60a..b38421f 100644 --- a/tests/integration/auth.test.ts +++ b/tests/integration/auth.test.ts @@ -46,7 +46,7 @@ describe("sessions", () => { expect(expiresAt.getTime()).toBeGreaterThan(Date.now()); const sessionUser = await validateSessionToken(token); - expect(sessionUser).toEqual({ id: user.id, username: "admin" }); + expect(sessionUser).toEqual({ id: user.id, username: "admin", role: "author" }); // Only a hash of the token is stored. const rows = await db.select().from(sessions); diff --git a/tests/integration/import-export.test.ts b/tests/integration/import-export.test.ts index 9322591..548f0e7 100644 --- a/tests/integration/import-export.test.ts +++ b/tests/integration/import-export.test.ts @@ -170,6 +170,7 @@ describe("export/import round trip", () => { slug: "sneaky", body: '

ok

', authorName: "Mallory", + authorUsername: null, featuredImageUrl: null, featuredImageAlt: null, status: "published", @@ -198,6 +199,7 @@ describe("parseSiteExportJson", () => { slug: "orphan", body: "", authorName: "A", + authorUsername: null, featuredImageUrl: null, featuredImageAlt: null, status: "draft", @@ -228,6 +230,7 @@ describe("parseSiteExportJson", () => { slug: "p", body: "", authorName: "A", + authorUsername: null, featuredImageUrl: null, featuredImageAlt: null, status: "published", diff --git a/tests/integration/users.test.ts b/tests/integration/users.test.ts new file mode 100644 index 0000000..5a441c7 --- /dev/null +++ b/tests/integration/users.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { db } from "@/db"; +import { tags } from "@/db/schema"; +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 { + createUser, + deleteUser, + getAllowedTagIds, + getUserWithTags, + listUsersWithTags, + updateUser, +} from "@/lib/services/users"; +import { resetDb } from "../helpers/db"; + +const postInput = (overrides: Partial = {}): PostInput => ({ + title: `Post ${Math.random().toString(36).slice(2, 8)}`, + slug: "", + body: "body", + authorName: "Someone", + featuredImageUrl: null, + featuredImageAlt: null, + status: "published", + tagIds: [], + newTagNames: [], + ...overrides, +}); + +async function seedTags(...names: string[]) { + return db + .insert(tags) + .values(names.map((name) => ({ name, slug: name.toLowerCase() }))) + .returning(); +} + +async function seedAdmin() { + const [admin] = await db + .insert((await import("@/db/schema")).users) + .values({ username: "boss", passwordHash: "x", role: "admin" }) + .returning(); + return admin; +} + +beforeEach(resetDb); + +describe("account management", () => { + it("creates author accounts with tag grants", async () => { + const [design, writing] = await seedTags("Design", "Writing"); + const user = await createUser({ + username: "casey", + password: "hunter2hunter2", + tagIds: [design.id, writing.id], + }); + expect(user.role).toBe("author"); + expect((await getAllowedTagIds(user.id)).sort()).toEqual( + [design.id, writing.id].sort(), + ); + + const listed = await listUsersWithTags(); + expect(listed.find((u) => u.username === "casey")?.tags.map((t) => t.name).sort()).toEqual( + ["Design", "Writing"], + ); + }); + + it("hashes passwords and updates them only when provided", async () => { + const user = await createUser({ username: "casey", password: "first-password", tagIds: [] }); + 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(row.passwordHash).not.toContain("first-password"); + expect(await verifyPassword(row.passwordHash, "first-password")).toBe(true); + + await updateUser(user.id, { password: null, tagIds: [] }); + const [same] = await db.select().from(users).where(eq(users.id, user.id)); + expect(same.passwordHash).toBe(row.passwordHash); + + await updateUser(user.id, { password: "second-password", tagIds: [] }); + const [changed] = await db.select().from(users).where(eq(users.id, user.id)); + expect(await verifyPassword(changed.passwordHash, "second-password")).toBe(true); + }); + + it("replaces tag grants on update", async () => { + const [design, writing] = await seedTags("Design", "Writing"); + const user = await createUser({ + username: "casey", + password: "hunter2hunter2", + tagIds: [design.id], + }); + await updateUser(user.id, { password: null, tagIds: [writing.id] }); + expect(await getAllowedTagIds(user.id)).toEqual([writing.id]); + }); + + 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); + + const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] }); + const post = await createPost(postInput(), author.id); + expect((await deleteUser(author.id)).ok).toBe(true); + + // Post survives, now unowned. + const kept = await getPostById(post.id); + expect(kept).not.toBeNull(); + expect(kept?.authorId).toBeNull(); + expect(await getUserWithTags(author.id)).toBeNull(); + }); +}); + +describe("post ownership", () => { + it("stamps the creating account and filters listAllPosts by author", async () => { + const admin = await seedAdmin(); + const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] }); + + await createPost(postInput({ title: "Admin post" }), admin.id); + await createPost(postInput({ title: "Casey post" }), author.id); + + const all = await listAllPosts(); + expect(all).toHaveLength(2); + const mine = await listAllPosts({ authorId: author.id }); + expect(mine.map((p) => p.title)).toEqual(["Casey post"]); + }); + + it("round-trips ownership through export/import by username", async () => { + await seedAdmin(); + const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] }); + const post = await createPost(postInput({ title: "Owned" }), author.id); + + const snapshot = await buildSiteExport(); + expect(snapshot.posts.find((p) => p.slug === post.slug)?.authorUsername).toBe("casey"); + + await importSiteExport(snapshot); + const restored = (await listAllPosts({ authorId: author.id })).find( + (p) => p.title === "Owned", + ); + expect(restored).toBeDefined(); + expect(restored?.authorId).toBe(author.id); + }); + + it("leaves posts unowned when the export references an unknown username", async () => { + await seedAdmin(); + const author = await createUser({ username: "casey", password: "hunter2hunter2", tagIds: [] }); + await createPost(postInput({ title: "Orphan-to-be" }), author.id); + + const snapshot = await buildSiteExport(); + await deleteUser(author.id); + await importSiteExport(snapshot); + + const [post] = await listAllPosts(); + expect(post.authorId).toBeNull(); + }); +});