diff --git a/messages/en.json b/messages/en.json index f7cf6a1e..ce6120a4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -502,8 +502,14 @@ "admin": "Admins", "user": "Users", "verified": "Verified", - "unverified": "Unverified" - } + "unverified": "Unverified", + "flagged": "Flagged" + }, + "flag": "Flag User", + "unflag": "Unflag User", + "flagged": "User flagged", + "unflagged": "User unflagged", + "flagFailed": "Failed to update flag status" }, "categories": { "title": "Category Management", diff --git a/prisma/migrations/20251222132600_add_user_flagged_and_unusual_activity/migration.sql b/prisma/migrations/20251222132600_add_user_flagged_and_unusual_activity/migration.sql new file mode 100644 index 00000000..20f2dfee --- /dev/null +++ b/prisma/migrations/20251222132600_add_user_flagged_and_unusual_activity/migration.sql @@ -0,0 +1,7 @@ +-- AlterEnum +ALTER TYPE "DelistReason" ADD VALUE 'UNUSUAL_ACTIVITY'; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "flagged" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "users" ADD COLUMN "flaggedAt" TIMESTAMP(3); +ALTER TABLE "users" ADD COLUMN "flaggedReason" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index beb828c2..8a5bbeb8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,6 +23,9 @@ model User { githubUsername String? apiKey String? @unique mcpPromptsPublicByDefault Boolean @default(false) + flagged Boolean @default(false) + flaggedAt DateTime? + flaggedReason String? accounts Account[] subscriptions CategorySubscription[] changeRequests ChangeRequest[] @@ -408,4 +411,5 @@ enum DelistReason { LOW_QUALITY NOT_LLM_INSTRUCTION MANUAL + UNUSUAL_ACTIVITY } diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 15b39dd4..c0708d34 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -15,10 +15,16 @@ export async function PATCH( const { id } = await params; const body = await request.json(); - const { role, verified } = body; + const { role, verified, flagged, flaggedReason } = body; // Build update data - const updateData: { role?: "ADMIN" | "USER"; verified?: boolean } = {}; + const updateData: { + role?: "ADMIN" | "USER"; + verified?: boolean; + flagged?: boolean; + flaggedAt?: Date | null; + flaggedReason?: string | null; + } = {}; if (role !== undefined) { if (!["ADMIN", "USER"].includes(role)) { @@ -31,9 +37,33 @@ export async function PATCH( updateData.verified = verified; } + if (flagged !== undefined) { + updateData.flagged = flagged; + if (flagged) { + updateData.flaggedAt = new Date(); + updateData.flaggedReason = flaggedReason || null; + } else { + updateData.flaggedAt = null; + updateData.flaggedReason = null; + } + } + const user = await db.user.update({ where: { id }, data: updateData, + select: { + id: true, + email: true, + username: true, + name: true, + avatar: true, + role: true, + verified: true, + flagged: true, + flaggedAt: true, + flaggedReason: true, + createdAt: true, + }, }); return NextResponse.json(user); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 79c63563..1018153f 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -41,6 +41,7 @@ export async function GET(request: NextRequest) { OR?: Array<{ email?: { contains: string; mode: "insensitive" }; username?: { contains: string; mode: "insensitive" }; name?: { contains: string; mode: "insensitive" } }>; role?: "ADMIN" | "USER"; verified?: boolean; + flagged?: boolean; }; const filterConditions: WhereCondition = {}; @@ -58,6 +59,9 @@ export async function GET(request: NextRequest) { case "unverified": filterConditions.verified = false; break; + case "flagged": + filterConditions.flagged = true; + break; default: // "all" - no filter break; @@ -95,6 +99,9 @@ export async function GET(request: NextRequest) { avatar: true, role: true, verified: true, + flagged: true, + flaggedAt: true, + flaggedReason: true, createdAt: true, _count: { select: { diff --git a/src/app/api/prompts/route.ts b/src/app/api/prompts/route.ts index 03287d69..1ba988b0 100644 --- a/src/app/api/prompts/route.ts +++ b/src/app/api/prompts/route.ts @@ -48,6 +48,13 @@ export async function POST(request: Request) { const { title, description, content, type, structuredFormat, categoryId, tagIds, contributorIds, isPrivate, mediaUrl, requiresMediaUpload, requiredMediaType, requiredMediaCount } = parsed.data; + // Check if user is flagged (for auto-delisting) + const currentUser = await db.user.findUnique({ + where: { id: session.user.id }, + select: { flagged: true }, + }); + const isUserFlagged = currentUser?.flagged ?? false; + // Rate limit: Check if user created a prompt in the last 30 seconds const thirtySecondsAgo = new Date(Date.now() - 30 * 1000); const recentPrompt = await db.prompt.findFirst({ @@ -135,6 +142,7 @@ export async function POST(request: Request) { const slug = await generatePromptSlug(title); // Create prompt with tags + // Auto-delist if user is flagged const prompt = await db.prompt.create({ data: { title, @@ -150,6 +158,12 @@ export async function POST(request: Request) { requiredMediaCount: requiresMediaUpload ? requiredMediaCount : null, authorId: session.user.id, categoryId: categoryId || null, + // Auto-delist prompts from flagged users + ...(isUserFlagged && { + isUnlisted: true, + unlistedAt: new Date(), + delistReason: "UNUSUAL_ACTIVITY", + }), tags: { create: tagIds.map((tagId) => ({ tagId, diff --git a/src/components/admin/users-table.tsx b/src/components/admin/users-table.tsx index 4e9b2031..f1e5b372 100644 --- a/src/components/admin/users-table.tsx +++ b/src/components/admin/users-table.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { useTranslations, useLocale } from "next-intl"; import { formatDistanceToNow } from "@/lib/date"; -import { MoreHorizontal, Shield, User, Trash2, BadgeCheck, Search, Loader2, ChevronLeft, ChevronRight, Filter } from "lucide-react"; +import { MoreHorizontal, Shield, User, Trash2, BadgeCheck, Search, Loader2, ChevronLeft, ChevronRight, Filter, Flag, AlertTriangle } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -51,6 +51,9 @@ interface UserData { avatar: string | null; role: "ADMIN" | "USER"; verified: boolean; + flagged: boolean; + flaggedAt: string | null; + flaggedReason: string | null; createdAt: string; _count: { prompts: number; @@ -148,12 +151,31 @@ export function UsersTable() { if (!res.ok) throw new Error("Failed to update verification"); toast.success(verified ? t("verified") : t("unverified")); + fetchUsers(currentPage, searchQuery, userFilter); router.refresh(); } catch { toast.error(t("verifyFailed")); } }; + const handleFlagToggle = async (userId: string, flagged: boolean) => { + try { + const res = await fetch(`/api/admin/users/${userId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ flagged, flaggedReason: flagged ? "Unusual activity" : null }), + }); + + if (!res.ok) throw new Error("Failed to update flag status"); + + toast.success(flagged ? t("flagged") : t("unflagged")); + fetchUsers(currentPage, searchQuery, userFilter); + router.refresh(); + } catch { + toast.error(t("flagFailed")); + } + }; + const handleDelete = async () => { if (!deleteUserId) return; @@ -195,6 +217,7 @@ export function UsersTable() { {t("filters.user")} {t("filters.verified")} {t("filters.unverified")} + {t("filters.flagged")}
@@ -241,6 +264,7 @@ export function UsersTable() {
{user.name || user.username} {user.verified && } + {user.flagged && }
@{user.username}
@@ -267,6 +291,10 @@ export function UsersTable() { {user.verified ? t("unverify") : t("verify")} + handleFlagToggle(user.id, !user.flagged)}> + + {user.flagged ? t("unflag") : t("flag")} + {user.name || user.username} {user.verified && } + {user.flagged && }
@{user.username}
@@ -360,6 +389,10 @@ export function UsersTable() { {user.verified ? t("unverify") : t("verify")}
+ handleFlagToggle(user.id, !user.flagged)}> + + {user.flagged ? t("unflag") : t("flag")} +