The two SkateSpots admin pubkeys (already in src/lib/admins.ts) can now remove any listing, not just their own. Because a NIP-09 deletion signed by a non-author isn't honored by most relays, admin takedowns are made reliable with a client-side moderation layer: - useDeleteEntity: owners still publish a plain NIP-09 kind:5. Admins acting on someone else's content publish the kind:5 AND a NIP-32 kind:1985 `hidden` label referencing the target coordinate/id, authored by the admin. - useAdminModeration (new): fetches admin-authored `hidden` labels app-wide and exposes a fast lookup + filterAdminHidden() helper. Only labels whose author is in the hardcoded ADMIN_PUBKEYS are honored (author = trust boundary, re-verified client-side; never derived from a d-tag). - Wired the hide filter into the list + detail hooks for spots, crews (incl. by-focus), tours, events, and shops via React Query `select`, so removed content disappears from lists and 404s on its detail page. UI: - DeleteEntityDialog now renders for owner OR admin, with distinct "Remove (admin)" framing and copy (used on Crew, Coach, Tour, Event pages). - SpotDetailPage & ShopDetailPage: routed their inline delete through useDeleteEntity, show the delete button for admins, and relabel the confirm dialog for admin takedowns. - Added the delete control to Tour and Event detail pages (previously none). SECURITY.md: documented the admin moderation trust boundary and extended the auditor file map. Verified build + no console errors. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
169 lines
6 KiB
TypeScript
169 lines
6 KiB
TypeScript
import { useState, type ReactNode } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { Trash2, Loader2, AlertTriangle } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from '@/components/ui/alert-dialog';
|
||
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useToast } from '@/hooks/useToast';
|
||
import { isAdminPubkey } from '@/lib/admins';
|
||
import { cn } from '@/lib/utils';
|
||
import type { NostrEvent } from '@nostrify/nostrify';
|
||
|
||
interface DeleteEntityDialogProps {
|
||
/** The addressable event to delete (crew, Lab profile, spot, …). */
|
||
event: NostrEvent;
|
||
/** Human label for the thing being deleted, e.g. "crew" or "coach profile". */
|
||
entityLabel: string;
|
||
/** The name/title shown in the confirmation copy. */
|
||
entityName: string;
|
||
/** Where to send the user after a successful delete (e.g. "/crews"). */
|
||
redirectTo?: string;
|
||
/** Verb shown on the trigger button (default "Delete"). */
|
||
triggerText?: string;
|
||
/** Optional custom trigger. If omitted a default outline button is rendered. */
|
||
trigger?: ReactNode;
|
||
/** Extra classes for the default trigger button. */
|
||
className?: string;
|
||
}
|
||
|
||
/**
|
||
* Reusable "delete / dissolve" control for any addressable event the current
|
||
* user owns. Publishes a NIP-09 deletion request (see useDeleteEntity) after an
|
||
* explicit confirmation, and is only rendered for the owner.
|
||
*/
|
||
export function DeleteEntityDialog({
|
||
event,
|
||
entityLabel,
|
||
entityName,
|
||
redirectTo,
|
||
triggerText = 'Delete',
|
||
trigger,
|
||
className,
|
||
}: DeleteEntityDialogProps) {
|
||
const { user } = useCurrentUser();
|
||
const { mutateAsync: deleteEntity, isPending } = useDeleteEntity();
|
||
const { toast } = useToast();
|
||
const navigate = useNavigate();
|
||
const [open, setOpen] = useState(false);
|
||
|
||
if (!user) return null;
|
||
|
||
const isOwner = user.pubkey === event.pubkey;
|
||
const isAdmin = isAdminPubkey(user.pubkey);
|
||
// The owner can delete their own event; a SkateSpots admin can take down any.
|
||
if (!isOwner && !isAdmin) return null;
|
||
|
||
// An admin acting on someone else's content is a moderation takedown.
|
||
const adminTakedown = isAdmin && !isOwner;
|
||
|
||
const handleDelete = async () => {
|
||
try {
|
||
await deleteEntity({
|
||
event,
|
||
reason: adminTakedown
|
||
? `SkateSpots admin removed ${entityLabel} "${entityName}"`
|
||
: `Deleted ${entityLabel} "${entityName}"`,
|
||
});
|
||
toast({
|
||
title: adminTakedown ? 'Content removed' : 'Deletion requested',
|
||
description: adminTakedown
|
||
? `This ${entityLabel} has been taken down and will be hidden across SkateSpots.`
|
||
: `Your ${entityLabel} has been removed. Relays that honor deletions will stop serving it.`,
|
||
});
|
||
setOpen(false);
|
||
if (redirectTo) navigate(redirectTo);
|
||
} catch (error) {
|
||
toast({
|
||
title: 'Delete failed',
|
||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{trigger ? (
|
||
<span onClick={() => setOpen(true)}>{trigger}</span>
|
||
) : (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setOpen(true)}
|
||
className={cn(
|
||
'gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10 hover:text-red-300',
|
||
className,
|
||
)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" /> {adminTakedown ? `Remove (admin)` : triggerText}
|
||
</Button>
|
||
)}
|
||
|
||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle className="flex items-center gap-2">
|
||
<Trash2 className="h-5 w-5 text-red-500" />
|
||
Delete "{entityName}"?
|
||
</AlertDialogTitle>
|
||
<AlertDialogDescription asChild>
|
||
<div className="space-y-3">
|
||
{adminTakedown ? (
|
||
<p>
|
||
As a SkateSpots admin you’re taking down this {entityLabel}. It will be hidden
|
||
across the app for everyone and a deletion request will be sent to relays. The
|
||
original author can’t restore it from here.
|
||
</p>
|
||
) : (
|
||
<p>
|
||
This asks relays to remove your {entityLabel}. This can’t be undone from here — you’d
|
||
have to create a new one.
|
||
</p>
|
||
)}
|
||
<div className="flex gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-sm">
|
||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-yellow-500" />
|
||
<p className="text-yellow-400/90">
|
||
Nostr is decentralized, so deletion is best-effort: relays that honor NIP-09 will
|
||
drop it, but a relay that doesn’t may keep a copy. SkateSpots hides admin-removed
|
||
content in the app regardless.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
onClick={handleDelete}
|
||
disabled={isPending}
|
||
className="bg-red-500 text-white hover:bg-red-600"
|
||
>
|
||
{isPending ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
Deleting…
|
||
</>
|
||
) : (
|
||
<>
|
||
<Trash2 className="mr-2 h-4 w-4" />
|
||
{triggerText}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</>
|
||
);
|
||
}
|