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>
174 lines
6.1 KiB
TypeScript
174 lines
6.1 KiB
TypeScript
import { asc, eq, inArray } from "drizzle-orm";
|
|
import { db } from "@/db";
|
|
import { type Tag, type User, tags, userTags, users } from "@/db/schema";
|
|
import { hashPassword, verifyPassword } from "@/lib/auth/password";
|
|
|
|
/** User row without the password hash — safe to hand to pages. */
|
|
export type SafeUser = Omit<User, "passwordHash">;
|
|
export type UserWithTags = SafeUser & { tags: Tag[] };
|
|
|
|
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<UserWithTags[]> {
|
|
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<number, Tag[]>();
|
|
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<UserWithTags | null> {
|
|
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<number[]> {
|
|
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<void> {
|
|
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[];
|
|
/** Defaults to no permissions (write/edit own drafts only). */
|
|
permissions?: PermissionColumns;
|
|
}): Promise<SafeUser> {
|
|
const passwordHash = await hashPassword(input.password);
|
|
const [user] = await db
|
|
.insert(users)
|
|
.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, 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[]; permissions?: PermissionColumns },
|
|
): Promise<SafeUser | null> {
|
|
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));
|
|
}
|
|
// 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<void> {
|
|
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);
|
|
* 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 };
|
|
}
|