diff --git a/app/(admin)/admin/users/page.tsx b/app/(admin)/admin/users/page.tsx new file mode 100644 index 0000000..dee6f0c --- /dev/null +++ b/app/(admin)/admin/users/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next" +import { UsersClient } from "./users-client" + +export const metadata: Metadata = { + title: "Users", +} + +export default function UsersPage() { + return +} diff --git a/app/(admin)/admin/users/users-client.tsx b/app/(admin)/admin/users/users-client.tsx new file mode 100644 index 0000000..4474e43 --- /dev/null +++ b/app/(admin)/admin/users/users-client.tsx @@ -0,0 +1,230 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { authClient } from "@/lib/auth-client" +import { Loader2, SearchIcon, BanIcon, UserCheckIcon } from "lucide-react" + +type Role = "user" | "contributor" | "admin" + +interface AdminUser { + id: string + name: string + email: string + emailVerified: boolean + image: string | null + createdAt: Date | string + role: Role + banned: boolean +} + +const roles: Role[] = ["user", "contributor", "admin"] + +function formatDate(value: Date | string | null | undefined) { + if (!value) return "—" + const d = typeof value === "string" ? new Date(value) : value + return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) +} + +function getInitial(name: string) { + return name?.charAt(0)?.toUpperCase() || "?" +} + +export function UsersClient() { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState("") + const [actionLoading, setActionLoading] = useState>({}) + + useEffect(() => { + const fetchUsers = async () => { + setLoading(true) + const res = await authClient.admin.listUsers({ query: { limit: 100 } }) + if (res.data?.users) { + setUsers(res.data.users as AdminUser[]) + } + setLoading(false) + } + fetchUsers() + }, []) + + const filteredUsers = useMemo(() => { + const term = search.trim().toLowerCase() + if (!term) return users + return users.filter( + (u) => + u.name?.toLowerCase().includes(term) || + u.email?.toLowerCase().includes(term) + ) + }, [users, search]) + + const handleRoleChange = async (userId: string, newRole: Role) => { + setActionLoading((prev) => ({ ...prev, [userId]: true })) + try { + await authClient.admin.setRole({ userId, role: newRole }) + setUsers((prev) => + prev.map((u) => (u.id === userId ? { ...u, role: newRole } : u)) + ) + } finally { + setActionLoading((prev) => ({ ...prev, [userId]: false })) + } + } + + const handleBan = async (userId: string) => { + setActionLoading((prev) => ({ ...prev, [userId]: true })) + try { + await authClient.admin.banUser({ userId }) + setUsers((prev) => + prev.map((u) => (u.id === userId ? { ...u, banned: true } : u)) + ) + } finally { + setActionLoading((prev) => ({ ...prev, [userId]: false })) + } + } + + const handleUnban = async (userId: string) => { + setActionLoading((prev) => ({ ...prev, [userId]: true })) + try { + await authClient.admin.unbanUser({ userId }) + setUsers((prev) => + prev.map((u) => (u.id === userId ? { ...u, banned: false } : u)) + ) + } finally { + setActionLoading((prev) => ({ ...prev, [userId]: false })) + } + } + + return ( +
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Search by name or email..." + className="w-full pl-9 pr-4 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors" + /> +
+ + {/* Table */} +
+ + + + + + + + + + + + {loading ? ( + + + + ) : filteredUsers.length === 0 ? ( + + + + ) : ( + filteredUsers.map((user) => ( + + + + + + + + )) + )} + +
+ User + + Role + + Status + + Joined + + Actions +
+ +
+ No users found. +
+
+ {user.image ? ( + + ) : ( +
+ {getInitial(user.name)} +
+ )} +
+

+ {user.name || "Unnamed"} +

+

{user.email}

+
+
+
+ + +
+ + + {user.banned ? "Banned" : "Active"} + +
+
+ {formatDate(user.createdAt)} + + {actionLoading[user.id] ? ( + + ) : user.banned ? ( + + ) : ( + + )} +
+
+
+ ) +}