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) <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-04-05 22:40:10 +02:00
parent 708b908679
commit 94efaad5dd
7 changed files with 471 additions and 7 deletions

View File

@@ -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 },

View File

@@ -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<string, string> = {
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<string, string> = {
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<number | null>(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 (
<div className="flex flex-col gap-6 w-full">
<PageHeader
title="Users"
description="Manage user accounts, roles, and access."
/>
<div className="flex items-center gap-3">
<Input
placeholder="Search users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-xs"
/>
<span className="text-sm text-muted-foreground ml-auto">
{filtered.length} user{filtered.length !== 1 ? "s" : ""}
</span>
</div>
{filtered.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<UserCog className="h-10 w-10 mx-auto mb-3 opacity-40" />
<p>No users found.</p>
</CardContent>
</Card>
)}
<div className="grid gap-3">
{filtered.map((user) => (
<Card key={user.id}>
<CardContent className="py-3 px-4">
{editUserId === user.id ? (
<EditUserRow
user={user}
onClose={() => setEditUserId(null)}
onSave={() => {
setEditUserId(null);
router.refresh();
}}
/>
) : (
<UserRow
user={user}
onEdit={() => setEditUserId(user.id)}
onRefresh={() => router.refresh()}
/>
)}
</CardContent>
</Card>
))}
</div>
</div>
);
}
function UserRow({
user,
onEdit,
onRefresh,
}: {
user: UserEntry;
onEdit: () => void;
onRefresh: () => void;
}) {
const isDisabled = user.status !== "active";
return (
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-muted flex items-center justify-center text-sm font-medium shrink-0">
{(user.name ?? user.email)[0]?.toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{user.name ?? user.email.split("@")[0]}
</span>
{isDisabled && (
<Badge variant="outline" className={STATUS_COLORS.disabled}>
disabled
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{user.email}</span>
<span>·</span>
<span>{user.provider}</span>
</div>
</div>
<Badge variant="outline" className={ROLE_COLORS[user.role] ?? ""}>
{user.role}
</Badge>
<div className="flex items-center gap-1 shrink-0">
{user.status === "active" ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-amber-500"
title="Disable user"
onClick={async () => {
if (confirm(`Disable user "${user.name ?? user.email}"?`)) {
await updateUserStatusAction(user.id, "disabled");
onRefresh();
}
}}
>
<Ban className="h-3.5 w-3.5" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-emerald-500"
title="Enable user"
onClick={async () => {
await updateUserStatusAction(user.id, "active");
onRefresh();
}}
>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Edit user"
onClick={onEdit}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
title="Delete user"
onClick={async () => {
if (confirm(`Permanently delete user "${user.name ?? user.email}"? This cannot be undone.`)) {
await deleteUserAction(user.id);
onRefresh();
}
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
function EditUserRow({
user,
onClose,
onSave,
}: {
user: UserEntry;
onClose: () => void;
onSave: () => void;
}) {
const [role, setRole] = useState(user.role);
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Pencil className="h-4 w-4" />
Editing {user.name ?? user.email}
</div>
<form
action={async (formData) => {
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"
>
<div className="space-y-1">
<Label htmlFor={`name-${user.id}`}>Name</Label>
<Input
id={`name-${user.id}`}
name="name"
defaultValue={user.name ?? ""}
placeholder="Display name"
/>
</div>
<div className="space-y-1">
<Label htmlFor={`email-${user.id}`}>Email</Label>
<Input
id={`email-${user.id}`}
name="email"
defaultValue={user.email}
placeholder="Email address"
/>
</div>
<div className="space-y-1">
<Label>Role</Label>
<Select value={role} onValueChange={(v) => setRole(v as UserEntry["role"])}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="sm:col-span-3 flex gap-2">
<Button type="submit" size="sm">Save</Button>
<Button type="button" variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
</div>
</form>
</div>
);
}

View File

@@ -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");
}

View File

@@ -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 <UsersClient users={safeUsers} />;
}

View File

@@ -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<string, unknown>) {
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<string, unknown> = {};
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);
}
}

View File

@@ -142,3 +142,27 @@ export async function promoteToAdmin(userId: number): Promise<void> {
})
.where(eq(users.id, userId));
}
export async function updateUserRole(userId: number, role: User["role"]): Promise<User | null> {
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<User | null> {
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<void> {
await db.delete(users).where(eq(users.id, userId));
}

View File

@@ -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();