SkateSpots/src/components/DeleteEntityDialog.tsx
shakespeare.diy 13eece3a1e Admins can delete/take down any listing (spot, crew, tour, event, shop)
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>
2026-08-18 17:51:34 +07:00

169 lines
6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
</>
);
}