From 5e1c31e21a2966b769062f7e9733d44df84ea2d9 Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Fri, 29 May 2026 09:45:20 +0200 Subject: [PATCH] Add plugin owner menu with publish, delete, and verification actions. Owners manage listings from a compact options menu with confirmation dialogs, and can delete published plugins after confirming in an alert dialog. Co-authored-by: Cursor --- apps/cursor/package.json | 1 + apps/cursor/src/actions/delete-plugin.ts | 48 +++ .../src/actions/toggle-plugin-listing.ts | 57 ++++ .../src/app/plugins/[slug]/edit/page.tsx | 4 +- .../src/components/plugins/plugin-card.tsx | 6 + .../src/components/plugins/plugin-detail.tsx | 19 +- .../components/plugins/plugin-owner-menu.tsx | 274 ++++++++++++++++++ .../components/plugins/verify-controls.tsx | 124 +++----- .../components/profile/profile-plugins.tsx | 4 +- .../cursor/src/components/ui/alert-dialog.tsx | 144 +++++++++ .../src/components/ui/dropdown-menu.tsx | 2 +- apps/cursor/src/data/queries.ts | 16 +- bun.lock | 90 +----- 13 files changed, 611 insertions(+), 178 deletions(-) create mode 100644 apps/cursor/src/actions/delete-plugin.ts create mode 100644 apps/cursor/src/actions/toggle-plugin-listing.ts create mode 100644 apps/cursor/src/components/plugins/plugin-owner-menu.tsx create mode 100644 apps/cursor/src/components/ui/alert-dialog.tsx diff --git a/apps/cursor/package.json b/apps/cursor/package.json index 4514fc09..811e9f34 100644 --- a/apps/cursor/package.json +++ b/apps/cursor/package.json @@ -14,6 +14,7 @@ "dependencies": { "@cursor/sdk": "^1.0.12", "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/apps/cursor/src/actions/delete-plugin.ts b/apps/cursor/src/actions/delete-plugin.ts new file mode 100644 index 00000000..59c94ddb --- /dev/null +++ b/apps/cursor/src/actions/delete-plugin.ts @@ -0,0 +1,48 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { createClient } from "@/utils/supabase/server"; +import { ActionError, authActionClient } from "./safe-action"; + +export const deletePluginAction = authActionClient + .metadata({ + actionName: "delete-plugin", + }) + .schema( + z.object({ + id: z.string().uuid(), + }), + ) + .action(async ({ parsedInput: { id }, ctx: { userId } }) => { + const supabase = await createClient(); + + const { data: existing, error: fetchError } = await supabase + .from("plugins") + .select("id, owner_id, slug, active") + .eq("id", id) + .single(); + + if (fetchError || !existing) { + throw new ActionError("Plugin not found."); + } + + if (existing.owner_id !== userId) { + throw new ActionError("You do not have permission to delete this plugin."); + } + + const { error } = await supabase + .from("plugins") + .delete() + .eq("id", id) + .eq("owner_id", userId); + + if (error) { + throw new ActionError(`Failed to delete plugin: ${error.message}`); + } + + revalidatePath("/"); + revalidatePath("/admin/plugins"); + + return { slug: existing.slug }; + }); diff --git a/apps/cursor/src/actions/toggle-plugin-listing.ts b/apps/cursor/src/actions/toggle-plugin-listing.ts new file mode 100644 index 00000000..77e6ac12 --- /dev/null +++ b/apps/cursor/src/actions/toggle-plugin-listing.ts @@ -0,0 +1,57 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { createClient } from "@/utils/supabase/server"; +import { ActionError, authActionClient } from "./safe-action"; + +export const togglePluginListingAction = authActionClient + .metadata({ + actionName: "toggle-plugin-listing", + }) + .schema( + z.object({ + id: z.string().uuid(), + active: z.boolean(), + }), + ) + .action(async ({ parsedInput: { id, active }, ctx: { userId } }) => { + const supabase = await createClient(); + + const { data: existing, error: fetchError } = await supabase + .from("plugins") + .select("id, owner_id, slug, permanently_blocked") + .eq("id", id) + .single(); + + if (fetchError || !existing) { + throw new ActionError("Plugin not found."); + } + + if (existing.owner_id !== userId) { + throw new ActionError("You do not have permission to update this plugin."); + } + + if (active && existing.permanently_blocked) { + throw new ActionError( + "This plugin cannot be published. Contact support if you believe this is a mistake.", + ); + } + + const { data, error } = await supabase + .from("plugins") + .update({ active }) + .eq("id", id) + .eq("owner_id", userId) + .select("slug") + .single(); + + if (error) { + throw new ActionError(error.message); + } + + revalidatePath("/"); + revalidatePath(`/plugins/${data.slug}`); + + return data; + }); diff --git a/apps/cursor/src/app/plugins/[slug]/edit/page.tsx b/apps/cursor/src/app/plugins/[slug]/edit/page.tsx index 89db34f4..be2be469 100644 --- a/apps/cursor/src/app/plugins/[slug]/edit/page.tsx +++ b/apps/cursor/src/app/plugins/[slug]/edit/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { Suspense } from "react"; import { EditPluginForm } from "@/components/forms/edit-plugin-form"; +import { PluginOwnerMenu } from "@/components/plugins/plugin-owner-menu"; import { Login } from "@/components/login"; import { getPluginBySlug } from "@/data/queries"; import { getSession } from "@/utils/supabase/auth"; @@ -41,8 +42,9 @@ export default async function Page({ params }: { params: Params }) { return (
-
+

Edit Plugin

+
diff --git a/apps/cursor/src/components/plugins/plugin-card.tsx b/apps/cursor/src/components/plugins/plugin-card.tsx index e24ad484..17fafb13 100644 --- a/apps/cursor/src/components/plugins/plugin-card.tsx +++ b/apps/cursor/src/components/plugins/plugin-card.tsx @@ -16,6 +16,7 @@ export type PluginCardData = { keywords?: string[]; installCount?: number; verified?: boolean; + unpublished?: boolean; href: string; }; @@ -57,6 +58,11 @@ export function PluginCard({ plugin }: { plugin: PluginCardData }) {

{plugin.name} {plugin.verified && } + {plugin.unpublished && ( + + Unpublished + + )}

diff --git a/apps/cursor/src/components/plugins/plugin-detail.tsx b/apps/cursor/src/components/plugins/plugin-detail.tsx index 36863d53..e882be7f 100644 --- a/apps/cursor/src/components/plugins/plugin-detail.tsx +++ b/apps/cursor/src/components/plugins/plugin-detail.tsx @@ -6,7 +6,6 @@ import { Copy, Download, Loader2, - Pencil, ShieldAlert, } from "lucide-react"; import Image from "next/image"; @@ -23,6 +22,7 @@ import { createClient } from "@/utils/supabase/client"; import { PluginIconFallback } from "./plugin-icon"; import { StarButton } from "./star-button"; import { VerifiedBadge } from "./verified-badge"; +import { PluginOwnerMenu } from "./plugin-owner-menu"; import { VerifyControls } from "./verify-controls"; function isValidImageUrl(url: string | null): url is string { @@ -255,6 +255,13 @@ export function PluginDetailView({ plugin }: { plugin: PluginRow }) {
+ {isOwner && !plugin.active && ( +
+ + This plugin is unpublished and hidden from the directory. + +
+ )}
@@ -267,19 +274,11 @@ export function PluginDetailView({ plugin }: { plugin: PluginRow }) {
+ {formatCount(installCount)} - {isOwner && ( - - - Edit - - )} { + const supabase = createClient(); + supabase.auth.getSession().then(({ data: { session } }) => { + const userId = session?.user.id ?? null; + setIsOwner(!!userId && plugin.owner_id === userId); + }); + }, [plugin.owner_id]); + + useEffect(() => { + setActive(plugin.active); + }, [plugin.active]); + + const requestPending = !!plugin.verification_requested_at; + + const { execute: requestVerification, isExecuting: isRequesting } = useAction( + requestPluginVerificationAction, + { + onSuccess: () => { + toast.success("Verification requested. We'll review it shortly."); + router.refresh(); + }, + onError: ({ error }) => { + toast.error( + error.serverError ?? "Failed to submit verification request.", + ); + }, + }, + ); + + const { execute: toggleListing, isExecuting: isToggling } = useAction( + togglePluginListingAction, + { + onSuccess: ({ input }) => { + setActive(input.active); + toast.success( + input.active ? "Plugin is now published." : "Plugin unpublished.", + ); + setConfirmUnpublish(false); + router.refresh(); + }, + onError: ({ error }) => { + toast.error(error.serverError ?? "Failed to update listing status."); + }, + }, + ); + + const { execute: deletePlugin, isExecuting: isDeleting } = useAction( + deletePluginAction, + { + onSuccess: () => { + toast.success("Plugin deleted."); + setConfirmDelete(false); + router.push("/"); + }, + onError: ({ error }) => { + toast.error(error.serverError ?? "Failed to delete plugin."); + }, + }, + ); + + if (!isOwner) { + return null; + } + + const busy = isRequesting || isToggling || isDeleting; + const canVerify = !plugin.verified && !requestPending; + const canPublish = !active && !plugin.permanently_blocked; + + return ( + <> + + + + + + + + + Edit + + + + {canVerify ? ( + requestVerification({ pluginId: plugin.id })} + > + + Submit for verification + + ) : null} + + {requestPending && !plugin.verified ? ( + + + Verification requested + + ) : null} + + {active ? ( + setConfirmUnpublish(true)} + > + + Unpublish + + ) : canPublish ? ( + toggleListing({ id: plugin.id, active: true })} + > + + Publish + + ) : null} + + + + setConfirmDelete(true)} + > + + Delete + + + + + + + + Unpublish plugin? + + “{plugin.name}” will be hidden from the directory. You + can still edit it or publish again later. + + + + + + + + + + + + + Delete plugin permanently? + + “{plugin.name}” and all of its components will be + removed from the directory. This cannot be undone. + {active ? " The plugin is currently published." : null} + + + + Cancel + { + e.preventDefault(); + deletePlugin({ id: plugin.id }); + }} + > + {isDeleting ? ( + + ) : null} + Delete + + + + + + ); +} diff --git a/apps/cursor/src/components/plugins/verify-controls.tsx b/apps/cursor/src/components/plugins/verify-controls.tsx index 88ddf3c3..36a95ec6 100644 --- a/apps/cursor/src/components/plugins/verify-controls.tsx +++ b/apps/cursor/src/components/plugins/verify-controls.tsx @@ -4,7 +4,6 @@ import { BadgeCheck, BadgeMinus, Check, Loader2, X } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { requestPluginVerificationAction } from "@/actions/request-plugin-verification"; import { dismissVerificationRequestAction, setPluginVerifiedAction, @@ -19,34 +18,18 @@ type Props = { }; export function VerifyControls({ plugin }: Props) { - const [isOwner, setIsOwner] = useState(false); const [isAdmin, setIsAdmin] = useState(false); useEffect(() => { const supabase = createClient(); supabase.auth.getSession().then(({ data: { session } }) => { const userId = session?.user.id ?? null; - setIsOwner(!!userId && plugin.owner_id === userId); setIsAdmin(isAdminClient(userId)); }); - }, [plugin.owner_id]); + }, []); const requestPending = !!plugin.verification_requested_at; - const { execute: requestVerification, isExecuting: isRequesting } = useAction( - requestPluginVerificationAction, - { - onSuccess: () => { - toast.success("Verification requested. We'll review it shortly."); - }, - onError: ({ error }) => { - toast.error( - error.serverError ?? "Failed to submit verification request.", - ); - }, - }, - ); - const { execute: setVerified, isExecuting: isSettingVerified } = useAction( setPluginVerifiedAction, { @@ -73,88 +56,59 @@ export function VerifyControls({ plugin }: Props) { }, ); - const busy = isRequesting || isSettingVerified || isDismissing; - - // Admin controls take priority — admins acting on their own plugin should - // see the admin actions, not the owner submit button. - if (isAdmin) { - if (plugin.verified) { - return ( - - ); - } + const busy = isSettingVerified || isDismissing; - if (requestPending) { - return ( -
- - -
- ); - } + if (!isAdmin) { + return null; + } + if (plugin.verified) { return ( ); } - if (!isOwner || plugin.verified) { - return null; - } - if (requestPending) { return ( - - - Verification requested - +
+ + +
); } @@ -163,14 +117,14 @@ export function VerifyControls({ plugin }: Props) { variant="outline" size="sm" disabled={busy} - onClick={() => requestVerification({ pluginId: plugin.id })} + onClick={() => setVerified({ pluginId: plugin.id, verified: true })} > - {isRequesting ? ( + {isSettingVerified ? ( ) : ( )} - Submit for verification + Mark verified ); } diff --git a/apps/cursor/src/components/profile/profile-plugins.tsx b/apps/cursor/src/components/profile/profile-plugins.tsx index 09be3ede..4bcc3625 100644 --- a/apps/cursor/src/components/profile/profile-plugins.tsx +++ b/apps/cursor/src/components/profile/profile-plugins.tsx @@ -24,6 +24,8 @@ function toPluginCard(plugin: PluginRow): PluginCardData { mcpCount: components.filter((c) => c.type === "mcp_server").length, keywords: plugin.keywords, installCount: plugin.install_count, + verified: plugin.verified, + unpublished: !plugin.active, href: `/plugins/${plugin.slug}`, }; } @@ -35,7 +37,7 @@ export async function ProfilePlugins({ userId: string; isOwner: boolean; }) { - const { data } = await getUserPlugins(userId); + const { data } = await getUserPlugins(userId, { includeInactive: isOwner }); const plugins = (data ?? []).map(toPluginCard); if (!plugins?.length) { diff --git a/apps/cursor/src/components/ui/alert-dialog.tsx b/apps/cursor/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..22823383 --- /dev/null +++ b/apps/cursor/src/components/ui/alert-dialog.tsx @@ -0,0 +1,144 @@ +"use client"; + +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import * as React from "react"; + +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/apps/cursor/src/components/ui/dropdown-menu.tsx b/apps/cursor/src/components/ui/dropdown-menu.tsx index 9a940646..c099dbae 100644 --- a/apps/cursor/src/components/ui/dropdown-menu.tsx +++ b/apps/cursor/src/components/ui/dropdown-menu.tsx @@ -83,7 +83,7 @@ const DropdownMenuItem = React.forwardRef<