From 94efaad5dd82d7d6c40096c2775f561eb5cdb3ce Mon Sep 17 00:00:00 2001 From: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:40:10 +0200 Subject: [PATCH] Add user management admin page with role, status, and profile editing - New /users page with search, inline editing, role/status changes, and deletion - Model: added updateUserRole, updateUserStatus, deleteUser functions - API: PUT /api/v1/users/[id] now supports role and status fields, added DELETE - Safety: cannot change own role/status or delete own account Co-Authored-By: Claude Opus 4.6 (1M context) --- app/(dashboard)/DashboardLayoutClient.tsx | 3 +- app/(dashboard)/users/UsersClient.tsx | 277 ++++++++++++++++++++++ app/(dashboard)/users/actions.ts | 95 ++++++++ app/(dashboard)/users/page.tsx | 11 + app/api/v1/users/[id]/route.ts | 58 ++++- src/lib/models/user.ts | 24 ++ tests/unit/api-routes/users.test.ts | 10 +- 7 files changed, 471 insertions(+), 7 deletions(-) create mode 100644 app/(dashboard)/users/UsersClient.tsx create mode 100644 app/(dashboard)/users/actions.ts create mode 100644 app/(dashboard)/users/page.tsx diff --git a/app/(dashboard)/DashboardLayoutClient.tsx b/app/(dashboard)/DashboardLayoutClient.tsx index 58adb74b..b521400f 100644 --- a/app/(dashboard)/DashboardLayoutClient.tsx +++ b/app/(dashboard)/DashboardLayoutClient.tsx @@ -7,7 +7,7 @@ import { useTheme } from "next-themes"; import { LayoutDashboard, ArrowLeftRight, Cable, KeyRound, ShieldCheck, ShieldOff, BarChart2, History, Settings, LogOut, Menu, Sun, Moon, - FileJson2, Users, + FileJson2, Users, UserCog, } from "lucide-react"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; @@ -29,6 +29,7 @@ const NAV_ITEMS = [ { href: "/l4-proxy-hosts", label: "L4 Proxy Hosts", icon: Cable }, { href: "/access-lists", label: "Access Lists", icon: KeyRound }, { href: "/groups", label: "Groups", icon: Users }, + { href: "/users", label: "Users", icon: UserCog }, { href: "/certificates", label: "Certificates", icon: ShieldCheck }, { href: "/waf", label: "WAF", icon: ShieldOff }, { href: "/analytics", label: "Analytics", icon: BarChart2 }, diff --git a/app/(dashboard)/users/UsersClient.tsx b/app/(dashboard)/users/UsersClient.tsx new file mode 100644 index 00000000..f201f8af --- /dev/null +++ b/app/(dashboard)/users/UsersClient.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useState } from "react"; +import { UserCog, Trash2, Pencil, Ban, CheckCircle2 } from "lucide-react"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useRouter } from "next/navigation"; +import { + updateUserRoleAction, + updateUserStatusAction, + updateUserInfoAction, + deleteUserAction, +} from "./actions"; + +type UserEntry = { + id: number; + email: string; + name: string | null; + role: "admin" | "user" | "viewer"; + provider: string; + subject: string; + avatar_url: string | null; + status: string; + created_at: string; + updated_at: string; +}; + +type Props = { + users: UserEntry[]; +}; + +const ROLE_COLORS: Record = { + admin: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/30", + user: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30", + viewer: "bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/30", +}; + +const STATUS_COLORS: Record = { + active: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/30", + disabled: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/30", +}; + +export default function UsersClient({ users }: Props) { + const router = useRouter(); + const [editUserId, setEditUserId] = useState(null); + const [search, setSearch] = useState(""); + + const filtered = search + ? users.filter( + (u) => + u.name?.toLowerCase().includes(search.toLowerCase()) || + u.email.toLowerCase().includes(search.toLowerCase()) || + u.role.includes(search.toLowerCase()) + ) + : users; + + return ( +
+ + +
+ setSearch(e.target.value)} + className="max-w-xs" + /> + + {filtered.length} user{filtered.length !== 1 ? "s" : ""} + +
+ + {filtered.length === 0 && ( + + + +

No users found.

+
+
+ )} + +
+ {filtered.map((user) => ( + + + {editUserId === user.id ? ( + setEditUserId(null)} + onSave={() => { + setEditUserId(null); + router.refresh(); + }} + /> + ) : ( + setEditUserId(user.id)} + onRefresh={() => router.refresh()} + /> + )} + + + ))} +
+
+ ); +} + +function UserRow({ + user, + onEdit, + onRefresh, +}: { + user: UserEntry; + onEdit: () => void; + onRefresh: () => void; +}) { + const isDisabled = user.status !== "active"; + + return ( +
+
+ {(user.name ?? user.email)[0]?.toUpperCase()} +
+
+
+ + {user.name ?? user.email.split("@")[0]} + + {isDisabled && ( + + disabled + + )} +
+
+ {user.email} + ยท + {user.provider} +
+
+ + {user.role} + +
+ {user.status === "active" ? ( + + ) : ( + + )} + + +
+
+ ); +} + +function EditUserRow({ + user, + onClose, + onSave, +}: { + user: UserEntry; + onClose: () => void; + onSave: () => void; +}) { + const [role, setRole] = useState(user.role); + + return ( +
+
+ + Editing {user.name ?? user.email} +
+
{ + await updateUserInfoAction(user.id, formData); + if (role !== user.role) { + await updateUserRoleAction(user.id, role); + } + onSave(); + }} + className="grid grid-cols-1 sm:grid-cols-3 gap-3" + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/app/(dashboard)/users/actions.ts b/app/(dashboard)/users/actions.ts new file mode 100644 index 00000000..2f3bc040 --- /dev/null +++ b/app/(dashboard)/users/actions.ts @@ -0,0 +1,95 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireAdmin } from "@/src/lib/auth"; +import { + updateUserProfile, + updateUserRole, + updateUserStatus, + deleteUser, + type User, +} from "@/src/lib/models/user"; +import { logAuditEvent } from "@/src/lib/audit"; + +export async function updateUserRoleAction(userId: number, role: User["role"]) { + const session = await requireAdmin(); + const actorId = Number(session.user.id); + + if (actorId === userId) { + throw new Error("Cannot change your own role"); + } + + await updateUserRole(userId, role); + + logAuditEvent({ + userId: actorId, + action: "update", + entityType: "user", + entityId: userId, + summary: `Changed user ${userId} role to ${role}`, + }); + + revalidatePath("/users"); +} + +export async function updateUserStatusAction(userId: number, status: string) { + const session = await requireAdmin(); + const actorId = Number(session.user.id); + + if (actorId === userId) { + throw new Error("Cannot change your own status"); + } + + await updateUserStatus(userId, status); + + logAuditEvent({ + userId: actorId, + action: "update", + entityType: "user", + entityId: userId, + summary: `Changed user ${userId} status to ${status}`, + }); + + revalidatePath("/users"); +} + +export async function updateUserInfoAction(userId: number, formData: FormData) { + const session = await requireAdmin(); + const actorId = Number(session.user.id); + + const name = formData.get("name") ? String(formData.get("name")).trim() : undefined; + const email = formData.get("email") ? String(formData.get("email")).trim() : undefined; + + await updateUserProfile(userId, { name, email }); + + logAuditEvent({ + userId: actorId, + action: "update", + entityType: "user", + entityId: userId, + summary: `Updated user ${userId} profile`, + }); + + revalidatePath("/users"); +} + +export async function deleteUserAction(userId: number) { + const session = await requireAdmin(); + const actorId = Number(session.user.id); + + if (actorId === userId) { + throw new Error("Cannot delete your own account"); + } + + await deleteUser(userId); + + logAuditEvent({ + userId: actorId, + action: "delete", + entityType: "user", + entityId: userId, + summary: `Deleted user ${userId}`, + }); + + revalidatePath("/users"); +} diff --git a/app/(dashboard)/users/page.tsx b/app/(dashboard)/users/page.tsx new file mode 100644 index 00000000..888ce615 --- /dev/null +++ b/app/(dashboard)/users/page.tsx @@ -0,0 +1,11 @@ +import UsersClient from "./UsersClient"; +import { listUsers } from "@/src/lib/models/user"; +import { requireAdmin } from "@/src/lib/auth"; + +export default async function UsersPage() { + await requireAdmin(); + const allUsers = await listUsers(); + // Strip password hashes before sending to client + const safeUsers = allUsers.map(({ password_hash, ...rest }) => rest); + return ; +} diff --git a/app/api/v1/users/[id]/route.ts b/app/api/v1/users/[id]/route.ts index b42a5151..43d4951e 100644 --- a/app/api/v1/users/[id]/route.ts +++ b/app/api/v1/users/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { requireApiUser, requireApiAdmin, apiErrorResponse, ApiAuthError } from "@/src/lib/api-auth"; -import { getUserById, updateUserProfile } from "@/src/lib/models/user"; +import { getUserById, updateUserProfile, updateUserRole, updateUserStatus, deleteUser } from "@/src/lib/models/user"; function stripPasswordHash(user: Record) { const { password_hash: _, ...rest } = user; @@ -37,10 +37,37 @@ export async function PUT( { params }: { params: Promise<{ id: string }> } ) { try { - await requireApiAdmin(request); + const auth = await requireApiAdmin(request); const { id } = await params; + const targetId = Number(id); const body = await request.json(); - const user = await updateUserProfile(Number(id), body); + + // Handle role change + if (body.role && ["admin", "user", "viewer"].includes(body.role)) { + if (auth.userId === targetId) { + return NextResponse.json({ error: "Cannot change your own role" }, { status: 400 }); + } + await updateUserRole(targetId, body.role); + } + + // Handle status change + if (body.status && ["active", "disabled"].includes(body.status)) { + if (auth.userId === targetId) { + return NextResponse.json({ error: "Cannot change your own status" }, { status: 400 }); + } + await updateUserStatus(targetId, body.status); + } + + // Handle profile update + const profileFields: Record = {}; + if (body.email !== undefined) profileFields.email = body.email; + if (body.name !== undefined) profileFields.name = body.name; + if (body.avatar_url !== undefined) profileFields.avatar_url = body.avatar_url; + if (Object.keys(profileFields).length > 0) { + await updateUserProfile(targetId, profileFields as { email?: string; name?: string | null; avatar_url?: string | null }); + } + + const user = await getUserById(targetId); if (!user) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } @@ -49,3 +76,28 @@ export async function PUT( return apiErrorResponse(error); } } + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const auth = await requireApiAdmin(request); + const { id } = await params; + const targetId = Number(id); + + if (auth.userId === targetId) { + return NextResponse.json({ error: "Cannot delete your own account" }, { status: 400 }); + } + + const user = await getUserById(targetId); + if (!user) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + await deleteUser(targetId); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/src/lib/models/user.ts b/src/lib/models/user.ts index 041a1261..12616e0f 100644 --- a/src/lib/models/user.ts +++ b/src/lib/models/user.ts @@ -142,3 +142,27 @@ export async function promoteToAdmin(userId: number): Promise { }) .where(eq(users.id, userId)); } + +export async function updateUserRole(userId: number, role: User["role"]): Promise { + const now = nowIso(); + const [updated] = await db + .update(users) + .set({ role, updatedAt: now }) + .where(eq(users.id, userId)) + .returning(); + return updated ? parseDbUser(updated) : null; +} + +export async function updateUserStatus(userId: number, status: string): Promise { + const now = nowIso(); + const [updated] = await db + .update(users) + .set({ status, updatedAt: now }) + .where(eq(users.id, userId)) + .returning(); + return updated ? parseDbUser(updated) : null; +} + +export async function deleteUser(userId: number): Promise { + await db.delete(users).where(eq(users.id, userId)); +} diff --git a/tests/unit/api-routes/users.test.ts b/tests/unit/api-routes/users.test.ts index 6e0e3c9a..fd0be9ac 100644 --- a/tests/unit/api-routes/users.test.ts +++ b/tests/unit/api-routes/users.test.ts @@ -4,6 +4,9 @@ vi.mock('@/src/lib/models/user', () => ({ listUsers: vi.fn(), getUserById: vi.fn(), updateUserProfile: vi.fn(), + updateUserRole: vi.fn(), + updateUserStatus: vi.fn(), + deleteUser: vi.fn(), })); vi.mock('@/src/lib/api-auth', () => { @@ -130,10 +133,11 @@ describe('GET /api/v1/users/[id]', () => { }); describe('PUT /api/v1/users/[id]', () => { - it('updates a user', async () => { + it('updates a user profile', async () => { const body = { name: 'Updated Name' }; const updated = { ...sampleUser, name: 'Updated Name' }; mockUpdateUserProfile.mockResolvedValue(updated as any); + mockGetUserById.mockResolvedValue(updated as any); const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ id: '1' }) }); const data = await response.json(); @@ -141,11 +145,11 @@ describe('PUT /api/v1/users/[id]', () => { expect(response.status).toBe(200); expect(data.name).toBe('Updated Name'); expect(data).not.toHaveProperty('password_hash'); - expect(mockUpdateUserProfile).toHaveBeenCalledWith(1, body); + expect(mockUpdateUserProfile).toHaveBeenCalledWith(1, { name: 'Updated Name' }); }); it('returns 404 when updating non-existent user', async () => { - mockUpdateUserProfile.mockResolvedValue(null as any); + mockGetUserById.mockResolvedValue(null as any); const response = await PUT(createMockRequest({ method: 'PUT', body: { name: 'X' } }), { params: Promise.resolve({ id: '999' }) }); const data = await response.json();