diff --git a/apps/cursor/src/actions/review-plugin.ts b/apps/cursor/src/actions/review-plugin.ts index 28b3e790..e7fe2f1e 100644 --- a/apps/cursor/src/actions/review-plugin.ts +++ b/apps/cursor/src/actions/review-plugin.ts @@ -36,6 +36,37 @@ export const approvePluginAction = adminActionClient return { success: true }; }); +/** + * Admin counterpart to the owner's unpublish: hides the plugin from the + * directory without blocking it, so the owner (or an admin) can re-publish. + * Use `confirmFlagAction` instead when the plugin should stay down. + */ +export const unpublishPluginAction = adminActionClient + .metadata({ actionName: "unpublish-plugin" }) + .schema(z.object({ pluginId: z.string().uuid() })) + .action(async ({ parsedInput: { pluginId } }) => { + const supabase = await createClient(); + + const { data: plugin, error } = await supabase + .from("plugins") + .update({ active: false }) + .eq("id", pluginId) + .select("slug") + .single(); + + if (error || !plugin) { + throw new ActionError( + `Failed to unpublish plugin: ${error?.message ?? "not found"}`, + ); + } + + revalidatePath("/admin/plugins"); + updateTag("plugins"); + updateTag(`plugin-${plugin.slug}`); + + return { success: true }; + }); + export const declinePluginAction = adminActionClient .metadata({ actionName: "decline-plugin" }) .schema(z.object({ pluginId: z.string().uuid() })) diff --git a/apps/cursor/src/components/plugins/plugin-admin-panel.tsx b/apps/cursor/src/components/plugins/plugin-admin-panel.tsx new file mode 100644 index 00000000..c30b93b4 --- /dev/null +++ b/apps/cursor/src/components/plugins/plugin-admin-panel.tsx @@ -0,0 +1,397 @@ +"use client"; + +import { + Check, + EyeOff, + Loader2, + RefreshCw, + ShieldAlert, + ShieldCheck, + Trash2, +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useAction } from "next-safe-action/hooks"; +import { type ReactNode, useEffect, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { + approveFlaggedPluginAction, + confirmFlagAction, + rescanPluginAction, +} from "@/actions/review-flagged-plugin"; +import { + approvePluginAction, + declinePluginAction, + unpublishPluginAction, +} from "@/actions/review-plugin"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button, buttonVariants } from "@/components/ui/button"; +import type { FlagSeverity, PluginRow, ScanStatus } from "@/lib/plugins/types"; +import { cn } from "@/lib/utils"; +import { isAdminClient } from "@/utils/admin"; +import { createClient } from "@/utils/supabase/client"; + +const severityClass: Record = { + high: "bg-red-500/15 text-red-500 border-red-500/30", + medium: "bg-amber-500/15 text-amber-500 border-amber-500/30", + low: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", +}; + +const scanStatusClass: Record = { + flagged: "border-red-500/30 bg-red-500/10 text-red-500", + error: "border-amber-500/30 bg-amber-500/10 text-amber-500", + pending: "border-border bg-muted text-muted-foreground", + scanning: "border-border bg-muted text-muted-foreground", + safe: "border-border bg-muted text-muted-foreground", + unscanned: "border-border bg-muted text-muted-foreground", +}; + +function StatusBadge({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +type Props = { + plugin: PluginRow; +}; + +/** + * Moderation toolbar shown only to admins on the public plugin page, so the + * queue actions (approve, re-scan, confirm flag, unpublish, delete) don't + * require a round trip to /admin/plugins. Rendering is gated client-side via + * NEXT_PUBLIC_ADMIN_USER_IDS; every action is enforced server-side by + * `adminActionClient`. + */ +export function PluginAdminPanel({ plugin }: Props) { + const router = useRouter(); + const [isAdmin, setIsAdmin] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [isRefreshing, startRefresh] = useTransition(); + + useEffect(() => { + const supabase = createClient(); + supabase.auth.getSession().then(({ data: { session } }) => { + setIsAdmin(isAdminClient(session?.user.id ?? null)); + }); + }, []); + + // The actions invalidate the detail cache tag; refresh so the panel and the + // rest of the page re-render with the new row. The transition keeps the + // buttons disabled until the fresh data has actually arrived. + const refresh = () => startRefresh(() => router.refresh()); + + const { execute: approve, isExecuting: isApproving } = useAction( + approvePluginAction, + { + onSuccess: () => { + toast.success(`"${plugin.name}" approved and now live.`); + refresh(); + }, + onError: ({ error }) => { + toast.error(error.serverError ?? "Failed to approve plugin."); + }, + }, + ); + + const { execute: approveFlagged, isExecuting: isApprovingFlagged } = + useAction(approveFlaggedPluginAction, { + onSuccess: () => { + toast.success(`"${plugin.name}" approved and now live.`); + refresh(); + }, + onError: ({ error }) => { + toast.error(error.serverError ?? "Failed to approve plugin."); + }, + }); + + const { execute: unpublish, isExecuting: isUnpublishing } = useAction( + unpublishPluginAction, + { + onSuccess: () => { + toast.success(`"${plugin.name}" unpublished.`); + refresh(); + }, + onError: ({ error }) => { + toast.error(error.serverError ?? "Failed to unpublish plugin."); + }, + }, + ); + + const { execute: rescan, isExecuting: isRescanning } = useAction( + rescanPluginAction, + { + onSuccess: () => { + toast.success(`Re-scanning "${plugin.name}"…`); + refresh(); + }, + onError: ({ error }) => { + toast.error(error.serverError ?? "Failed to enqueue re-scan."); + }, + }, + ); + + const { execute: confirmFlag, isExecuting: isConfirming } = useAction( + confirmFlagAction, + { + onSuccess: () => { + toast.success(`"${plugin.name}" permanently blocked.`); + refresh(); + }, + onError: ({ error }) => { + toast.error(error.serverError ?? "Failed to confirm flag."); + }, + }, + ); + + const { execute: deletePlugin, isExecuting: isDeleting } = useAction( + declinePluginAction, + { + onSuccess: () => { + toast.success(`"${plugin.name}" deleted.`); + setConfirmDelete(false); + router.push("/admin/plugins"); + }, + onError: ({ error }) => { + toast.error(error.serverError ?? "Failed to delete plugin."); + }, + }, + ); + + if (!isAdmin) { + return null; + } + + const busy = + isApproving || + isApprovingFlagged || + isUnpublishing || + isRescanning || + isConfirming || + isDeleting || + isRefreshing; + + const status = plugin.scan_status; + const isFlagged = status === "flagged"; + // A blocked plugin must go through the flagged path so approving also + // clears `permanently_blocked`; plain approve would leave the block in + // place and the next scan would hide it again. + const needsFlagReview = isFlagged || plugin.permanently_blocked; + const isScanRunning = status === "pending" || status === "scanning"; + const verdict = plugin.scan_verdict?.verdict; + const reasons = isFlagged ? (plugin.flag_reasons ?? []) : []; + // `flag_summary` carries the agent's summary when flagged and the error + // message when the scan failed; both are useful to an admin. + const summary = isFlagged || status === "error" ? plugin.flag_summary : null; + + return ( + <> +
+
+
+
+ + + Admin + + + {plugin.active ? "Live" : "Hidden"} + + + {isScanRunning && } + scan: {status} + + {isFlagged && plugin.flag_severity && ( + + {plugin.flag_severity} + + )} + {isFlagged && verdict && verdict !== "safe" && ( + + {verdict} + + )} + {plugin.permanently_blocked && ( + + Blocked + + )} + + Open queue + +
+ + {summary && ( +
+ +

{summary}

+
+ )} + + {reasons.length > 0 && ( +
    + {reasons.map((reason) => ( +
  • + • + {reason} +
  • + ))} +
+ )} +
+ +
+ {needsFlagReview ? ( + + ) : plugin.active ? ( + + ) : ( + + )} + + + + {isFlagged && !plugin.permanently_blocked && ( + + )} + + +
+
+
+ + + + + Delete plugin permanently? + + “{plugin.name}” and all of its components will be + removed from the directory. This cannot be undone. + {plugin.active ? " The plugin is currently published." : null} + + + + Cancel + { + e.preventDefault(); + deletePlugin({ pluginId: plugin.id }); + }} + > + {isDeleting ? ( + + ) : null} + Delete + + + + + + ); +} diff --git a/apps/cursor/src/components/plugins/plugin-detail.tsx b/apps/cursor/src/components/plugins/plugin-detail.tsx index a5b8f11d..6bc0fccc 100644 --- a/apps/cursor/src/components/plugins/plugin-detail.tsx +++ b/apps/cursor/src/components/plugins/plugin-detail.tsx @@ -19,6 +19,7 @@ import { McpSection } from "./detail/mcp-section"; import { PluginLogo } from "./detail/plugin-logo"; import { RulesSection } from "./detail/rules-section"; import { ScanStatusBanner } from "./detail/scan-status-banner"; +import { PluginAdminPanel } from "./plugin-admin-panel"; import { PluginOwnerMenu } from "./plugin-owner-menu"; import { StarButton } from "./star-button"; import { VerifiedBadge } from "./verified-badge"; @@ -75,6 +76,7 @@ export function PluginDetailView({ plugin }: { plugin: PluginRow }) { return (
+ {isOwner && !plugin.active && (