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/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/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..550e2b5 --- /dev/null +++ b/src/features/admin/components/block-user-dialog.tsx @@ -0,0 +1,197 @@ +"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); + } + } + + let actionIcon = ; + if (isSubmitting) { + actionIcon = ; + } else if (isBlock) { + actionIcon = ; + } + + 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..ba57b3e --- /dev/null +++ b/src/features/admin/components/user-management-table.tsx @@ -0,0 +1,550 @@ +"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; + + 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, + 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", + }); + } + } + + 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; + + return ( +
+ {/* Search & Filter Bar */} +
+
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+ +
+ + + + + +
+
+ + {/* Notification banner */} + {actionMessage ? ( +
+ {actionMessage.type === "success" ? ( + + ) : ( + + )} + {actionMessage.text} +
+ ) : null} + + {/* Table Container */} +
+ + + + + + + + + + + + {renderTableRows()} + +
+ User + + Role + + Status + + Joined + + Actions +
+
+ + {/* 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..90c50d9 --- /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, + 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/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.

-
+
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..777b4a3 --- /dev/null +++ b/tests/features/admin/admin-users-client.test.ts @@ -0,0 +1,237 @@ +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", + searchField: "email", + limit: 10, + offset: 20, + sortBy: "name", + sortDirection: "asc", + filterField: "banned", + filterValue: true, + filterOperator: "eq", + }); + + expect(admin.listUsers).toHaveBeenCalledWith({ + query: { + searchValue: "alice", + searchField: "email", + limit: 10, + offset: 20, + sortBy: "name", + sortDirection: "asc", + filterField: "banned", + filterValue: true, + filterOperator: "eq", + }, + }); + }); + + 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." }, + }); + + 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", () => { + 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("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: {}, + }); + + await expect(banUser({ userId: "self" })).rejects.toThrow("Failed to ban user."); + }); + }); + + 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" }); + }); + + 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", () => { + 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" }); + }); + + 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", () => { + 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" }); + }); + + 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 new file mode 100644 index 0000000..1617f5c --- /dev/null +++ b/tests/features/admin/components/block-user-dialog.test.tsx @@ -0,0 +1,239 @@ +// @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, +})); + +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"; + +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("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({}); + + 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); + }); + + 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 new file mode 100644 index 0000000..dd4c64e --- /dev/null +++ b/tests/features/admin/components/user-management-table.test.tsx @@ -0,0 +1,490 @@ +// @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} + ), +})); + +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 = [ + { + 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", + 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", () => { + beforeEach(() => { + vi.clearAllMocks(); + listAdminUsers.mockResolvedValue({ + users: mockUsers, + total: 25, + }); + }); + + 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.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.getAllByText("Blocked").length).toBeGreaterThanOrEqual(1); + }); + }); + + 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); + }); + }); + + 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/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"]) { 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",