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:
@@ -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 },
|
||||
|
||||
277
app/(dashboard)/users/UsersClient.tsx
Normal file
277
app/(dashboard)/users/UsersClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
app/(dashboard)/users/actions.ts
Normal file
95
app/(dashboard)/users/actions.ts
Normal 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");
|
||||
}
|
||||
11
app/(dashboard)/users/page.tsx
Normal file
11
app/(dashboard)/users/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user