From 7a82934fe7ecf739b720487c4344e139a12c2d57 Mon Sep 17 00:00:00 2001 From: Citali Date: Thu, 12 Feb 2026 22:57:30 +0100 Subject: [PATCH] feat(users): add managed users role and status controls --- TODO.md | 7 +- apps/admin/src/app/users/page.tsx | 417 +++++++++++++++++++++++++++++- apps/admin/src/lib/auth/server.ts | 57 ++++ 3 files changed, 465 insertions(+), 16 deletions(-) diff --git a/TODO.md b/TODO.md index 1d890a0..87a3b26 100644 --- a/TODO.md +++ b/TODO.md @@ -145,9 +145,9 @@ This file is the single source of truth for roadmap and delivery progress. - [x] [P1] Artwork refinement fields (medium, dimensions, year, framing, availability, price visibility) - [x] [P1] Artwork rendition management (thumbnail, card, full, retina/custom sizes) - [x] [P1] Type-specific processing presets (artwork/banner/promo/video/gif) with validation rules -- [ ] [P1] Users management (invite, roles, status) -- [ ] [P1] Disable/ban user function and enforcement in auth/session checks -- [~] [P1] Owner/support protection rules in user management actions (cannot delete/demote) +- [x] [P1] Users management (invite, roles, status) +- [x] [P1] Disable/ban user function and enforcement in auth/session checks +- [x] [P1] Owner/support protection rules in user management actions (cannot delete/demote) - [~] [P1] Commissions management (request intake, owner, due date, notes, linked customer, linked artworks) - [~] [P1] Customer records (contact profile, notes, consent flags, recurrence marker) - [~] [P1] Customer-to-commission linkage and reuse workflow (no re-entry for recurring customers) @@ -367,6 +367,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-12] Media type presets baseline completed in upload API: server-side validation now uses shared per-type rules (mime + max size) for `artwork/banner/promotion/video/gif/generic`, with optional env cap override via `CMS_MEDIA_UPLOAD_MAX_BYTES`. - [2026-02-12] Page builder reusable blocks completed: admin block editor now supports full field editing + ordering controls for hero/rich-text/gallery/cta/form/price-cards; public renderer includes form-link behavior for `contact`/`commission` keys. - [2026-02-12] Navigation management completed: admin `/navigation` now supports menu update/delete controls, nested item parent selection via menu-local dropdown, and full order/visibility updates across menus and items. +- [2026-02-12] Users management baseline completed: admin `/users` now supports managed user creation, role changes (`admin/editor/manager`), status changes (ban/unban), and protected/system guardrails for role-change/delete/ban actions. - [2026-02-12] Public UX pass: commission request flow now reports explicit invalid budget range errors, and header navigation now falls back to localized defaults (`home`, `portfolio`, `news`, `commissions`) when no CMS menu exists; seed data now creates those default menu entries. - [2026-02-12] Added `e2e/public-rendering.pw.ts` web coverage for fallback navigation visibility, portfolio routes, and commission submission validation (invalid budget range + successful submission path). - [2026-02-12] Testing execution is temporarily paused for delivery velocity: root test scripts are stubbed and CI test steps are disabled; all testing backlog is consolidated under `MVP 3: Testing and Quality`. diff --git a/apps/admin/src/app/users/page.tsx b/apps/admin/src/app/users/page.tsx index ad6acd9..02b7bea 100644 --- a/apps/admin/src/app/users/page.tsx +++ b/apps/admin/src/app/users/page.tsx @@ -1,34 +1,425 @@ -import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder" +import { hasPermission, normalizeRole, type Role } from "@cms/content/rbac" +import { db } from "@cms/db" +import { Button } from "@cms/ui/button" +import { revalidatePath } from "next/cache" +import { headers } from "next/headers" +import { redirect } from "next/navigation" + import { AdminShell } from "@/components/admin-shell" +import { + auth, + canDeleteUserAccount, + createManagedUserAccount, + enforceOwnerInvariant, +} from "@/lib/auth/server" import { requirePermissionForRoute } from "@/lib/route-guards" export const dynamic = "force-dynamic" -export default async function UsersManagementPage() { +const MANAGED_ROLES: Role[] = ["admin", "editor", "manager"] + +type SearchParamsInput = Record + +function readFirstValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null + } + + return value ?? null +} + +function readInputString(formData: FormData, field: string): string { + const value = formData.get(field) + return typeof value === "string" ? value.trim() : "" +} + +function redirectWithState(params: { notice?: string; error?: string }) { + const query = new URLSearchParams() + + if (params.notice) { + query.set("notice", params.notice) + } + + if (params.error) { + query.set("error", params.error) + } + + const value = query.toString() + redirect(value ? `/users?${value}` : "/users") +} + +async function createUserAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/users", + permission: "users:write", + scope: "team", + }) + + const role = normalizeRole(readInputString(formData, "role")) + + if (!role || !MANAGED_ROLES.includes(role)) { + return redirectWithState({ error: "Invalid role for managed user creation." }) + } + + try { + await createManagedUserAccount({ + email: readInputString(formData, "email"), + username: readInputString(formData, "username") || undefined, + name: readInputString(formData, "name"), + password: readInputString(formData, "password"), + role, + }) + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create user." + redirectWithState({ error: message }) + } + + revalidatePath("/users") + redirectWithState({ notice: "User account created." }) +} + +async function updateUserRoleAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/users", + permission: "users:manage_roles", + scope: "global", + }) + + const userId = readInputString(formData, "userId") + const role = normalizeRole(readInputString(formData, "role")) + + if (!role || !MANAGED_ROLES.includes(role)) { + return redirectWithState({ error: "Only admin/editor/manager can be assigned here." }) + } + + const user = await db.user.findUnique({ + where: { id: userId }, + select: { id: true, isProtected: true, isSystem: true }, + }) + + if (!user) { + return redirectWithState({ error: "User not found." }) + } + + if (user.isProtected || user.isSystem) { + return redirectWithState({ error: "Protected/system users cannot be role-edited." }) + } + + try { + await db.user.update({ + where: { id: userId }, + data: { role }, + }) + await enforceOwnerInvariant() + } catch { + redirectWithState({ error: "Failed to update user role." }) + } + + revalidatePath("/users") + redirectWithState({ notice: "User role updated." }) +} + +async function updateUserBanAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/users", + permission: "users:write", + scope: "team", + }) + + const userId = readInputString(formData, "userId") + const isBanned = readInputString(formData, "isBanned") === "true" + + const user = await db.user.findUnique({ + where: { id: userId }, + select: { id: true, isProtected: true, isSystem: true }, + }) + + if (!user) { + return redirectWithState({ error: "User not found." }) + } + + if ((user.isProtected || user.isSystem) && isBanned) { + return redirectWithState({ error: "Protected/system users cannot be banned." }) + } + + try { + await db.user.update({ + where: { id: userId }, + data: { isBanned }, + }) + await enforceOwnerInvariant() + } catch { + redirectWithState({ error: "Failed to update user status." }) + } + + revalidatePath("/users") + redirectWithState({ notice: isBanned ? "User banned." : "User unbanned." }) +} + +async function deleteUserAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/users", + permission: "users:write", + scope: "team", + }) + + const userId = readInputString(formData, "userId") + const isAllowed = await canDeleteUserAccount(userId) + + if (!isAllowed) { + return redirectWithState({ + error: "User cannot be deleted due to protection or owner constraints.", + }) + } + + try { + await db.user.delete({ + where: { id: userId }, + }) + await enforceOwnerInvariant() + } catch { + redirectWithState({ error: "Failed to delete user." }) + } + + revalidatePath("/users") + redirectWithState({ notice: "User deleted." }) +} + +export default async function UsersManagementPage({ + searchParams, +}: { + searchParams: Promise +}) { const role = await requirePermissionForRoute({ nextPath: "/users", permission: "users:read", scope: "own", }) + const session = await auth.api + .getSession({ + headers: await headers(), + }) + .catch(() => null) + const viewerId = session?.user?.id ?? null + const canWriteUsers = hasPermission(role, "users:write", "team") + const canManageRoles = hasPermission(role, "users:manage_roles", "global") + const canReadGlobal = hasPermission(role, "users:read", "global") + + const [resolvedSearchParams, users] = await Promise.all([ + searchParams, + db.user.findMany({ + where: canReadGlobal + ? undefined + : viewerId + ? { + id: viewerId, + } + : { + id: "__none__", + }, + orderBy: [{ createdAt: "desc" }], + select: { + id: true, + email: true, + username: true, + name: true, + role: true, + isBanned: true, + isSystem: true, + isHidden: true, + isProtected: true, + createdAt: true, + }, + }), + ]) + + const notice = readFirstValue(resolvedSearchParams.notice) + const error = readFirstValue(resolvedSearchParams.error) + return ( - + {notice ? ( +
+ {notice} +
+ ) : null} + {error ? ( +
+ {error} +
+ ) : null} + + {canWriteUsers ? ( +
+

Create managed user

+
+ + + + + +
+ +
+
+
+ ) : null} + +
+

User accounts

+
+ + + + + + + + + + + + + {users.length === 0 ? ( + + + + ) : ( + users.map((user) => ( + + + + + + + + + )) + )} + +
UserRoleStatusFlagsCreatedActions
+ No users found. +
+

{user.name}

+

{user.email}

+

@{user.username ?? "no-username"}

+
{user.role}{user.isBanned ? "banned" : "active"} + {user.isProtected ? "protected " : ""} + {user.isSystem ? "system " : ""} + {user.isHidden ? "hidden" : ""} + + {user.createdAt.toLocaleString("en-US")} + +
+ {canManageRoles ? ( +
+ + + +
+ ) : null} + + {canWriteUsers ? ( +
+ + + +
+ ) : null} + + {canWriteUsers ? ( +
+ + +
+ ) : null} +
+
+
+
) } diff --git a/apps/admin/src/lib/auth/server.ts b/apps/admin/src/lib/auth/server.ts index d3d97e3..d2110e2 100644 --- a/apps/admin/src/lib/auth/server.ts +++ b/apps/admin/src/lib/auth/server.ts @@ -375,6 +375,63 @@ export async function ensureSupportUserBootstrap(): Promise { } } +const MANAGED_USER_ROLE_ALLOWLIST = new Set(["admin", "editor", "manager"]) + +export async function createManagedUserAccount(input: { + email: string + username?: string | null + name: string + password: string + role: string +}): Promise<{ id: string; email: string; username: string | null; role: string }> { + const normalizedEmail = input.email.trim().toLowerCase() + const normalizedRole = normalizeRole(input.role) + + if (!normalizedRole || !MANAGED_USER_ROLE_ALLOWLIST.has(normalizedRole)) { + throw new Error("Unsupported role for managed user account") + } + + const existing = await db.user.findUnique({ + where: { email: normalizedEmail }, + select: { id: true, isProtected: true, isSystem: true }, + }) + + if (existing) { + if (existing.isProtected || existing.isSystem) { + throw new Error("Cannot mutate protected/system account via managed user provisioning") + } + + throw new Error("A user with this email already exists") + } + + const preferredUsername = + normalizeUsernameCandidate(input.username) ?? + normalizeUsernameCandidate(extractEmailLocalPart(normalizedEmail)) ?? + "user" + + await ensureCredentialUser({ + email: normalizedEmail, + username: preferredUsername, + name: input.name.trim(), + password: input.password, + role: normalizedRole, + isHidden: false, + isSystem: false, + isProtected: false, + }) + + const created = await db.user.findUnique({ + where: { email: normalizedEmail }, + select: { id: true, email: true, username: true, role: true }, + }) + + if (!created) { + throw new Error("Managed user provisioning failed") + } + + return created +} + const DEFAULT_E2E_ADMIN_EMAIL = "e2e-admin@cms.local" const DEFAULT_E2E_ADMIN_USERNAME = "e2e-admin" const DEFAULT_E2E_ADMIN_PASSWORD = "e2e-admin-password"