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 (
+
+ );
+}
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 */}
+
+
+
+
+ |
+ User
+ |
+
+ Role
+ |
+
+ Status
+ |
+
+ Joined
+ |
+
+ Actions
+ |
+
+
+
+ {renderTableRows()}
+
+
+
+
+ {/* 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
-
-
-
-
+
+
+
+
+
+
+
+
+
+ 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.
-
-
-
-
-
+
+