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>
This commit is contained in:
shakespeare.diy 2026-08-18 17:51:34 +07:00
parent 538ef67b0b
commit 13eece3a1e
13 changed files with 363 additions and 98 deletions

View file

@ -213,6 +213,19 @@ intentionally *not* author-filtered.
worst-case loss from a compromised connection.
- Prefer wallets/flows that let you set a spending budget.
### Admin moderation / takedowns
Two hardcoded admin pubkeys (`src/lib/admins.ts`, `ADMIN_PUBKEYS`) can take
down any listing (spot, crew, tour, event, shop, …). A takedown publishes both
a NIP-09 kind:5 deletion request **and** a NIP-32 kind:1985 `hidden` moderation
label authored by the admin. The client-side layer (`useAdminModeration`) then
hides that content across the app, so takedowns are reliable even when a relay
won't honor a cross-author NIP-09 request. **Trust boundary:** only labels whose
`pubkey` is in the hardcoded admin list are honored — this is re-verified
client-side in `useAdminModeration` and is never derived from a `d` tag or any
attacker-controllable value. Auditors should confirm the admin list is the only
source of moderation authority.
### Tooling
- A custom ESLint rule (`eslint-rules/no-inline-script.js`) forbids inline
@ -251,6 +264,7 @@ oversights:
| Login / key handling | `src/components/auth/`, Nostrify login (`nostr:login`) |
| Lightning / NWC | `src/hooks/useNWC.ts`, `src/components/WalletModal.tsx` |
| Cashu ecash (native only): proofs in IndexedDB `cashu-wallet`, seed in `cashu-mnemonic` | `src/hooks/wallet/useCashu.ts`, `src/lib/platform.ts` |
| Admin authority / moderation / takedowns | `src/lib/admins.ts`, `src/hooks/useAdminModeration.ts`, `src/hooks/useDeleteEntity.ts` |
| CSP & security headers | `index.html` (`<meta>` — covers mobile), `public/_headers`, `netlify.toml`, `public/boot-guard.js` |
| Native wrapper (same bundle) | `capacitor.config.ts` (`webDir: 'dist'`) |
| NIP-19 routing | `src/pages/NIP19Page.tsx`, `src/AppRouter.tsx` |

View file

@ -13,6 +13,7 @@ import {
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';
@ -53,15 +54,29 @@ export function DeleteEntityDialog({
const navigate = useNavigate();
const [open, setOpen] = useState(false);
// Only the owner can delete their own event.
if (!user || user.pubkey !== event.pubkey) return null;
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: `Deleted ${entityLabel} "${entityName}"` });
await deleteEntity({
event,
reason: adminTakedown
? `SkateSpots admin removed ${entityLabel} "${entityName}"`
: `Deleted ${entityLabel} "${entityName}"`,
});
toast({
title: 'Deletion requested',
description: `Your ${entityLabel} has been removed. Relays that honor deletions will stop serving it.`,
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);
@ -89,7 +104,7 @@ export function DeleteEntityDialog({
className,
)}
>
<Trash2 className="h-3.5 w-3.5" /> {triggerText}
<Trash2 className="h-3.5 w-3.5" /> {adminTakedown ? `Remove (admin)` : triggerText}
</Button>
)}
@ -102,15 +117,24 @@ export function DeleteEntityDialog({
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>
This asks relays to remove your {entityLabel}. This can’t be undone from here — you’d
have to create a new one.
</p>
{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.
drop it, but a relay that doesn’t may keep a copy. SkateSpots hides admin-removed
content in the app regardless.
</p>
</div>
</div>

View file

@ -0,0 +1,107 @@
import { useQuery } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { ADMIN_PUBKEYS } from '@/lib/admins';
import {
MODERATION_LABEL_NAMESPACE,
MODERATION_LABEL_HIDDEN,
} from '@/lib/types';
/**
* App-wide admin moderation layer.
*
* SkateSpots admins (see src/lib/admins.ts) can take down ANY listing (spot,
* crew, tour, event, shop, Lab profile, …). Because a NIP-09 deletion request
* signed by a non-author is not honored by most relays, admin takedowns also
* publish a NIP-32 kind:1985 `hidden` label referencing the target. This hook
* fetches those admin labels so the client can hide the content everywhere,
* reliably, regardless of relay NIP-09 behavior.
*
* Security: only labels authored by a hardcoded ADMIN pubkey are honored, so a
* random user cannot hide someone else's listing. The label authorship is the
* trust boundary (see .agents/skills/nostr-security §3).
*/
export interface AdminHiddenSet {
/** Hidden addressable coordinates `kind:pubkey:d`. */
coords: Set<string>;
/** Hidden concrete event ids. */
ids: Set<string>;
/** True if either set contains anything (cheap guard for callers). */
has: (opts: { coord?: string; id?: string }) => boolean;
}
const EMPTY: AdminHiddenSet = {
coords: new Set(),
ids: new Set(),
has: () => false,
};
/**
* Drop admin-hidden entities from a parsed list. Works for any entity that
* carries `{ id, pubkey, identifier }` (spots, crews, tours, shops, events, …).
* `kind` is the entity's Nostr kind, used to build the `a` coordinate.
*/
export function filterAdminHidden<
T extends { id: string; pubkey: string; identifier: string },
>(items: T[], kind: number, hidden: AdminHiddenSet): T[] {
if (hidden.coords.size === 0 && hidden.ids.size === 0) return items;
return items.filter(
(it) =>
!hidden.has({ coord: `${kind}:${it.pubkey}:${it.identifier}`, id: it.id }),
);
}
export function useAdminModeration() {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const query = useQuery({
queryKey: ['admin-moderation', getQueryKeySuffix()],
queryFn: async (): Promise<AdminHiddenSet> => {
const signal = AbortSignal.timeout(8000);
const events = await querySkateContent(
[
{
kinds: [1985],
authors: ADMIN_PUBKEYS,
'#l': [MODERATION_LABEL_HIDDEN],
limit: 1000,
},
],
{ signal },
);
const adminSet = new Set(ADMIN_PUBKEYS.map((p) => p.toLowerCase()));
const coords = new Set<string>();
const ids = new Set<string>();
for (const ev of events) {
// Defense in depth: re-verify author + namespace + label client-side.
if (!adminSet.has(ev.pubkey.toLowerCase())) continue;
const hasNamespace = ev.tags.some(
([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE,
);
const hasLabel = ev.tags.some(
([n, v]) => n === 'l' && v === MODERATION_LABEL_HIDDEN,
);
if (!hasNamespace || !hasLabel) continue;
for (const [n, v] of ev.tags) {
if (n === 'a' && v) coords.add(v);
if (n === 'e' && v) ids.add(v);
}
}
return {
coords,
ids,
has: ({ coord, id }) =>
(!!coord && coords.has(coord)) || (!!id && ids.has(id)),
};
},
staleTime: 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnMount: true,
});
return query.data ?? EMPTY;
}

View file

@ -8,12 +8,15 @@ import {
type CrewFocus,
} from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
export function useCrews(limit = 200) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-crews', limit, getQueryKeySuffix()],
select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
@ -50,9 +53,11 @@ export function useCrews(limit = 200) {
export function useCrewsByFocus(focus: Exclude<CrewFocus, 'mixed'>, limit = 30) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const focusTag = CREW_FOCUS_TAG[focus];
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-crews-focus', focus, limit, getQueryKeySuffix()],
select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
@ -84,9 +89,14 @@ export function useCrewsByFocus(focus: Exclude<CrewFocus, 'mixed'>, limit = 30)
export function useCrew(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-crew', identifier, pubkey, getQueryKeySuffix()],
select: (crew: SkateCrew | null) =>
crew && hidden.has({ coord: `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`, id: crew.id })
? null
: crew,
queryFn: async () => {
const signal = AbortSignal.timeout(10000);

View file

@ -1,23 +1,34 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isAdminPubkey } from '@/lib/admins';
import {
MODERATION_LABEL_NAMESPACE,
MODERATION_LABEL_HIDDEN,
} from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
/**
* NIP-09 deletion request for an ADDRESSABLE event (kinds 30000–39999) such as
* a skate crew, a Lab profile, a spot, etc.
* Deletion / takedown for an ADDRESSABLE event (kinds 30000–39999) such as a
* skate crew, a Lab profile, a spot, a tour, an event, etc.
*
* How deletion works on Nostr (important to set expectations):
* • We publish a kind:5 "deletion request" that references the target event by
* its `a` coordinate (kind:pubkey:d-tag) and its `e` id + `k` kind.
* • Relays that honor NIP-09 stop serving the referenced event and won't
* return it in future queries. This is how "delete" works in a
* decentralized network — there's no central database row to remove.
* • A relay that ignores NIP-09 may still serve a cached copy, and other
* users' relays may retain it. So deletion is best-effort, not a guarantee.
* Two callers, two mechanisms:
*
* Only the ORIGINAL AUTHOR can delete their own event — a deletion request is
* only valid when signed by the same pubkey that created the target.
* 1. The ORIGINAL AUTHOR deleting their own listing → NIP-09 kind:5 deletion
* request. Relays that honor NIP-09 stop serving the event. A deletion
* request is only valid when signed by the pubkey that created the target,
* so an author's request is respected network-wide.
*
* 2. A SkateSpots ADMIN (see src/lib/admins.ts) taking down someone else's
* content → we ALSO publish a kind:5 (honored by the community relay for
* its own stored events) AND a NIP-32 kind:1985 `hidden` label referencing
* the target coordinate. The label is authored by the admin, so the app's
* client-side moderation layer (useAdminModeration) can hide the content
* everywhere regardless of whether a given relay honors a cross-author
* NIP-09 request. This makes admin takedowns reliable inside the app.
*
* In all cases deletion on a decentralized network is best-effort: a relay that
* ignores NIP-09 may keep a cached copy, and other users' relays may retain it.
*/
export function useDeleteEntity() {
const { mutateAsync: publishEvent } = useNostrPublish();
@ -35,7 +46,10 @@ export function useDeleteEntity() {
reason?: string;
}) => {
if (!user) throw new Error('You must be logged in to delete.');
if (user.pubkey !== event.pubkey) {
const isOwner = user.pubkey === event.pubkey;
const isAdmin = isAdminPubkey(user.pubkey);
if (!isOwner && !isAdmin) {
throw new Error('You can only delete your own listing.');
}
@ -44,17 +58,37 @@ export function useDeleteEntity() {
// NIP-09: reference by both the addressable coordinate (`a`) and the
// concrete event id (`e`), plus the kind (`k`), for maximum relay support.
const tags: string[][] = [
const deletionTags: string[][] = [
['a', coord],
['e', event.id],
['k', String(event.kind)],
];
return publishEvent({
await publishEvent({
kind: 5,
content: reason?.trim() || '',
tags,
tags: deletionTags,
});
// Admin takedown of someone else's content: also publish a client-side
// moderation label so the app hides it even if relays won't honor a
// cross-author NIP-09 deletion.
if (isAdmin && !isOwner) {
const labelTags: string[][] = [
['L', MODERATION_LABEL_NAMESPACE],
['l', MODERATION_LABEL_HIDDEN, MODERATION_LABEL_NAMESPACE],
['a', coord],
['e', event.id],
['k', String(event.kind)],
['p', event.pubkey],
['alt', `SkateSpots admin takedown${reason?.trim() ? `: ${reason.trim()}` : ''}`],
];
await publishEvent({
kind: 1985,
content: reason?.trim() || 'Removed by SkateSpots admin',
tags: labelTags,
});
}
},
onSuccess: () => {
// Nudge every list/detail query to refetch so the deleted entity drops out.

View file

@ -1,12 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { CALENDAR_EVENT_KIND, parseSkateEvent, isFeaturedActive, type SkateEvent } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
export function useSkateEvents(limit = 50) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-events', limit, getQueryKeySuffix()],
select: (events: SkateEvent[]) => filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(15000);
@ -41,10 +44,12 @@ export function useSkateEvents(limit = 50) {
export function useUpcomingSkateEvents(limit = 20) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
const now = Math.floor(Date.now() / 1000);
return useQuery({
queryKey: ['upcoming-skate-events', limit, getQueryKeySuffix()],
select: (events: SkateEvent[]) => filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(15000);
@ -84,9 +89,14 @@ export function useUpcomingSkateEvents(limit = 20) {
export function useSkateEvent(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-event', identifier, pubkey, getQueryKeySuffix()],
select: (evt: SkateEvent | null) =>
evt && hidden.has({ coord: `${CALENDAR_EVENT_KIND}:${evt.pubkey}:${evt.identifier}`, id: evt.id })
? null
: evt,
queryFn: async () => {
const signal = AbortSignal.timeout(10000);

View file

@ -1,15 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_SHOP_KIND, parseSkateShop, type SkateShop } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
/**
* Hook to fetch skate shops using the skate relay logic
*/
export function useSkateShops(limit: number = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery<SkateShop[]>({
return useQuery<SkateShop[], Error, SkateShop[]>({
queryKey: ['skate-shops', limit, getQueryKeySuffix()],
select: (shops: SkateShop[]) => filterAdminHidden(shops, SKATE_SHOP_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
@ -41,9 +44,14 @@ export function useSkateShops(limit: number = 100) {
*/
export function useSkateShop(pubkey: string | undefined, identifier: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery<SkateShop | null>({
return useQuery<SkateShop | null, Error, SkateShop | null>({
queryKey: ['skate-shop', pubkey, identifier, getQueryKeySuffix()],
select: (shop: SkateShop | null) =>
shop && hidden.has({ coord: `${SKATE_SHOP_KIND}:${shop.pubkey}:${shop.identifier}`, id: shop.id })
? null
: shop,
queryFn: async () => {
if (!pubkey || !identifier) return null;

View file

@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache';
// Spots change infrequently — cache for 5 minutes, keep for 30 minutes
@ -10,9 +11,11 @@ const QUERY_TIMEOUT = 15000; // 15s — mobile cold-start needs mo
export function useSkateSpots(limit = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-spots', limit, getQueryKeySuffix()],
select: (spots: SkateSpot[]) => filterAdminHidden(spots, SKATE_SPOT_KIND, hidden),
queryFn: async () => {
// If offline, return cached spots
if (isOffline()) {
@ -60,9 +63,14 @@ export function useSkateSpots(limit = 100) {
export function useSkateSpot(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-spot', identifier, pubkey, getQueryKeySuffix()],
select: (spot: SkateSpot | null) =>
spot && hidden.has({ coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, id: spot.id })
? null
: spot,
queryFn: async () => {
// If offline, try to get from cache first
if (isOffline() && pubkey) {

View file

@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_TOUR_KIND, parseSkateTour, isFeaturedActive, type SkateTour } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
/** Sort featured (Pro) tours first, then newest. */
function sortTours(a: SkateTour, b: SkateTour): number {
@ -16,9 +17,11 @@ const TIMEOUT = 8000;
export function useTours(limit = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-tours', limit, getQueryKeySuffix()],
select: (tours: SkateTour[]) => filterAdminHidden(tours, SKATE_TOUR_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(TIMEOUT);
@ -49,9 +52,14 @@ export function useTours(limit = 100) {
export function useTour(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-tour', identifier, pubkey, getQueryKeySuffix()],
select: (tour: SkateTour | null) =>
tour && hidden.has({ coord: `${SKATE_TOUR_KIND}:${tour.pubkey}:${tour.identifier}`, id: tour.id })
? null
: tour,
queryFn: async () => {
const signal = AbortSignal.timeout(TIMEOUT);

View file

@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { ZapButton } from '@/components/ZapButton';
import { CommentsSection } from '@/components/comments/CommentsSection';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { useSkateEvent } from '@/hooks/useSkateEvents';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
@ -194,14 +195,22 @@ export default function EventDetailPage() {
return (
<Layout>
<div className="container px-4 py-8">
{/* Back link */}
<Link
to="/events"
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Events
</Link>
{/* Back link + owner/admin controls */}
<div className="mb-6 flex items-center justify-between gap-2">
<Link
to="/events"
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Events
</Link>
<DeleteEntityDialog
event={event.event}
entityLabel="event"
entityName={event.title}
redirectTo="/events"
/>
</div>
<div className="grid lg:grid-cols-3 gap-8">
{/* Main Content */}

View file

@ -17,7 +17,9 @@ import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/saniti
import { OfficialBadge } from '@/components/OfficialBadge';
import { useShop } from '@/hooks/useShops';
import { useAuthor } from '@/hooks/useAuthor';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
import { isAdminPubkey } from '@/lib/admins';
import { cn } from '@/lib/utils';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import NotFound from './NotFound';
@ -78,13 +80,17 @@ export default function ShopDetailPage() {
const { data: shop, isLoading, error } = useShop(identifier || '', pubkey);
const author = useAuthor(shop?.pubkey);
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const { mutateAsync: deleteEntity } = useDeleteEntity();
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const isOwner = user && shop && user.pubkey === shop.pubkey;
const isAdmin = isAdminPubkey(user?.pubkey);
// Owner can delete their own shop; a SkateSpots admin can take down any shop.
const canDelete = !!user && !!shop && (isOwner || isAdmin);
const adminTakedown = isAdmin && !isOwner;
const hasNip05 = user?.metadata?.nip05;
useSeoMeta({
@ -144,19 +150,19 @@ export default function ShopDetailPage() {
if (!shop || !user) return;
try {
await publishEvent({
kind: 5,
content: 'Deleted shop',
tags: [
['e', shop.id],
['a', `${shop.event.kind}:${shop.pubkey}:${shop.identifier}`],
],
// Routes through useDeleteEntity: owner → NIP-09 delete; admin → NIP-09
// delete + a moderation label so the shop is hidden across the app.
await deleteEntity({
event: shop.event,
reason: adminTakedown ? `SkateSpots admin removed shop "${shop.name}"` : 'Deleted shop',
});
setShowDeleteDialog(false);
toast({
title: 'Shop deleted',
description: 'Deletion request sent to relays. Most relays will remove this content.',
title: adminTakedown ? 'Shop removed' : 'Shop deleted',
description: adminTakedown
? 'This shop has been taken down and will be hidden across SkateSpots.'
: 'Deletion request sent to relays. Most relays will remove this content.',
});
queryClient.invalidateQueries({ queryKey: ['skate-shops'] });
@ -257,21 +263,25 @@ export default function ShopDetailPage() {
</div>
<div className="flex items-center gap-2">
{isOwner && (
<>
<Link to={`/edit-shop/${shop.pubkey}/${shop.identifier}`}>
<Button variant="outline" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</Link>
<Button
variant="outline"
size="icon"
onClick={() => setShowDeleteDialog(true)}
className="hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="h-4 w-4" />
<Link to={`/edit-shop/${shop.pubkey}/${shop.identifier}`}>
<Button variant="outline" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</>
</Link>
)}
{canDelete && (
<Button
variant="outline"
size="icon"
onClick={() => setShowDeleteDialog(true)}
title={adminTakedown ? 'Remove (admin)' : 'Delete'}
className={cn(
'hover:bg-destructive hover:text-destructive-foreground',
adminTakedown && 'border-red-500/50 text-red-400',
)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
<Button variant="outline" size="icon" onClick={handleShare}>
<Share2 className="h-4 w-4" />
@ -478,10 +488,12 @@ export default function ShopDetailPage() {
<AlertDialogHeader>
<AlertDialogTitle className="text-white flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
Delete Shop?
{adminTakedown ? 'Remove this shop? (admin)' : 'Delete Shop?'}
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-300">
This will permanently delete "{shop.name}" from all relays. This action cannot be undone.
{adminTakedown
? `As a SkateSpots admin you're taking down "${shop.name}". It will be hidden across the app for everyone and a deletion request will be sent to relays.`
: `This will permanently delete "${shop.name}" from all relays. This action cannot be undone.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@ -490,7 +502,7 @@ export default function ShopDetailPage() {
onClick={handleDelete}
className="bg-red-500 hover:bg-red-600 text-white"
>
Delete Shop
{adminTakedown ? 'Remove Shop' : 'Delete Shop'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -16,7 +16,9 @@ import { SpotChat } from '@/components/SpotChat';
import { CrewsForSpotCard } from '@/components/CrewsForSpotCard';
import { useSkateSpot } from '@/hooks/useSkateSpots';
import { useAuthor } from '@/hooks/useAuthor';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
import { isAdminPubkey } from '@/lib/admins';
import { cn } from '@/lib/utils';
import { useToast } from '@/hooks/useToast';
import { useSpotWeather } from '@/hooks/useSpotWeather';
import { useMarketplaceAtSpot } from '@/hooks/useMarketplace';
@ -121,7 +123,7 @@ export default function SpotDetailPage() {
const { data: spotServices, isLoading: isLoadingServices } = useSpotServices(pubkey, identifier);
const author = useAuthor(spot?.pubkey);
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const { mutateAsync: deleteEntity } = useDeleteEntity();
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
@ -131,6 +133,10 @@ export default function SpotDetailPage() {
const [showStatusDialog, setShowStatusDialog] = useState(false);
const isOwner = user && spot && user.pubkey === spot.pubkey;
const isAdmin = isAdminPubkey(user?.pubkey);
// Owner can delete their own spot; a SkateSpots admin can take down any spot.
const canDelete = !!user && !!spot && (isOwner || isAdmin);
const adminTakedown = isAdmin && !isOwner;
const isShop = spot?.types.includes('shop');
const hasNip05 = user?.metadata?.nip05;
@ -192,19 +198,19 @@ export default function SpotDetailPage() {
if (!spot || !user) return;
try {
await publishEvent({
kind: 5,
content: 'Deleted spot',
tags: [
['e', spot.id],
['a', `${spot.event.kind}:${spot.pubkey}:${spot.identifier}`],
],
// Routes through useDeleteEntity: owner → NIP-09 delete; admin → NIP-09
// delete + a moderation label so the spot is hidden across the app.
await deleteEntity({
event: spot.event,
reason: adminTakedown ? `SkateSpots admin removed spot "${spot.title}"` : 'Deleted spot',
});
setShowDeleteDialog(false);
toast({
title: 'Spot deleted',
description: 'Deletion request sent to relays. Most relays will remove this content.',
title: adminTakedown ? 'Spot removed' : 'Spot deleted',
description: adminTakedown
? 'This spot has been taken down and will be hidden across SkateSpots.'
: 'Deletion request sent to relays. Most relays will remove this content.',
});
queryClient.invalidateQueries({ queryKey: ['skate-spots'] });
@ -310,21 +316,25 @@ export default function SpotDetailPage() {
</div>
<div className="flex items-center gap-2 flex-wrap">
{isOwner && (
<>
<Link to={`/edit-spot/${spot.pubkey}/${spot.identifier}`}>
<Button variant="outline" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</Link>
<Button
variant="outline"
size="icon"
onClick={() => setShowDeleteDialog(true)}
className="hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="h-4 w-4" />
<Link to={`/edit-spot/${spot.pubkey}/${spot.identifier}`}>
<Button variant="outline" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</>
</Link>
)}
{canDelete && (
<Button
variant="outline"
size="icon"
onClick={() => setShowDeleteDialog(true)}
title={adminTakedown ? 'Remove (admin)' : 'Delete'}
className={cn(
'hover:bg-destructive hover:text-destructive-foreground',
adminTakedown && 'border-red-500/50 text-red-400',
)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
{user && !isShop && (
<>
@ -971,10 +981,12 @@ export default function SpotDetailPage() {
<AlertDialogHeader>
<AlertDialogTitle className="text-white flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
Delete Spot?
{adminTakedown ? 'Remove this spot? (admin)' : 'Delete Spot?'}
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-300">
This will permanently delete "{spot.title}" from all relays. This action cannot be undone.
{adminTakedown
? `As a SkateSpots admin you're taking down "${spot.title}". It will be hidden across the app for everyone and a deletion request will be sent to relays.`
: `This will permanently delete "${spot.title}" from all relays. This action cannot be undone.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@ -983,7 +995,7 @@ export default function SpotDetailPage() {
onClick={handleDelete}
className="bg-red-500 hover:bg-red-600 text-white"
>
Delete Spot
{adminTakedown ? 'Remove Spot' : 'Delete Spot'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -13,6 +13,7 @@ import { genUserName } from '@/lib/genUserName';
import { TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, SKATE_TOUR_KIND } from '@/lib/types';
import { ZapButton } from '@/components/ZapButton';
import { ProBadge } from '@/components/ProBadge';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import {
ArrowLeft,
Route,
@ -124,11 +125,19 @@ export default function TourDetailPage() {
return (
<Layout>
<div className="container px-4 py-6 max-w-3xl">
{/* Back link */}
<Link to="/map" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-6">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Map
</Link>
{/* Back link + owner/admin controls */}
<div className="mb-6 flex items-center justify-between gap-2">
<Link to="/map" className="inline-flex items-center text-gray-400 hover:text-white transition-colors">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Map
</Link>
<DeleteEntityDialog
event={tour.event}
entityLabel="tour"
entityName={tour.title}
redirectTo="/map"
/>
</div>
{/* Main card */}
<Card className="bg-black/90 border-2 border-cyan-500/30 overflow-hidden">