The seeded account is the single admin; it can create author accounts on the new /admin/users page (username + password) and grant each one access to specific tags. Authors sign in to a Posts-only panel where they can write, edit, publish, and unpublish their own posts — every post must carry at least one granted tag, tags outside the grants are rejected server-side, and only the admin can create tags or delete posts (or anything else: pages, comments, settings, and backups stay admin-only). Admin-only URLs bounce authors to their post list, and foreign post editors 404. posts.author_id records ownership; deleting an account keeps its posts as unowned, admin-managed rows and signs the account out everywhere. Backups (export v3) store the owner's username per post and re-attach ownership on import when the account still exists. Also fixes a latent form bug: a missing newTags field (author forms don't render it) failed zod validation with an invisible error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
106 lines
4.5 KiB
TypeScript
106 lines
4.5 KiB
TypeScript
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<Record<string, string | string[] | undefined>>;
|
|
}) {
|
|
await requireAdmin();
|
|
const [sp, users] = await Promise.all([searchParams, listUsersWithTags()]);
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
|
<h1 className="text-2xl font-bold tracking-tight text-ink-bright">Users</h1>
|
|
<LinkButton href="/admin/users/new">New account</LinkButton>
|
|
</div>
|
|
|
|
{sp.created === "1" && <Flash>Account created.</Flash>}
|
|
{sp.deleted === "1" && <Flash>Account deleted.</Flash>}
|
|
|
|
<div className="overflow-x-auto rounded-lg border border-edge bg-surface">
|
|
<table className="w-full min-w-[38rem] border-collapse text-sm">
|
|
<thead>
|
|
<tr className="border-b border-edge text-left text-xs uppercase tracking-wider text-ink-muted">
|
|
<th scope="col" className="px-4 py-3 font-medium">Username</th>
|
|
<th scope="col" className="px-4 py-3 font-medium">Role</th>
|
|
<th scope="col" className="px-4 py-3 font-medium">Tag access</th>
|
|
<th scope="col" className="px-4 py-3 font-medium">Created</th>
|
|
<th scope="col" className="px-4 py-3 text-right font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-edge">
|
|
{users.map((user) => (
|
|
<tr key={user.id}>
|
|
<td className="px-4 py-3">
|
|
<Link
|
|
href={`/admin/users/${user.id}/edit`}
|
|
className="font-medium text-ink-strong transition-colors hover:text-link"
|
|
>
|
|
{user.username}
|
|
</Link>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{user.role === "admin" ? (
|
|
<span className="inline-flex items-center rounded-full border border-link/40 bg-link/10 px-2 py-0.5 text-xs font-medium text-link">
|
|
Admin
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center rounded-full border border-edge-strong px-2 py-0.5 text-xs font-medium text-ink-muted">
|
|
Author
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-ink-muted">
|
|
{user.role === "admin"
|
|
? "All tags"
|
|
: user.tags.length === 0
|
|
? "None yet"
|
|
: user.tags.map((t) => t.name).join(", ")}
|
|
</td>
|
|
<td className="whitespace-nowrap px-4 py-3 text-ink-muted">
|
|
{formatDate(user.createdAt)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Link
|
|
href={`/admin/users/${user.id}/edit`}
|
|
className="rounded-md px-2 py-1 text-xs font-medium text-ink-muted transition-colors hover:bg-background hover:text-ink-strong"
|
|
>
|
|
Edit
|
|
</Link>
|
|
{user.role !== "admin" && (
|
|
<form action={deleteUserAction.bind(null, user.id)}>
|
|
<ConfirmButton
|
|
confirmMessage={`Delete the account “${user.username}”? Their posts are kept and become admin-managed. This cannot be undone.`}
|
|
className="border-none px-2 py-1 text-xs"
|
|
>
|
|
Delete
|
|
</ConfirmButton>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p className="mt-3 text-sm text-ink-muted">
|
|
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.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|