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>
119 lines
3.8 KiB
TypeScript
119 lines
3.8 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { z } from "zod";
|
|
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 {
|
|
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,
|
|
formData: FormData,
|
|
): Promise<FormState> {
|
|
await requireAdmin();
|
|
const parsed = createUserFormSchema.safeParse({
|
|
username: formData.get("username"),
|
|
password: formData.get("password"),
|
|
tagIds: formData.getAll("tagIds"),
|
|
permissions: readPermissions(formData),
|
|
});
|
|
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<FormState> {
|
|
await requireAdmin();
|
|
const userId = z.number().int().positive().parse(id);
|
|
const parsed = updateUserFormSchema.safeParse({
|
|
password: formData.get("password"),
|
|
tagIds: formData.getAll("tagIds"),
|
|
permissions: readPermissions(formData),
|
|
});
|
|
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" };
|
|
}
|
|
|
|
/** Any signed-in account may change its own password. */
|
|
export async function changeOwnPasswordAction(
|
|
_prev: FormState,
|
|
formData: FormData,
|
|
): Promise<FormState> {
|
|
const user = await requireUser();
|
|
const parsed = changePasswordFormSchema.safeParse({
|
|
currentPassword: formData.get("currentPassword"),
|
|
newPassword: formData.get("newPassword"),
|
|
});
|
|
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
|
|
|
try {
|
|
const result = await changeOwnPassword(
|
|
user.id,
|
|
parsed.data.currentPassword,
|
|
parsed.data.newPassword,
|
|
);
|
|
if (!result.ok) return { fieldErrors: { currentPassword: [result.error] } };
|
|
} catch (error) {
|
|
console.error("changeOwnPasswordAction failed", error);
|
|
return { formError: "Something went wrong while changing your password." };
|
|
}
|
|
return { status: "success" };
|
|
}
|
|
|
|
export async function deleteUserAction(id: number): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const userId = z.number().int().positive().parse(id);
|
|
// 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");
|
|
}
|