Each author account now carries five grantable permissions, editable on the Users page: publish posts, unpublish posts, delete posts (all three scoped to the author's own posts), create tags, and approve comments (scoped to comments on the author's posts, without delete). A bare author writes and edits their own drafts only. Permission checks gate the status TRANSITION, so editing an already-published post never requires the publish permission, and the editor's status dropdown only offers what the account may do. Tags an author creates are granted to them automatically, and "creating" an existing off-grant tag is refused (it would be a self-grant loophole). Existing author accounts keep publish+unpublish via migration backfill. Tags the admin attaches outside an author's grants now survive the author's edits: the form shows them checked-and-locked and the server re-attaches them on every save. Every account can change its own password on the new /admin/account page (current password required); the username in the admin header links there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
202 lines
6.8 KiB
TypeScript
202 lines
6.8 KiB
TypeScript
import { z } from "zod";
|
|
import { fontEnum, themeEnum } from "@/db/schema";
|
|
|
|
/** http(s) URL or a site-relative path starting with a single "/". */
|
|
export function isValidLinkUrl(value: string): boolean {
|
|
if (value.startsWith("/")) return !value.startsWith("//");
|
|
try {
|
|
const url = new URL(value);
|
|
return url.protocol === "http:" || url.protocol === "https:";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const slugInput = z
|
|
.string()
|
|
.trim()
|
|
.max(120, "Slug must be at most 120 characters.")
|
|
.default("");
|
|
|
|
const statusInput = z.enum(["draft", "published"], {
|
|
message: "Status must be draft or published.",
|
|
});
|
|
|
|
export const postFormSchema = z.object({
|
|
title: z.string().trim().min(1, "Title is required.").max(200, "Title is too long."),
|
|
slug: slugInput,
|
|
authorName: z
|
|
.string()
|
|
.trim()
|
|
.min(1, "Author name is required.")
|
|
.max(120, "Author name is too long."),
|
|
body: z.string().max(500_000, "Body is too long."),
|
|
featuredImageUrl: z
|
|
.string()
|
|
.trim()
|
|
.max(2000, "Image URL is too long.")
|
|
.default("")
|
|
.refine((v) => v === "" || isValidLinkUrl(v), {
|
|
message: "Enter an http(s) URL or a site-relative path.",
|
|
}),
|
|
featuredImageAlt: z.string().trim().max(300, "Alt text is too long.").default(""),
|
|
status: statusInput,
|
|
tagIds: z.array(z.coerce.number().int().positive()).max(50).default([]),
|
|
newTags: z.string().trim().max(500, "New tag list is too long.").default(""),
|
|
});
|
|
export type PostFormData = z.infer<typeof postFormSchema>;
|
|
|
|
export const pageFormSchema = z.object({
|
|
title: z.string().trim().min(1, "Title is required.").max(200, "Title is too long."),
|
|
slug: slugInput,
|
|
body: z.string().max(500_000, "Body is too long."),
|
|
status: statusInput,
|
|
});
|
|
export type PageFormData = z.infer<typeof pageFormSchema>;
|
|
|
|
export const commentFormSchema = z.object({
|
|
postId: z.coerce.number().int().positive(),
|
|
parentId: z.preprocess(
|
|
(v) => (v === "" || v === null || v === undefined ? null : v),
|
|
z.coerce.number().int().positive().nullable(),
|
|
),
|
|
authorName: z.string().trim().min(1, "Name is required.").max(120, "Name is too long."),
|
|
authorEmail: z
|
|
.string()
|
|
.trim()
|
|
.min(1, "Email is required.")
|
|
.max(254, "Email is too long.")
|
|
.pipe(z.email("Enter a valid email address.")),
|
|
// Checkbox: present ("on") when ticked, absent otherwise.
|
|
emailPublic: z.preprocess((v) => v === "on" || v === "true" || v === true, z.boolean()),
|
|
body: z
|
|
.string()
|
|
.trim()
|
|
.min(1, "Comment cannot be empty.")
|
|
.max(5000, "Comments are limited to 5000 characters."),
|
|
});
|
|
export type CommentFormData = z.infer<typeof commentFormSchema>;
|
|
|
|
export const loginFormSchema = z.object({
|
|
username: z.string().trim().min(1, "Username is required.").max(120),
|
|
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.");
|
|
|
|
/** 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({
|
|
// 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([]),
|
|
permissions: permissionsInput,
|
|
});
|
|
|
|
export const changePasswordFormSchema = z.object({
|
|
currentPassword: z.string().min(1, "Enter your current password.").max(200),
|
|
newPassword: newPasswordInput,
|
|
});
|
|
|
|
export const navItemSchema = z
|
|
.object({
|
|
label: z.string().trim().min(1, "Every navigation item needs a label.").max(80),
|
|
url: z.string().trim().max(2000).nullable(),
|
|
pageId: z.number().int().positive().nullable(),
|
|
})
|
|
.superRefine((item, ctx) => {
|
|
const hasUrl = item.url !== null && item.url !== "";
|
|
const hasPage = item.pageId !== null;
|
|
if (hasUrl === hasPage) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: `“${item.label}” must link to either a page or a URL.`,
|
|
});
|
|
} else if (hasUrl && !isValidLinkUrl(item.url as string)) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: `“${item.label}” needs an http(s) URL or a path starting with “/”.`,
|
|
});
|
|
}
|
|
});
|
|
|
|
const optionalId = z.preprocess(
|
|
(v) => (v === "" || v === null || v === undefined ? undefined : v),
|
|
z.coerce.number().int().positive().optional(),
|
|
);
|
|
|
|
export const settingsFormSchema = z.object({
|
|
siteTitle: z.string().trim().min(1, "Site title is required.").max(120),
|
|
headerText: z.string().trim().max(300, "Header text is too long.").default(""),
|
|
footerText: z.string().trim().max(500, "Footer text is too long.").default(""),
|
|
postsPerPage: z.coerce
|
|
.number({ message: "Posts per page must be a number." })
|
|
.int("Posts per page must be a whole number.")
|
|
.min(1, "Posts per page must be at least 1.")
|
|
.max(50, "Posts per page must be at most 50."),
|
|
excerptWords: z.coerce
|
|
.number({ message: "Excerpt word limit must be a number." })
|
|
.int("Excerpt word limit must be a whole number.")
|
|
.min(5, "Excerpt word limit must be at least 5.")
|
|
.max(200, "Excerpt word limit must be at most 200."),
|
|
homeMode: z.enum(["posts", "tag", "page"]),
|
|
homeTagId: optionalId,
|
|
homePageId: optionalId,
|
|
theme: z.enum(themeEnum.enumValues, {
|
|
message: "Choose one of the available themes.",
|
|
}),
|
|
font: z.enum(fontEnum.enumValues, {
|
|
message: "Choose one of the available fonts.",
|
|
}),
|
|
navItemsJson: z.string().max(50_000).default("[]"),
|
|
});
|
|
export type SettingsFormData = z.infer<typeof settingsFormSchema>;
|
|
|
|
export function parseNavItemsJson(
|
|
json: string,
|
|
): { items: Array<z.infer<typeof navItemSchema>> } | { error: string } {
|
|
let raw: unknown;
|
|
try {
|
|
raw = JSON.parse(json);
|
|
} catch {
|
|
return { error: "Navigation items could not be read. Reload and try again." };
|
|
}
|
|
const parsed = z.array(navItemSchema).max(20, "At most 20 navigation items.").safeParse(raw);
|
|
if (!parsed.success) {
|
|
return { error: parsed.error.issues[0]?.message ?? "Navigation items are invalid." };
|
|
}
|
|
return { items: parsed.data };
|
|
}
|