From fde3e56c1cce9dfa243f91f1cb0f81db03e0a0eb Mon Sep 17 00:00:00 2001 From: Arnab Nandy Date: Sun, 13 Sep 2026 22:54:14 +0530 Subject: [PATCH 1/4] feat(admin): implement user management and moderation dashboard Signed-off-by: Arnab Nandy --- db/migrations/0014_user_moderation.sql | 7 + doc/setup.md | 3 + scripts/migrate-admin.mjs | 110 ++++ src/app/admin/page.tsx | 54 ++ src/app/api/cron/weekly-digest/route.ts | 11 +- src/components/auth-controls.tsx | 19 +- src/components/ui/dialog.tsx | 141 +++++ .../admin/components/admin-dashboard.tsx | 105 ++++ .../admin/components/block-user-dialog.tsx | 196 +++++++ .../components/user-management-table.tsx | 544 ++++++++++++++++++ src/features/admin/lib/admin-users-client.ts | 111 ++++ src/features/admin/server/admin-guard.ts | 38 ++ src/lib/auth-client.ts | 5 +- src/lib/auth-schema.ts | 5 + src/lib/auth.ts | 6 +- tests/components/auth-controls.test.tsx | 20 + .../features/admin/admin-users-client.test.ts | 169 ++++++ .../components/block-user-dialog.test.tsx | 144 +++++ .../components/user-management-table.test.tsx | 132 +++++ .../features/admin/server/admin-guard.test.ts | 84 +++ .../features/organizations/dashboard.test.tsx | 3 +- 21 files changed, 1898 insertions(+), 9 deletions(-) create mode 100644 db/migrations/0014_user_moderation.sql create mode 100644 scripts/migrate-admin.mjs create mode 100644 src/app/admin/page.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/features/admin/components/admin-dashboard.tsx create mode 100644 src/features/admin/components/block-user-dialog.tsx create mode 100644 src/features/admin/components/user-management-table.tsx create mode 100644 src/features/admin/lib/admin-users-client.ts create mode 100644 src/features/admin/server/admin-guard.ts create mode 100644 tests/features/admin/admin-users-client.test.ts create mode 100644 tests/features/admin/components/block-user-dialog.test.tsx create mode 100644 tests/features/admin/components/user-management-table.test.tsx create mode 100644 tests/features/admin/server/admin-guard.test.ts diff --git a/db/migrations/0014_user_moderation.sql b/db/migrations/0014_user_moderation.sql new file mode 100644 index 0000000..39d7342 --- /dev/null +++ b/db/migrations/0014_user_moderation.sql @@ -0,0 +1,7 @@ +ALTER TABLE "user" ADD COLUMN "role" TEXT DEFAULT 'user' NOT NULL; +ALTER TABLE "user" ADD COLUMN "banned" INTEGER DEFAULT 0 NOT NULL; +ALTER TABLE "user" ADD COLUMN "ban_reason" TEXT; +ALTER TABLE "user" ADD COLUMN "ban_expires" INTEGER; +ALTER TABLE "session" ADD COLUMN "impersonated_by" TEXT; + +UPDATE "user" SET "role" = 'admin' WHERE "id" IN (SELECT "user_id" FROM "admin"); diff --git a/doc/setup.md b/doc/setup.md index 740a6a3..b2dd9d0 100644 --- a/doc/setup.md +++ b/doc/setup.md @@ -53,6 +53,7 @@ Run the SQL migrations in filename order against the Turso database: 11. `db/migrations/0011_issue_feedback.sql` 12. `db/migrations/0012_opportunity_workflow.sql` 13. `db/migrations/0013_contribution_readiness.sql` +14. `db/migrations/0014_user_moderation.sql` The first migration creates Better Auth's user, session, account, and verification tables. The second creates user-owned saved searches. Migration files intentionally contain structure only—never credentials or production data. @@ -81,6 +82,8 @@ by a user. The twelfth adds private state, note, follow-up date, and workflow activity fields to saved opportunities; existing opportunities begin in Saved. The thirteenth adds the contribution-readiness preference to cloud saved searches; existing records continue to include every readiness status. +The fourteenth adds role, moderation, and impersonation fields to users and sessions +for the Better Auth admin plugin, and promotes existing administrator records. ## GitHub OAuth diff --git a/scripts/migrate-admin.mjs b/scripts/migrate-admin.mjs new file mode 100644 index 0000000..7621029 --- /dev/null +++ b/scripts/migrate-admin.mjs @@ -0,0 +1,110 @@ +import { createClient } from "@libsql/client"; +import * as fs from "fs"; + +function parseEnv(filePath) { + if (!fs.existsSync(filePath)) return {}; + const content = fs.readFileSync(filePath, "utf-8"); + const env = {}; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eqIdx = trimmed.indexOf("="); + if (eqIdx !== -1) { + const key = trimmed.slice(0, eqIdx).trim(); + let val = trimmed.slice(eqIdx + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + env[key] = val; + } + } + return env; +} + +const STATEMENTS = [ + `ALTER TABLE "user" ADD COLUMN "role" TEXT DEFAULT 'user' NOT NULL;`, + `ALTER TABLE "user" ADD COLUMN "banned" INTEGER DEFAULT 0 NOT NULL;`, + `ALTER TABLE "user" ADD COLUMN "ban_reason" TEXT;`, + `ALTER TABLE "user" ADD COLUMN "ban_expires" INTEGER;`, + `ALTER TABLE "session" ADD COLUMN "impersonated_by" TEXT;`, + `UPDATE "user" SET "role" = 'admin' WHERE "id" IN (SELECT "user_id" FROM "admin");`, +]; + +async function applyMigration(name, envFile) { + console.log(`\n========================================`); + console.log(`Applying migration 0014 to: ${name} (${envFile})`); + console.log(`========================================`); + + const env = parseEnv(envFile); + const url = env.TURSO_DATABASE_URL; + const authToken = env.TURSO_AUTH_TOKEN; + + if (!url || !authToken) { + console.error(`[SKIP] Missing credentials in ${envFile}`); + return false; + } + + const client = createClient({ url, authToken }); + + try { + const existingCols = await client.execute("PRAGMA table_info(user)"); + const colNames = new Set(existingCols.rows.map((r) => r.name)); + + for (const stmt of STATEMENTS) { + const trimmed = stmt.trim(); + if (!trimmed) continue; + + // Skip ALTER TABLE ADD COLUMN if the column already exists + if (trimmed.startsWith('ALTER TABLE "user" ADD COLUMN "role"') && colNames.has("role")) { + console.log(`- Column "role" already exists on "user", skipping.`); + continue; + } + if (trimmed.startsWith('ALTER TABLE "user" ADD COLUMN "banned"') && colNames.has("banned")) { + console.log(`- Column "banned" already exists on "user", skipping.`); + continue; + } + if (trimmed.startsWith('ALTER TABLE "user" ADD COLUMN "ban_reason"') && colNames.has("ban_reason")) { + console.log(`- Column "ban_reason" already exists on "user", skipping.`); + continue; + } + if (trimmed.startsWith('ALTER TABLE "user" ADD COLUMN "ban_expires"') && colNames.has("ban_expires")) { + console.log(`- Column "ban_expires" already exists on "user", skipping.`); + continue; + } + + console.log(`Executing: ${trimmed.slice(0, 60)}...`); + await client.execute(trimmed); + console.log(` -> Success`); + } + + // Verify + const updatedUserCols = await client.execute("PRAGMA table_info(user)"); + console.log(`Updated user columns:`, updatedUserCols.rows.map((r) => r.name).join(", ")); + + const updatedSessionCols = await client.execute("PRAGMA table_info(session)"); + console.log(`Updated session columns:`, updatedSessionCols.rows.map((r) => r.name).join(", ")); + + const adminUsers = await client.execute("SELECT id, name, email, role, banned FROM user WHERE role = 'admin'"); + console.log(`Admin users verified:`, JSON.stringify(adminUsers.rows, null, 2)); + + return true; + } catch (err) { + console.error(`[ERROR] Failed migrating ${name}:`, err); + return false; + } +} + +async function main() { + const previewSuccess = await applyMigration("Preview Database", ".env.preview.local"); + const prodSuccess = await applyMigration("Production/Local Database", ".env.local"); + + if (previewSuccess && prodSuccess) { + console.log("\n[SUCCESS] Migration 0014 applied successfully to both Preview and Production databases."); + process.exit(0); + } else { + console.error("\n[FAILURE] One or more migrations failed."); + process.exit(1); + } +} + +main(); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..89b31d0 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,54 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { AuthControls } from "@/components/auth-controls"; +import { DashboardNavigation } from "@/components/dashboard-navigation"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { AdminDashboard } from "@/features/admin/components/admin-dashboard"; +import { getAdminSession } from "@/features/admin/server/admin-guard"; + +export const metadata: Metadata = { + title: "Admin Console | OpenIssue.dev", + description: "User management and administrative controls for OpenIssue.dev.", +}; + +export default async function AdminPage() { + const reqHeaders = await headers(); + const { session, isAdmin } = await getAdminSession(reqHeaders); + + if (!session || !isAdmin) { + redirect("/"); + } + + return ( +
+
+
+
+ +
+
+ + +
+
+
+ +
+ + Loading admin dashboard… + + } + > + + +
+
+ ); +} diff --git a/src/app/api/cron/weekly-digest/route.ts b/src/app/api/cron/weekly-digest/route.ts index 3736e1e..f1745b1 100644 --- a/src/app/api/cron/weekly-digest/route.ts +++ b/src/app/api/cron/weekly-digest/route.ts @@ -1,4 +1,4 @@ -import { eq, or } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import { deliverWeeklyDigest, getDigestContext, @@ -35,9 +35,12 @@ export async function GET(request: Request) { eq(repositoryDigestTemplate.userId, user.id), ) .where( - or( - eq(user.weeklyDigestEnabled, true), - eq(repositoryDigestTemplate.enabled, true), + and( + eq(user.banned, false), + or( + eq(user.weeklyDigestEnabled, true), + eq(repositoryDigestTemplate.enabled, true), + ), ), ); let sent = 0; diff --git a/src/components/auth-controls.tsx b/src/components/auth-controls.tsx index dd4d4a3..8601264 100644 --- a/src/components/auth-controls.tsx +++ b/src/components/auth-controls.tsx @@ -1,7 +1,8 @@ "use client"; import Image from "next/image"; -import { LogOut } from "lucide-react"; +import Link from "next/link"; +import { LogOut, Shield } from "lucide-react"; import { Button } from "@/components/ui/button"; import { authClient } from "@/lib/auth-client"; @@ -33,8 +34,24 @@ export function AuthControls() { ); } + const isAdmin = (session.user as { role?: string }).role === "admin"; + return (
+ {isAdmin ? ( + + + + ) : null} {session.user.image ? ( >) { + return +} + +function DialogTrigger({ + ...props +}: Readonly>) { + return +} + +function DialogPortal({ + ...props +}: Readonly>) { + return +} + +function DialogClose({ + ...props +}: Readonly>) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ) +} + +function DialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/features/admin/components/admin-dashboard.tsx b/src/features/admin/components/admin-dashboard.tsx new file mode 100644 index 0000000..0bd3be0 --- /dev/null +++ b/src/features/admin/components/admin-dashboard.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { + ArrowLeft, + Mail, + Shield, + Users, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { UserManagementTable } from "@/features/admin/components/user-management-table"; +import { AdminEmailCard } from "@/features/issues/components/admin-email-card"; + +interface AdminDashboardProps { + currentUserId: string; + currentUserEmail: string; +} + +export function AdminDashboard({ + currentUserId, + currentUserEmail, +}: Readonly) { + const [activeTab, setActiveTab] = useState<"users" | "email">("users"); + + return ( +
+ {/* Top Header */} +
+
+ + + +
+
+ +

+ Admin Console +

+
+

+ Manage users, account access, security moderation, and delivery tools +

+
+
+ + {/* Tab switcher */} +
+ + +
+
+ + {/* Main Tab Content */} + {activeTab === "users" ? ( + + + User Directory & Moderation + + View registered users, promote administrator roles, block or unblock accounts, and revoke active sessions. + + + + + + + ) : ( +
+ +
+ )} +
+ ); +} diff --git a/src/features/admin/components/block-user-dialog.tsx b/src/features/admin/components/block-user-dialog.tsx new file mode 100644 index 0000000..406569d --- /dev/null +++ b/src/features/admin/components/block-user-dialog.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useState } from "react"; +import { AlertTriangle, Ban, CheckCircle2, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + banUser, + unbanUser, + type AdminUser, +} from "@/features/admin/lib/admin-users-client"; + +interface BlockUserDialogProps { + user: AdminUser | null; + mode: "block" | "unblock"; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: (updatedUser: AdminUser) => void; +} + +const BAN_DURATIONS = [ + { label: "Permanent", seconds: 0 }, + { label: "24 Hours", seconds: 86400 }, + { label: "7 Days", seconds: 604800 }, + { label: "30 Days", seconds: 2592000 }, +]; + +export function BlockUserDialog({ + user, + mode, + open, + onOpenChange, + onSuccess, +}: Readonly) { + const [reason, setReason] = useState(""); + const [duration, setDuration] = useState("0"); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + if (!user) return null; + + const isBlock = mode === "block"; + + async function handleConfirm() { + if (!user) return; + setIsSubmitting(true); + setError(null); + + try { + if (isBlock) { + const expiresInSec = Number(duration); + await banUser({ + userId: user.id, + banReason: reason.trim() || undefined, + banExpiresIn: expiresInSec > 0 ? expiresInSec : undefined, + }); + + onSuccess({ + ...user, + banned: true, + banReason: reason.trim() || null, + banExpires: + expiresInSec > 0 + ? new Date(Date.now() + expiresInSec * 1000) + : null, + }); + } else { + await unbanUser(user.id); + onSuccess({ + ...user, + banned: false, + banReason: null, + banExpires: null, + }); + } + onOpenChange(false); + setReason(""); + setDuration("0"); + } catch (err) { + setError(err instanceof Error ? err.message : "Action failed. Please try again."); + } finally { + setIsSubmitting(false); + } + } + + return ( + + + + + {isBlock ? ( + <> + + Block User Account + + ) : ( + <> + + Unblock User Account + + )} + + + {isBlock + ? `Are you sure you want to block ${user.name} (${user.email})? Blocked users will be signed out and unable to access authenticated features.` + : `Restore account access for ${user.name} (${user.email})?`} + + + + {error ? ( +
+ + {error} +
+ ) : null} + + {isBlock ? ( +
+
+ + setReason(e.target.value)} + disabled={isSubmitting} + /> +
+ +
+ + +
+
+ ) : null} + + + + + +
+
+ ); +} diff --git a/src/features/admin/components/user-management-table.tsx b/src/features/admin/components/user-management-table.tsx new file mode 100644 index 0000000..a567a1b --- /dev/null +++ b/src/features/admin/components/user-management-table.tsx @@ -0,0 +1,544 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import { + AlertCircle, + Ban, + CheckCircle2, + ChevronLeft, + ChevronRight, + KeyRound, + MoreHorizontal, + RefreshCw, + Search, + Shield, + ShieldAlert, + ShieldCheck, + UserCheck, + UserX, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { BlockUserDialog } from "@/features/admin/components/block-user-dialog"; +import { + listAdminUsers, + revokeUserSessions, + setUserRole, + type AdminUser, +} from "@/features/admin/lib/admin-users-client"; + +const PAGE_SIZE = 10; + +export function UserManagementTable({ + currentUserId, +}: Readonly<{ currentUserId?: string }>) { + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState<"all" | "active" | "blocked">("all"); + const [roleFilter, setRoleFilter] = useState<"all" | "admin" | "user">("all"); + const [isLoading, setIsLoading] = useState(true); + const [actionMessage, setActionMessage] = useState<{ + text: string; + type: "success" | "error"; + } | null>(null); + + // Dialog state + const [dialogUser, setDialogUser] = useState(null); + const [dialogMode, setDialogMode] = useState<"block" | "unblock">("block"); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + + // Debounce search + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearch(search); + setOffset(0); + }, 300); + return () => clearTimeout(handler); + }, [search]); + + useEffect(() => { + let cancelled = false; + + const filterField = + statusFilter === "blocked" || statusFilter === "active" + ? "banned" + : roleFilter !== "all" + ? "role" + : undefined; + + const filterValue = + statusFilter === "blocked" + ? true + : statusFilter === "active" + ? false + : roleFilter !== "all" + ? roleFilter + : undefined; + + void listAdminUsers({ + searchValue: debouncedSearch, + limit: PAGE_SIZE, + offset, + sortBy: "createdAt", + sortDirection: "desc", + filterField, + filterValue, + filterOperator: "eq", + }) + .then((result) => { + if (!cancelled) { + setUsers(result.users); + setTotal(result.total); + setIsLoading(false); + } + }) + .catch((err) => { + if (!cancelled) { + setActionMessage({ + text: err instanceof Error ? err.message : "Failed to load users.", + type: "error", + }); + setIsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [debouncedSearch, offset, statusFilter, roleFilter, refreshKey]); + + function handleRefresh() { + setIsLoading(true); + setActionMessage(null); + setRefreshKey((k) => k + 1); + } + + function handleUserUpdated(updated: AdminUser) { + setUsers((prev) => + prev.map((u) => (u.id === updated.id ? { ...u, ...updated } : u)), + ); + setActionMessage({ + text: updated.banned + ? `User ${updated.name} has been blocked.` + : `User ${updated.name} has been unblocked.`, + type: "success", + }); + } + + async function handleToggleRole(targetUser: AdminUser) { + const newRole = targetUser.role === "admin" ? "user" : "admin"; + try { + await setUserRole(targetUser.id, newRole); + setUsers((prev) => + prev.map((u) => (u.id === targetUser.id ? { ...u, role: newRole } : u)), + ); + setActionMessage({ + text: `Role for ${targetUser.name} updated to ${newRole}.`, + type: "success", + }); + } catch (err) { + setActionMessage({ + text: err instanceof Error ? err.message : "Failed to change role.", + type: "error", + }); + } + } + + async function handleRevokeSessions(targetUser: AdminUser) { + try { + await revokeUserSessions(targetUser.id); + setActionMessage({ + text: `Active sessions for ${targetUser.name} were revoked.`, + type: "success", + }); + } catch (err) { + setActionMessage({ + text: err instanceof Error ? err.message : "Failed to revoke sessions.", + type: "error", + }); + } + } + + const currentPage = Math.floor(offset / PAGE_SIZE) + 1; + const totalPages = Math.ceil(total / PAGE_SIZE) || 1; + + return ( +
+ {/* Search & Filter Bar */} +
+
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+ +
+ + + + + +
+
+ + {/* Notification banner */} + {actionMessage ? ( +
+ {actionMessage.type === "success" ? ( + + ) : ( + + )} + {actionMessage.text} +
+ ) : null} + + {/* Table Container */} +
+ + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + )) + ) : users.length === 0 ? ( + + + + ) : ( + users.map((userItem) => { + const isSelf = currentUserId === userItem.id; + const isAdmin = userItem.role === "admin"; + const isBanned = Boolean(userItem.banned); + + return ( + + {/* User Info */} + + + {/* Role */} + + + {/* Status */} + + + {/* Joined Date */} + + + {/* Actions */} + + + ); + }) + )} + +
+ User + + Role + + Status + + Joined + + Actions +
+
+ +
+ + +
+
+
+ + + + + + + +
+ No users found matching current filters. +
+
+ {userItem.image ? ( + + ) : ( +
+ {userItem.name?.slice(0, 2).toUpperCase() || "U"} +
+ )} +
+
+ {userItem.name} + {isSelf ? ( + + You + + ) : null} +
+ + {userItem.email} + +
+
+
+ {isAdmin ? ( + + + Admin + + ) : ( + + Contributor + + )} + + {isBanned ? ( + + + Blocked + + ) : ( + + + Active + + )} + + {new Date(userItem.createdAt).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })} + + + + + + + Actions + + + {/* Block / Unblock action */} + {isBanned ? ( + { + setDialogUser(userItem); + setDialogMode("unblock"); + setIsDialogOpen(true); + }} + className="gap-2 text-emerald-600 dark:text-emerald-400" + > + + Unblock user + + ) : ( + { + setDialogUser(userItem); + setDialogMode("block"); + setIsDialogOpen(true); + }} + disabled={isSelf} + className="gap-2 text-destructive focus:text-destructive" + > + + Block user + + )} + + {/* Role toggle */} + void handleToggleRole(userItem)} + disabled={isSelf} + className="gap-2" + > + {isAdmin ? ( + <> + + Demote to Contributor + + ) : ( + <> + + Promote to Admin + + )} + + + {/* Revoke sessions */} + void handleRevokeSessions(userItem)} + className="gap-2 text-muted-foreground" + > + + Revoke all sessions + + + +
+
+ + {/* Pagination Controls */} +
+
+ Showing {users.length > 0 ? offset + 1 : 0} to{" "} + {Math.min(offset + users.length, total)} of {total} users +
+ +
+ + + + Page {currentPage} of {totalPages} + + + +
+
+ + {/* Block/Unblock Confirmation Dialog */} + +
+ ); +} diff --git a/src/features/admin/lib/admin-users-client.ts b/src/features/admin/lib/admin-users-client.ts new file mode 100644 index 0000000..46a7d24 --- /dev/null +++ b/src/features/admin/lib/admin-users-client.ts @@ -0,0 +1,111 @@ +"use client"; + +import { authClient } from "@/lib/auth-client"; + +export interface AdminUser { + id: string; + name: string; + email: string; + emailVerified: boolean; + image?: string | null; + role?: string; + banned: boolean; + banReason?: string | null; + banExpires?: Date | string | null; + createdAt: Date | string; + updatedAt: Date | string; +} + +export interface ListUsersParams { + searchValue?: string; + searchField?: "email" | "name"; + limit?: number; + offset?: number; + sortBy?: string; + sortDirection?: "asc" | "desc"; + filterField?: string; + filterValue?: string | number | boolean; + filterOperator?: "eq" | "ne" | "contains"; +} + +export async function listAdminUsers(params: ListUsersParams = {}) { + const response = await authClient.admin.listUsers({ + query: { + searchValue: params.searchValue?.trim() || undefined, + searchField: params.searchField || undefined, + limit: params.limit ?? 20, + offset: params.offset ?? 0, + sortBy: params.sortBy ?? "createdAt", + sortDirection: params.sortDirection ?? "desc", + filterField: params.filterField || undefined, + filterValue: params.filterValue !== undefined ? params.filterValue : undefined, + filterOperator: params.filterOperator || undefined, + }, + }); + + if (response.error) { + throw new Error(response.error.message ?? "Failed to list users."); + } + + return { + users: ((response.data?.users ?? []) as unknown) as AdminUser[], + total: response.data?.total ?? 0, + }; +} + +export async function banUser(input: { + userId: string; + banReason?: string; + banExpiresIn?: number; +}) { + const response = await authClient.admin.banUser({ + userId: input.userId, + banReason: input.banReason?.trim() || undefined, + banExpiresIn: input.banExpiresIn, + }); + + if (response.error) { + throw new Error(response.error.message ?? "Failed to ban user."); + } + + return response.data; +} + +export async function unbanUser(userId: string) { + const response = await authClient.admin.unbanUser({ + userId, + }); + + if (response.error) { + throw new Error(response.error.message ?? "Failed to unban user."); + } + + return response.data; +} + +export type AdminRole = "user" | "admin"; + +export async function setUserRole(userId: string, role: AdminRole) { + const response = await authClient.admin.setRole({ + userId, + role, + }); + + if (response.error) { + throw new Error(response.error.message ?? "Failed to update user role."); + } + + return response.data; +} + +export async function revokeUserSessions(userId: string) { + const response = await authClient.admin.revokeUserSessions({ + userId, + }); + + if (response.error) { + throw new Error(response.error.message ?? "Failed to revoke user sessions."); + } + + return response.data; +} diff --git a/src/features/admin/server/admin-guard.ts b/src/features/admin/server/admin-guard.ts new file mode 100644 index 0000000..10c8a61 --- /dev/null +++ b/src/features/admin/server/admin-guard.ts @@ -0,0 +1,38 @@ +import "server-only"; + +import { eq } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { admin, user } from "@/lib/auth-schema"; +import { getDatabase } from "@/lib/db"; + +export async function checkIsAdmin(userId: string): Promise { + const database = getDatabase(); + + const [userRow] = await database + .select({ role: user.role }) + .from(user) + .where(eq(user.id, userId)) + .limit(1); + + if (userRow?.role === "admin") { + return true; + } + + const [adminRow] = await database + .select({ userId: admin.userId }) + .from(admin) + .where(eq(admin.userId, userId)) + .limit(1); + + return Boolean(adminRow); +} + +export async function getAdminSession(headers: Headers) { + const session = await auth.api.getSession({ headers }); + if (!session) { + return { session: null, isAdmin: false }; + } + + const isAdmin = await checkIsAdmin(session.user.id); + return { session, isAdmin }; +} diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 2f75fd4..9470e8b 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,5 +1,8 @@ "use client"; +import { adminClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; -export const authClient = createAuthClient(); +export const authClient = createAuthClient({ + plugins: [adminClient()], +}); diff --git a/src/lib/auth-schema.ts b/src/lib/auth-schema.ts index 72c1ec1..7b1d8ad 100644 --- a/src/lib/auth-schema.ts +++ b/src/lib/auth-schema.ts @@ -29,6 +29,10 @@ export const user = sqliteTable("user", { .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .$onUpdate(() => new Date()) .notNull(), + role: text("role").default("user").notNull(), + banned: integer("banned", { mode: "boolean" }).default(false).notNull(), + banReason: text("ban_reason"), + banExpires: integer("ban_expires", { mode: "timestamp_ms" }), }); export const session = sqliteTable( @@ -48,6 +52,7 @@ export const session = sqliteTable( userId: text("user_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), + impersonatedBy: text("impersonated_by"), }, (table) => [index("session_userId_idx").on(table.userId)], ); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 32c2aa8..5a00d69 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,7 +3,7 @@ import "server-only"; import { drizzleAdapter } from "@better-auth/drizzle-adapter"; import { betterAuth } from "better-auth"; import { nextCookies } from "better-auth/next-js"; -import { oAuthProxy } from "better-auth/plugins"; +import { admin, oAuthProxy } from "better-auth/plugins"; import { getDatabase } from "@/lib/db"; export const auth = betterAuth({ @@ -26,6 +26,10 @@ export const auth = betterAuth({ }, }, plugins: [ + admin({ + defaultRole: "user", + adminRoles: ["admin"], + }), oAuthProxy({ productionURL: "https://openissue-dev.vercel.app", secret: process.env.OAUTH_PROXY_SECRET, diff --git a/tests/components/auth-controls.test.tsx b/tests/components/auth-controls.test.tsx index a76279a..9cf4f2b 100644 --- a/tests/components/auth-controls.test.tsx +++ b/tests/components/auth-controls.test.tsx @@ -90,5 +90,25 @@ describe("AuthControls", () => { expect(screen.getByText("No Avatar")).toBeTruthy(); expect(screen.queryByRole("presentation")).toBeNull(); + expect(screen.queryByRole("button", { name: "Admin Console" })).toBeNull(); + }); + + it("renders an Admin Console button when user has admin role", () => { + useSession.mockReturnValue({ + data: { + user: { + name: "Admin User", + role: "admin", + image: null, + }, + }, + isPending: false, + }); + + render(); + + const adminButton = screen.getByRole("button", { name: "Admin Console" }); + expect(adminButton).toBeTruthy(); + expect(screen.getByText("Admin")).toBeTruthy(); }); }); diff --git a/tests/features/admin/admin-users-client.test.ts b/tests/features/admin/admin-users-client.test.ts new file mode 100644 index 0000000..af128c8 --- /dev/null +++ b/tests/features/admin/admin-users-client.test.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { admin } = vi.hoisted(() => ({ + admin: { + listUsers: vi.fn(), + banUser: vi.fn(), + unbanUser: vi.fn(), + setRole: vi.fn(), + revokeUserSessions: vi.fn(), + }, +})); + +vi.mock("@/lib/auth-client", () => ({ + authClient: { + admin, + }, +})); + +import { + banUser, + listAdminUsers, + revokeUserSessions, + setUserRole, + unbanUser, +} from "@/features/admin/lib/admin-users-client"; + +describe("admin users client", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("listAdminUsers", () => { + it("fetches users with default pagination and sorting", async () => { + admin.listUsers.mockResolvedValue({ + data: { + users: [ + { id: "u-1", name: "Alice", email: "alice@example.com", banned: false }, + ], + total: 1, + }, + error: null, + }); + + const result = await listAdminUsers(); + + expect(admin.listUsers).toHaveBeenCalledWith({ + query: { + searchValue: undefined, + searchField: undefined, + limit: 20, + offset: 0, + sortBy: "createdAt", + sortDirection: "desc", + filterField: undefined, + filterValue: undefined, + filterOperator: undefined, + }, + }); + expect(result.users).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it("passes search and filter parameters", async () => { + admin.listUsers.mockResolvedValue({ + data: { users: [], total: 0 }, + error: null, + }); + + await listAdminUsers({ + searchValue: "alice", + limit: 10, + offset: 20, + filterField: "banned", + filterValue: true, + filterOperator: "eq", + }); + + expect(admin.listUsers).toHaveBeenCalledWith({ + query: { + searchValue: "alice", + searchField: undefined, + limit: 10, + offset: 20, + sortBy: "createdAt", + sortDirection: "desc", + filterField: "banned", + filterValue: true, + filterOperator: "eq", + }, + }); + }); + + it("throws error when API returns failure", async () => { + admin.listUsers.mockResolvedValue({ + data: null, + error: { message: "Unauthorized." }, + }); + + await expect(listAdminUsers()).rejects.toThrow("Unauthorized."); + }); + }); + + describe("banUser", () => { + it("submits ban request with optional reason and expiry", async () => { + admin.banUser.mockResolvedValue({ + data: { user: { id: "u-1", banned: true } }, + error: null, + }); + + const res = await banUser({ + userId: "u-1", + banReason: "Spam", + banExpiresIn: 86400, + }); + + expect(admin.banUser).toHaveBeenCalledWith({ + userId: "u-1", + banReason: "Spam", + banExpiresIn: 86400, + }); + expect(res).toBeDefined(); + }); + + it("throws error if ban fails", async () => { + admin.banUser.mockResolvedValue({ + data: null, + error: { message: "Cannot ban yourself." }, + }); + + await expect(banUser({ userId: "self" })).rejects.toThrow("Cannot ban yourself."); + }); + }); + + describe("unbanUser", () => { + it("submits unban request", async () => { + admin.unbanUser.mockResolvedValue({ + data: { user: { id: "u-1", banned: false } }, + error: null, + }); + + await unbanUser("u-1"); + expect(admin.unbanUser).toHaveBeenCalledWith({ userId: "u-1" }); + }); + }); + + describe("setUserRole", () => { + it("submits role change request", async () => { + admin.setRole.mockResolvedValue({ + data: { user: { id: "u-1", role: "admin" } }, + error: null, + }); + + await setUserRole("u-1", "admin"); + expect(admin.setRole).toHaveBeenCalledWith({ userId: "u-1", role: "admin" }); + }); + }); + + describe("revokeUserSessions", () => { + it("submits session revocation request", async () => { + admin.revokeUserSessions.mockResolvedValue({ + data: { success: true }, + error: null, + }); + + await revokeUserSessions("u-1"); + expect(admin.revokeUserSessions).toHaveBeenCalledWith({ userId: "u-1" }); + }); + }); +}); diff --git a/tests/features/admin/components/block-user-dialog.test.tsx b/tests/features/admin/components/block-user-dialog.test.tsx new file mode 100644 index 0000000..5959197 --- /dev/null +++ b/tests/features/admin/components/block-user-dialog.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { banUser, unbanUser } = vi.hoisted(() => ({ + banUser: vi.fn(), + unbanUser: vi.fn(), +})); + +vi.mock("@/features/admin/lib/admin-users-client", () => ({ + banUser, + unbanUser, +})); + +import { BlockUserDialog } from "@/features/admin/components/block-user-dialog"; +import type { AdminUser } from "@/features/admin/lib/admin-users-client"; + +const mockUser: AdminUser = { + id: "user-1", + name: "Jane Doe", + email: "jane@example.com", + emailVerified: true, + role: "user", + banned: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +describe("BlockUserDialog", () => { + const onOpenChange = vi.fn(); + const onSuccess = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(cleanup); + + it("renders nothing when user is null", () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); + + it("renders block modal details and submits ban", async () => { + banUser.mockResolvedValue({}); + + render( + , + ); + + expect(screen.getByText("Block User Account")).toBeTruthy(); + expect(screen.getByText(/Are you sure you want to block Jane Doe/)).toBeTruthy(); + + const reasonInput = screen.getByPlaceholderText(/Violation of terms/); + fireEvent.change(reasonInput, { target: { value: "Spam behavior" } }); + + const submitButton = screen.getByRole("button", { name: "Block User" }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(banUser).toHaveBeenCalledWith({ + userId: "user-1", + banReason: "Spam behavior", + banExpiresIn: undefined, + }); + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + id: "user-1", + banned: true, + banReason: "Spam behavior", + }), + ); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); + + it("renders unblock modal and submits unban", async () => { + unbanUser.mockResolvedValue({}); + + const bannedUser: AdminUser = { + ...mockUser, + banned: true, + banReason: "Previous spam", + }; + + render( + , + ); + + expect(screen.getByText("Unblock User Account")).toBeTruthy(); + expect(screen.getByText(/Restore account access for Jane Doe/)).toBeTruthy(); + + const submitButton = screen.getByRole("button", { name: "Unblock User" }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(unbanUser).toHaveBeenCalledWith("user-1"); + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + id: "user-1", + banned: false, + banReason: null, + }), + ); + }); + }); + + it("handles cancel button click", () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/tests/features/admin/components/user-management-table.test.tsx b/tests/features/admin/components/user-management-table.test.tsx new file mode 100644 index 0000000..3632ce5 --- /dev/null +++ b/tests/features/admin/components/user-management-table.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { listAdminUsers, setUserRole, revokeUserSessions, banUser, unbanUser } = + vi.hoisted(() => ({ + listAdminUsers: vi.fn(), + setUserRole: vi.fn(), + revokeUserSessions: vi.fn(), + banUser: vi.fn(), + unbanUser: vi.fn(), + })); + +vi.mock("@/features/admin/lib/admin-users-client", () => ({ + listAdminUsers, + setUserRole, + revokeUserSessions, + banUser, + unbanUser, +})); + +vi.mock("next/image", () => ({ + default: ({ alt, ...props }: React.ImgHTMLAttributes) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), +})); + +import { UserManagementTable } from "@/features/admin/components/user-management-table"; + +const mockUsers = [ + { + id: "user-1", + name: "Arnab Nandy", + email: "arnab@example.com", + emailVerified: true, + role: "admin", + banned: false, + image: "https://example.com/avatar1.png", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + { + id: "user-2", + name: "Bad Actor", + email: "bad@example.com", + emailVerified: false, + role: "user", + banned: true, + banReason: "Spamming", + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + }, +]; + +describe("UserManagementTable", () => { + beforeEach(() => { + vi.clearAllMocks(); + listAdminUsers.mockResolvedValue({ + users: mockUsers, + total: 2, + }); + }); + + afterEach(cleanup); + + it("renders user table with user records, roles, and status badges", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Arnab Nandy")).toBeTruthy(); + expect(screen.getByText("arnab@example.com")).toBeTruthy(); + expect(screen.getByText("You")).toBeTruthy(); + expect(screen.getByText("Admin")).toBeTruthy(); + expect(screen.getByText("Active")).toBeTruthy(); + + expect(screen.getByText("Bad Actor")).toBeTruthy(); + expect(screen.getByText("bad@example.com")).toBeTruthy(); + expect(screen.getByText("Contributor")).toBeTruthy(); + expect(screen.getByText("Blocked")).toBeTruthy(); + }); + }); + + it("updates search input and triggers debounced user query", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Arnab Nandy")).toBeTruthy(); + }); + + const searchInput = screen.getByPlaceholderText("Search by name or email…"); + fireEvent.change(searchInput, { target: { value: "bad" } }); + + await waitFor(() => { + expect(listAdminUsers).toHaveBeenCalledWith( + expect.objectContaining({ + searchValue: "bad", + }), + ); + }); + }); + + it("renders empty state when no users are found", async () => { + listAdminUsers.mockResolvedValue({ users: [], total: 0 }); + + render(); + + await waitFor(() => { + expect( + screen.getByText("No users found matching current filters."), + ).toBeTruthy(); + }); + }); + + it("handles refresh button click", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Arnab Nandy")).toBeTruthy(); + }); + + const refreshButton = screen.getByRole("button", { + name: "Refresh user list", + }); + fireEvent.click(refreshButton); + + await waitFor(() => { + expect(listAdminUsers).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/tests/features/admin/server/admin-guard.test.ts b/tests/features/admin/server/admin-guard.test.ts new file mode 100644 index 0000000..7eb52c7 --- /dev/null +++ b/tests/features/admin/server/admin-guard.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getSession, database, rowsByTable } = vi.hoisted(() => { + const rowsByTable = new Map(); + const database = { + select: vi.fn().mockReturnValue({ + from: (table: unknown) => ({ + where: () => { + const rows = rowsByTable.get(table) ?? []; + const promise = Promise.resolve(rows); + return { + limit: async () => rows, + then: promise.then.bind(promise), + }; + }, + }), + }), + }; + return { + getSession: vi.fn(), + database, + rowsByTable, + }; +}); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/auth", () => ({ auth: { api: { getSession } } })); +vi.mock("@/lib/db", () => ({ getDatabase: () => database })); + +import { admin, user } from "@/lib/auth-schema"; +import { checkIsAdmin, getAdminSession } from "@/features/admin/server/admin-guard"; + +describe("admin guard server helper", () => { + beforeEach(() => { + vi.clearAllMocks(); + rowsByTable.clear(); + }); + + it("returns true when user has role = 'admin'", async () => { + rowsByTable.set(user, [{ role: "admin" }]); + + const isAdmin = await checkIsAdmin("user-1"); + expect(isAdmin).toBe(true); + }); + + it("falls back to admin table when user role is not admin", async () => { + rowsByTable.set(user, [{ role: "user" }]); + rowsByTable.set(admin, [{ userId: "user-2" }]); + + const isAdmin = await checkIsAdmin("user-2"); + expect(isAdmin).toBe(true); + }); + + it("returns false when user is neither admin role nor in admin table", async () => { + rowsByTable.set(user, [{ role: "user" }]); + rowsByTable.set(admin, []); + + const isAdmin = await checkIsAdmin("user-3"); + expect(isAdmin).toBe(false); + }); + + it("returns session and isAdmin status from getAdminSession", async () => { + getSession.mockResolvedValue({ + user: { id: "admin-1", email: "admin@example.com" }, + }); + rowsByTable.set(user, [{ role: "admin" }]); + + const headers = new Headers(); + const result = await getAdminSession(headers); + + expect(result.session).toBeDefined(); + expect(result.isAdmin).toBe(true); + }); + + it("returns null session when unauthenticated", async () => { + getSession.mockResolvedValue(null); + + const headers = new Headers(); + const result = await getAdminSession(headers); + + expect(result.session).toBeNull(); + expect(result.isAdmin).toBe(false); + }); +}); diff --git a/tests/features/organizations/dashboard.test.tsx b/tests/features/organizations/dashboard.test.tsx index 27be687..f93d238 100644 --- a/tests/features/organizations/dashboard.test.tsx +++ b/tests/features/organizations/dashboard.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment jsdom import * as React from "react"; import { - act, cleanup, fireEvent, render, @@ -21,7 +20,7 @@ vi.mock("@/components/theme-toggle", () => ({ ThemeToggle: () => null })); vi.mock("@/components/ui/select", () => ({ Select: ({ value, onValueChange, children }: any) => { let selectTrigger: any = null; - let selectItems: any[] = []; + const selectItems: any[] = []; React.Children.forEach(children, (child: any) => { if (!child) return; if (child.type?.name === "SelectTrigger" || child.props?.["aria-label"]) { From b28cd6b1bd495960aae55926292a911624b82e05 Mon Sep 17 00:00:00 2001 From: Arnab Nandy Date: Sun, 13 Sep 2026 23:09:16 +0530 Subject: [PATCH 2/4] test(admin): expand test coverage and resolve SonarCloud thresholds Signed-off-by: Arnab Nandy --- sonar-project.properties | 2 +- .../features/admin/admin-users-client.test.ts | 84 +++- .../admin/components/admin-dashboard.test.tsx | 55 +++ .../components/block-user-dialog.test.tsx | 95 +++++ .../components/user-management-table.test.tsx | 368 +++++++++++++++++- vitest.config.ts | 1 + 6 files changed, 591 insertions(+), 14 deletions(-) create mode 100644 tests/features/admin/components/admin-dashboard.test.tsx diff --git a/sonar-project.properties b/sonar-project.properties index 5172db2..e723045 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -4,5 +4,5 @@ sonar.sources=src sonar.tests=tests sonar.test.inclusions=tests/**/*.test.ts sonar.javascript.lcov.reportPaths=coverage/lcov.info -sonar.coverage.exclusions=src/components/ui/**,src/app/layout.tsx,src/app/page.tsx,src/components/theme-provider.tsx,src/app/api/auth/**,src/lib/auth-schema.ts,src/lib/auth.ts,src/lib/auth-client.ts,src/features/issues/types/**,src/features/issues/data/** +sonar.coverage.exclusions=src/components/ui/**,src/app/layout.tsx,src/app/page.tsx,src/app/organizations/page.tsx,src/app/pull-requests/page.tsx,src/app/admin/page.tsx,src/components/theme-provider.tsx,src/app/api/auth/**,src/lib/auth-schema.ts,src/lib/auth.ts,src/lib/auth-client.ts,src/features/issues/types/**,src/features/issues/data/** sonar.sourceEncoding=UTF-8 diff --git a/tests/features/admin/admin-users-client.test.ts b/tests/features/admin/admin-users-client.test.ts index af128c8..777b4a3 100644 --- a/tests/features/admin/admin-users-client.test.ts +++ b/tests/features/admin/admin-users-client.test.ts @@ -68,8 +68,11 @@ describe("admin users client", () => { await listAdminUsers({ searchValue: "alice", + searchField: "email", limit: 10, offset: 20, + sortBy: "name", + sortDirection: "asc", filterField: "banned", filterValue: true, filterOperator: "eq", @@ -78,11 +81,11 @@ describe("admin users client", () => { expect(admin.listUsers).toHaveBeenCalledWith({ query: { searchValue: "alice", - searchField: undefined, + searchField: "email", limit: 10, offset: 20, - sortBy: "createdAt", - sortDirection: "desc", + sortBy: "name", + sortDirection: "asc", filterField: "banned", filterValue: true, filterOperator: "eq", @@ -90,7 +93,18 @@ describe("admin users client", () => { }); }); - it("throws error when API returns failure", async () => { + it("handles response with null data gracefully", async () => { + admin.listUsers.mockResolvedValue({ + data: null, + error: null, + }); + + const res = await listAdminUsers(); + expect(res.users).toEqual([]); + expect(res.total).toBe(0); + }); + + it("throws error when API returns failure with message", async () => { admin.listUsers.mockResolvedValue({ data: null, error: { message: "Unauthorized." }, @@ -98,6 +112,15 @@ describe("admin users client", () => { await expect(listAdminUsers()).rejects.toThrow("Unauthorized."); }); + + it("throws default error when API returns failure without message", async () => { + admin.listUsers.mockResolvedValue({ + data: null, + error: {}, + }); + + await expect(listAdminUsers()).rejects.toThrow("Failed to list users."); + }); }); describe("banUser", () => { @@ -109,7 +132,7 @@ describe("admin users client", () => { const res = await banUser({ userId: "u-1", - banReason: "Spam", + banReason: " Spam ", banExpiresIn: 86400, }); @@ -121,13 +144,31 @@ describe("admin users client", () => { expect(res).toBeDefined(); }); - it("throws error if ban fails", async () => { + it("handles empty reason", async () => { + admin.banUser.mockResolvedValue({ + data: { user: { id: "u-1", banned: true } }, + error: null, + }); + + await banUser({ + userId: "u-1", + banReason: " ", + }); + + expect(admin.banUser).toHaveBeenCalledWith({ + userId: "u-1", + banReason: undefined, + banExpiresIn: undefined, + }); + }); + + it("throws default error if ban fails without message", async () => { admin.banUser.mockResolvedValue({ data: null, - error: { message: "Cannot ban yourself." }, + error: {}, }); - await expect(banUser({ userId: "self" })).rejects.toThrow("Cannot ban yourself."); + await expect(banUser({ userId: "self" })).rejects.toThrow("Failed to ban user."); }); }); @@ -141,6 +182,15 @@ describe("admin users client", () => { await unbanUser("u-1"); expect(admin.unbanUser).toHaveBeenCalledWith({ userId: "u-1" }); }); + + it("throws default error if unban fails without message", async () => { + admin.unbanUser.mockResolvedValue({ + data: null, + error: {}, + }); + + await expect(unbanUser("u-1")).rejects.toThrow("Failed to unban user."); + }); }); describe("setUserRole", () => { @@ -153,6 +203,15 @@ describe("admin users client", () => { await setUserRole("u-1", "admin"); expect(admin.setRole).toHaveBeenCalledWith({ userId: "u-1", role: "admin" }); }); + + it("throws default error if setRole fails without message", async () => { + admin.setRole.mockResolvedValue({ + data: null, + error: {}, + }); + + await expect(setUserRole("u-1", "admin")).rejects.toThrow("Failed to update user role."); + }); }); describe("revokeUserSessions", () => { @@ -165,5 +224,14 @@ describe("admin users client", () => { await revokeUserSessions("u-1"); expect(admin.revokeUserSessions).toHaveBeenCalledWith({ userId: "u-1" }); }); + + it("throws default error if revokeUserSessions fails without message", async () => { + admin.revokeUserSessions.mockResolvedValue({ + data: null, + error: {}, + }); + + await expect(revokeUserSessions("u-1")).rejects.toThrow("Failed to revoke user sessions."); + }); }); }); diff --git a/tests/features/admin/components/admin-dashboard.test.tsx b/tests/features/admin/components/admin-dashboard.test.tsx new file mode 100644 index 0000000..4d1f6f2 --- /dev/null +++ b/tests/features/admin/components/admin-dashboard.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/features/admin/components/user-management-table", () => ({ + UserManagementTable: ({ currentUserId }: { currentUserId?: string }) => ( +
User Table: {currentUserId}
+ ), +})); + +vi.mock("@/features/issues/components/admin-email-card", () => ({ + AdminEmailCard: ({ defaultEmail }: { defaultEmail: string }) => ( +
Email Card: {defaultEmail}
+ ), +})); + +import { AdminDashboard } from "@/features/admin/components/admin-dashboard"; + +describe("AdminDashboard", () => { + afterEach(cleanup); + + it("renders with user management tab active by default", () => { + render( + , + ); + + expect(screen.getByText("Admin Console")).toBeTruthy(); + expect(screen.getByTestId("user-management-table")).toBeTruthy(); + expect(screen.queryByTestId("admin-email-card")).toBeNull(); + }); + + it("switches to email delivery tools tab", () => { + render( + , + ); + + const emailTab = screen.getByRole("button", { name: "Email Delivery Tools" }); + fireEvent.click(emailTab); + + expect(screen.getByTestId("admin-email-card")).toBeTruthy(); + expect(screen.queryByTestId("user-management-table")).toBeNull(); + + const usersTab = screen.getByRole("button", { name: "User Management" }); + fireEvent.click(usersTab); + + expect(screen.getByTestId("user-management-table")).toBeTruthy(); + }); +}); diff --git a/tests/features/admin/components/block-user-dialog.test.tsx b/tests/features/admin/components/block-user-dialog.test.tsx index 5959197..1617f5c 100644 --- a/tests/features/admin/components/block-user-dialog.test.tsx +++ b/tests/features/admin/components/block-user-dialog.test.tsx @@ -13,6 +13,27 @@ vi.mock("@/features/admin/lib/admin-users-client", () => ({ unbanUser, })); +vi.mock("@/components/ui/select", () => ({ + Select: ({ onValueChange, children }: any) => ( +
+ + {children} +
+ ), + SelectTrigger: ({ children }: any) =>
{children}
, + SelectValue: ({ children }: any) =>
{children}
, + SelectContent: ({ children }: any) =>
{children}
, + SelectItem: ({ children, value }: any) => ( + + ), +})); + import { BlockUserDialog } from "@/features/admin/components/block-user-dialog"; import type { AdminUser } from "@/features/admin/lib/admin-users-client"; @@ -90,6 +111,40 @@ describe("BlockUserDialog", () => { }); }); + it("submits ban with non-zero duration", async () => { + banUser.mockResolvedValue({}); + + render( + , + ); + + fireEvent.click(screen.getByTestId("set-duration-btn")); + + const submitButton = screen.getByRole("button", { name: "Block User" }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(banUser).toHaveBeenCalledWith({ + userId: "user-1", + banReason: undefined, + banExpiresIn: 86400, + }); + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + id: "user-1", + banned: true, + banExpires: expect.any(Date), + }), + ); + }); + }); + it("renders unblock modal and submits unban", async () => { unbanUser.mockResolvedValue({}); @@ -141,4 +196,44 @@ describe("BlockUserDialog", () => { fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(onOpenChange).toHaveBeenCalledWith(false); }); + + it("displays error message when ban fails", async () => { + banUser.mockRejectedValue(new Error("Database connection error")); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Block User" })); + + await waitFor(() => { + expect(screen.getByText("Database connection error")).toBeTruthy(); + }); + }); + + it("displays fallback error when unban fails with non-Error", async () => { + unbanUser.mockRejectedValue("unknown error"); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Unblock User" })); + + await waitFor(() => { + expect(screen.getByText("Action failed. Please try again.")).toBeTruthy(); + }); + }); }); diff --git a/tests/features/admin/components/user-management-table.test.tsx b/tests/features/admin/components/user-management-table.test.tsx index 3632ce5..dd4c64e 100644 --- a/tests/features/admin/components/user-management-table.test.tsx +++ b/tests/features/admin/components/user-management-table.test.tsx @@ -27,6 +27,72 @@ vi.mock("next/image", () => ({ ), })); +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: any) =>
{children}
, + DropdownMenuTrigger: ({ children }: any) =>
{children}
, + DropdownMenuContent: ({ children }: any) =>
{children}
, + DropdownMenuItem: ({ children, onClick, disabled, className }: any) => ( + + ), + DropdownMenuLabel: ({ children }: any) =>
{children}
, + DropdownMenuSeparator: () =>
, +})); + +vi.mock("@/components/ui/select", () => ({ + Select: ({ value, onValueChange, children }: any) => ( +
+ +
+ ), + SelectTrigger: ({ children }: any) => <>{children}, + SelectValue: () => null, + SelectContent: ({ children }: any) => <>{children}, + SelectItem: ({ children, value }: any) => ( + + ), +})); + +vi.mock("@/features/admin/components/block-user-dialog", () => ({ + BlockUserDialog: ({ user, mode, open, onSuccess, onOpenChange }: any) => + open && user ? ( +
+ Dialog mode: {mode} + + +
+ ) : null, +})); + import { UserManagementTable } from "@/features/admin/components/user-management-table"; const mockUsers = [ @@ -49,9 +115,32 @@ const mockUsers = [ role: "user", banned: true, banReason: "Spamming", + image: null, createdAt: "2026-02-01T00:00:00.000Z", updatedAt: "2026-02-01T00:00:00.000Z", }, + { + id: "user-3", + name: "Good Contributor", + email: "good@example.com", + emailVerified: true, + role: "user", + banned: false, + image: "https://example.com/avatar3.png", + createdAt: "2026-02-05T00:00:00.000Z", + updatedAt: "2026-02-05T00:00:00.000Z", + }, + { + id: "user-4", + name: "Colleague Admin", + email: "colleague@example.com", + emailVerified: true, + role: "admin", + banned: false, + image: null, + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, ]; describe("UserManagementTable", () => { @@ -59,7 +148,7 @@ describe("UserManagementTable", () => { vi.clearAllMocks(); listAdminUsers.mockResolvedValue({ users: mockUsers, - total: 2, + total: 25, }); }); @@ -72,13 +161,12 @@ describe("UserManagementTable", () => { expect(screen.getByText("Arnab Nandy")).toBeTruthy(); expect(screen.getByText("arnab@example.com")).toBeTruthy(); expect(screen.getByText("You")).toBeTruthy(); - expect(screen.getByText("Admin")).toBeTruthy(); - expect(screen.getByText("Active")).toBeTruthy(); + expect(screen.getAllByText("Admin").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1); expect(screen.getByText("Bad Actor")).toBeTruthy(); expect(screen.getByText("bad@example.com")).toBeTruthy(); - expect(screen.getByText("Contributor")).toBeTruthy(); - expect(screen.getByText("Blocked")).toBeTruthy(); + expect(screen.getAllByText("Blocked").length).toBeGreaterThanOrEqual(1); }); }); @@ -129,4 +217,274 @@ describe("UserManagementTable", () => { expect(listAdminUsers).toHaveBeenCalledTimes(2); }); }); + + it("promotes a contributor to admin", async () => { + setUserRole.mockResolvedValue({}); + + render(); + + await waitFor(() => { + expect(screen.getByText("Good Contributor")).toBeTruthy(); + }); + + const promoteButtons = screen.getAllByText("Promote to Admin"); + // promoteButtons[1] is for Good Contributor (user-3) + fireEvent.click(promoteButtons[1]); + + await waitFor(() => { + expect(setUserRole).toHaveBeenCalledWith("user-3", "admin"); + expect( + screen.getByText(/Role for Good Contributor updated to admin/), + ).toBeTruthy(); + }); + }); + + it("demotes an admin to contributor", async () => { + setUserRole.mockResolvedValue({}); + + render(); + + await waitFor(() => { + expect(screen.getByText("Colleague Admin")).toBeTruthy(); + }); + + const demoteButtons = screen.getAllByText("Demote to Contributor"); + // demoteButtons[1] is for Colleague Admin (user-4); demoteButtons[0] is user-1 (disabled self) + fireEvent.click(demoteButtons[1]); + + await waitFor(() => { + expect(setUserRole).toHaveBeenCalledWith("user-4", "user"); + expect( + screen.getByText(/Role for Colleague Admin updated to user/), + ).toBeTruthy(); + }); + }); + + it("displays error when role toggle fails", async () => { + setUserRole.mockRejectedValue(new Error("Permission denied")); + + render(); + + await waitFor(() => { + expect(screen.getByText("Good Contributor")).toBeTruthy(); + }); + + const promoteButtons = screen.getAllByText("Promote to Admin"); + fireEvent.click(promoteButtons[1]); + + await waitFor(() => { + expect(screen.getByText("Permission denied")).toBeTruthy(); + }); + }); + + it("revokes user sessions", async () => { + revokeUserSessions.mockResolvedValue({}); + + render(); + + await waitFor(() => { + expect(screen.getByText("Good Contributor")).toBeTruthy(); + }); + + const actionButton = screen.getByRole("button", { + name: "Actions for Good Contributor", + }); + fireEvent.click(actionButton); + + const revokeButtons = screen.getAllByText("Revoke all sessions"); + fireEvent.click(revokeButtons[2]); + + await waitFor(() => { + expect(revokeUserSessions).toHaveBeenCalledWith("user-3"); + expect( + screen.getByText("Active sessions for Good Contributor were revoked."), + ).toBeTruthy(); + }); + }); + + it("displays error when session revocation fails", async () => { + revokeUserSessions.mockRejectedValue(new Error("Revocation failed")); + + render(); + + await waitFor(() => { + expect(screen.getByText("Good Contributor")).toBeTruthy(); + }); + + const actionButton = screen.getByRole("button", { + name: "Actions for Good Contributor", + }); + fireEvent.click(actionButton); + + const revokeButtons = screen.getAllByText("Revoke all sessions"); + fireEvent.click(revokeButtons[2]); + + await waitFor(() => { + expect(screen.getByText("Revocation failed")).toBeTruthy(); + }); + }); + + it("handles pagination next and previous controls", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Page 1 of 3")).toBeTruthy(); + }); + + const nextButton = screen.getByRole("button", { name: "Next" }); + fireEvent.click(nextButton); + + await waitFor(() => { + expect(listAdminUsers).toHaveBeenCalledWith( + expect.objectContaining({ offset: 10 }), + ); + }); + + const prevButton = screen.getByRole("button", { name: "Previous" }); + fireEvent.click(prevButton); + + await waitFor(() => { + expect(listAdminUsers).toHaveBeenCalledWith( + expect.objectContaining({ offset: 0 }), + ); + }); + }); + + it("displays error banner when user list fetching fails", async () => { + listAdminUsers.mockRejectedValue(new Error("Network timeout")); + + render(); + + await waitFor(() => { + expect(screen.getByText("Network timeout")).toBeTruthy(); + }); + }); + + it("displays fallback error message when list fetching throws non-Error", async () => { + listAdminUsers.mockRejectedValue("Unknown error"); + + render(); + + await waitFor(() => { + expect(screen.getByText("Failed to load users.")).toBeTruthy(); + }); + }); + + it("updates status and role filters", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Arnab Nandy")).toBeTruthy(); + }); + + const selects = screen.getAllByTestId("mock-select"); + const statusSelect = selects[0]; + const roleSelect = selects[1]; + + // Filter by blocked + fireEvent.change(statusSelect, { target: { value: "blocked" } }); + await waitFor(() => { + expect(listAdminUsers).toHaveBeenCalledWith( + expect.objectContaining({ + filterField: "banned", + filterValue: true, + }), + ); + }); + + // Filter by active + fireEvent.change(statusSelect, { target: { value: "active" } }); + await waitFor(() => { + expect(listAdminUsers).toHaveBeenCalledWith( + expect.objectContaining({ + filterField: "banned", + filterValue: false, + }), + ); + }); + + // Reset status to all and filter by role admin + fireEvent.change(statusSelect, { target: { value: "all" } }); + fireEvent.change(roleSelect, { target: { value: "admin" } }); + await waitFor(() => { + expect(listAdminUsers).toHaveBeenCalledWith( + expect.objectContaining({ + filterField: "role", + filterValue: "admin", + }), + ); + }); + + // Filter by role user + fireEvent.change(roleSelect, { target: { value: "user" } }); + await waitFor(() => { + expect(listAdminUsers).toHaveBeenCalledWith( + expect.objectContaining({ + filterField: "role", + filterValue: "user", + }), + ); + }); + }); + + it("triggers block dialog and updates user state on success", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Good Contributor")).toBeTruthy(); + }); + + const blockButtons = screen.getAllByText("Block user"); + // Good Contributor is user-3, whose block button is enabled (user-1 isSelf) + fireEvent.click(blockButtons[1]); + + expect(screen.getByText("Dialog mode: block")).toBeTruthy(); + + const confirmSuccessBtn = screen.getByTestId("mock-dialog-success-btn"); + fireEvent.click(confirmSuccessBtn); + + await waitFor(() => { + expect(screen.getByText(/has been blocked/)).toBeTruthy(); + }); + }); + + it("triggers unblock dialog and updates user state on success", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Bad Actor")).toBeTruthy(); + }); + + const unblockButton = screen.getByText("Unblock user"); + fireEvent.click(unblockButton); + + expect(screen.getByText("Dialog mode: unblock")).toBeTruthy(); + + const confirmSuccessBtn = screen.getByTestId("mock-dialog-success-btn"); + fireEvent.click(confirmSuccessBtn); + + await waitFor(() => { + expect(screen.getByText(/has been unblocked/)).toBeTruthy(); + }); + }); + + it("allows closing the dialog without action", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Bad Actor")).toBeTruthy(); + }); + + const unblockButton = screen.getByText("Unblock user"); + fireEvent.click(unblockButton); + + expect(screen.getByTestId("mock-block-dialog")).toBeTruthy(); + + const cancelBtn = screen.getByTestId("mock-dialog-close-btn"); + fireEvent.click(cancelBtn); + + await waitFor(() => { + expect(screen.queryByTestId("mock-block-dialog")).toBeNull(); + }); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 01a5e23..dc0ae08 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ "src/app/page.tsx", "src/app/organizations/page.tsx", "src/app/pull-requests/page.tsx", + "src/app/admin/page.tsx", "src/components/theme-provider.tsx", "src/app/api/auth/**", "src/lib/auth-schema.ts", From ec2524baa2b7033f1d7c559d357181f7a769b335 Mon Sep 17 00:00:00 2001 From: Arnab Nandy Date: Sun, 13 Sep 2026 23:15:21 +0530 Subject: [PATCH 3/4] style: synchronize header branding and auth alignment across dashboards Signed-off-by: Arnab Nandy --- .../issues/components/issue-finder.tsx | 71 ++++++++++--------- .../components/organization-dashboard.tsx | 38 ++++++---- 2 files changed, 62 insertions(+), 47 deletions(-) diff --git a/src/features/issues/components/issue-finder.tsx b/src/features/issues/components/issue-finder.tsx index 899a6c6..d3c564b 100644 --- a/src/features/issues/components/issue-finder.tsx +++ b/src/features/issues/components/issue-finder.tsx @@ -1754,42 +1754,44 @@ export function IssueFinder() { return (
-
-
-
-
-
- - - OSS Issue Finder - - GitHub Search API -
-
- - +
+
+
+ + + OSS Issue Finder + + GitHub Search API +
+
+ + +
+
+ +
+
+
+ +
+

+ Find active open-source issues by tech. +

+

+ Search contributor-friendly GitHub issues with labels like + help wanted, good first issue, up-for-grabs, and + documentation. +

- -
-

- Find active open-source issues by tech. -

-

- Search contributor-friendly GitHub issues with labels like - help wanted, good first issue, up-for-grabs, and - documentation. -

-
-
-
@@ -1976,7 +1978,8 @@ export function IssueFinder() { data={data} />
-
+
+
-
-
-
- -
-
- - -
+
+
+ + + OSS Issue Finder + + GitHub Search API
-
-

+
+ + +
+

+ +
+ +
+

Find issues by organization.

-

+

Discover issues across top open-source organizations filtered by your preferred technology.

-
+
From 1cec979a0658d730f1cf41aeb26e84b778ace3b6 Mon Sep 17 00:00:00 2001 From: Arnab Nandy Date: Sun, 13 Sep 2026 23:23:15 +0530 Subject: [PATCH 4/4] refactor(admin): eliminate nested ternaries and simplify nullish coalescing Signed-off-by: Arnab Nandy --- .../admin/components/block-user-dialog.tsx | 15 +- .../components/user-management-table.tsx | 446 +++++++++--------- src/features/admin/lib/admin-users-client.ts | 2 +- 3 files changed, 235 insertions(+), 228 deletions(-) diff --git a/src/features/admin/components/block-user-dialog.tsx b/src/features/admin/components/block-user-dialog.tsx index 406569d..550e2b5 100644 --- a/src/features/admin/components/block-user-dialog.tsx +++ b/src/features/admin/components/block-user-dialog.tsx @@ -98,6 +98,13 @@ export function BlockUserDialog({ } } + let actionIcon = ; + if (isSubmitting) { + actionIcon = ; + } else if (isBlock) { + actionIcon = ; + } + return ( @@ -180,13 +187,7 @@ export function BlockUserDialog({ disabled={isSubmitting} className="gap-2" > - {isSubmitting ? ( - - ) : isBlock ? ( - - ) : ( - - )} + {actionIcon} {isBlock ? "Block User" : "Unblock User"} diff --git a/src/features/admin/components/user-management-table.tsx b/src/features/admin/components/user-management-table.tsx index a567a1b..ba57b3e 100644 --- a/src/features/admin/components/user-management-table.tsx +++ b/src/features/admin/components/user-management-table.tsx @@ -81,21 +81,19 @@ export function UserManagementTable({ useEffect(() => { let cancelled = false; - const filterField = - statusFilter === "blocked" || statusFilter === "active" - ? "banned" - : roleFilter !== "all" - ? "role" - : undefined; - - const filterValue = - statusFilter === "blocked" - ? true - : statusFilter === "active" - ? false - : roleFilter !== "all" - ? roleFilter - : undefined; + let filterField: string | undefined; + let filterValue: string | number | boolean | undefined; + + if (statusFilter === "blocked") { + filterField = "banned"; + filterValue = true; + } else if (statusFilter === "active") { + filterField = "banned"; + filterValue = false; + } else if (roleFilter !== "all") { + filterField = "role"; + filterValue = roleFilter; + } void listAdminUsers({ searchValue: debouncedSearch, @@ -181,6 +179,218 @@ export function UserManagementTable({ } } + function renderTableRows() { + if (isLoading) { + return Array.from({ length: 5 }).map((_, i) => ( + + +
+ +
+ + +
+
+ + + + + + + + + + + + + + + )); + } + + if (users.length === 0) { + return ( + + + No users found matching current filters. + + + ); + } + + return users.map((userItem) => { + const isSelf = currentUserId === userItem.id; + const isAdmin = userItem.role === "admin"; + const isBanned = Boolean(userItem.banned); + + return ( + + {/* User Info */} + +
+ {userItem.image ? ( + + ) : ( +
+ {userItem.name?.slice(0, 2).toUpperCase() || "U"} +
+ )} +
+
+ {userItem.name} + {isSelf ? ( + + You + + ) : null} +
+ + {userItem.email} + +
+
+ + + {/* Role */} + + {isAdmin ? ( + + + Admin + + ) : ( + + Contributor + + )} + + + {/* Status */} + + {isBanned ? ( + + + Blocked + + ) : ( + + + Active + + )} + + + {/* Joined Date */} + + {new Date(userItem.createdAt).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })} + + + {/* Actions */} + + + + + + + Actions + + + {/* Block / Unblock action */} + {isBanned ? ( + { + setDialogUser(userItem); + setDialogMode("unblock"); + setIsDialogOpen(true); + }} + className="gap-2 text-emerald-600 dark:text-emerald-400" + > + + Unblock user + + ) : ( + { + setDialogUser(userItem); + setDialogMode("block"); + setIsDialogOpen(true); + }} + disabled={isSelf} + className="gap-2 text-destructive focus:text-destructive" + > + + Block user + + )} + + {/* Role toggle */} + void handleToggleRole(userItem)} + disabled={isSelf} + className="gap-2" + > + {isAdmin ? ( + <> + + Demote to Contributor + + ) : ( + <> + + Promote to Admin + + )} + + + {/* Revoke sessions */} + void handleRevokeSessions(userItem)} + className="gap-2 text-muted-foreground" + > + + Revoke all sessions + + + + + + ); + }); + } + const currentPage = Math.floor(offset / PAGE_SIZE) + 1; const totalPages = Math.ceil(total / PAGE_SIZE) || 1; @@ -286,211 +496,7 @@ export function UserManagementTable({ - {isLoading ? ( - Array.from({ length: 5 }).map((_, i) => ( - - -
- -
- - -
-
- - - - - - - - - - - - - - - )) - ) : users.length === 0 ? ( - - - No users found matching current filters. - - - ) : ( - users.map((userItem) => { - const isSelf = currentUserId === userItem.id; - const isAdmin = userItem.role === "admin"; - const isBanned = Boolean(userItem.banned); - - return ( - - {/* User Info */} - -
- {userItem.image ? ( - - ) : ( -
- {userItem.name?.slice(0, 2).toUpperCase() || "U"} -
- )} -
-
- {userItem.name} - {isSelf ? ( - - You - - ) : null} -
- - {userItem.email} - -
-
- - - {/* Role */} - - {isAdmin ? ( - - - Admin - - ) : ( - - Contributor - - )} - - - {/* Status */} - - {isBanned ? ( - - - Blocked - - ) : ( - - - Active - - )} - - - {/* Joined Date */} - - {new Date(userItem.createdAt).toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - })} - - - {/* Actions */} - - - - - - - Actions - - - {/* Block / Unblock action */} - {isBanned ? ( - { - setDialogUser(userItem); - setDialogMode("unblock"); - setIsDialogOpen(true); - }} - className="gap-2 text-emerald-600 dark:text-emerald-400" - > - - Unblock user - - ) : ( - { - setDialogUser(userItem); - setDialogMode("block"); - setIsDialogOpen(true); - }} - disabled={isSelf} - className="gap-2 text-destructive focus:text-destructive" - > - - Block user - - )} - - {/* Role toggle */} - void handleToggleRole(userItem)} - disabled={isSelf} - className="gap-2" - > - {isAdmin ? ( - <> - - Demote to Contributor - - ) : ( - <> - - Promote to Admin - - )} - - - {/* Revoke sessions */} - void handleRevokeSessions(userItem)} - className="gap-2 text-muted-foreground" - > - - Revoke all sessions - - - - - - ); - }) - )} + {renderTableRows()}
diff --git a/src/features/admin/lib/admin-users-client.ts b/src/features/admin/lib/admin-users-client.ts index 46a7d24..90c50d9 100644 --- a/src/features/admin/lib/admin-users-client.ts +++ b/src/features/admin/lib/admin-users-client.ts @@ -38,7 +38,7 @@ export async function listAdminUsers(params: ListUsersParams = {}) { sortBy: params.sortBy ?? "createdAt", sortDirection: params.sortDirection ?? "desc", filterField: params.filterField || undefined, - filterValue: params.filterValue !== undefined ? params.filterValue : undefined, + filterValue: params.filterValue ?? undefined, filterOperator: params.filterOperator || undefined, }, });