Extend admin takedown to Lab profiles and gear/marketplace listings

Completes the admin moderation coverage from the previous commit.

Lab profiles (coaches, filmers, editors, photographers, builders, artists,
graffiti, tattoo, trainers, musicians):
- useLabProfiles / useLabProfile and useCoaches / useCoach now filter out
  admin-hidden entries (list) and 404 hidden ones (detail) via the shared
  moderation layer. Added filterAdminHiddenMixed() for the mixed-kind Lab
  union (kind read from each item's event).
- CoachDetailPage (the shared Lab detail page) now shows the delete/takedown
  card to admins too, not just the owner, with admin-specific copy.
  DeleteEntityDialog already handles the owner-or-admin logic.

Gear / marketplace listings (NIP-99, kind 30402):
- useMarketplaceItems / useMarketplaceAtSpot filter out admin-hidden listings.
- GearSwapItemCard: admins get a "Remove (admin)" action in the card menu with
  a confirmation dialog that routes through useDeleteEntity (NIP-09 delete +
  hidden label). Report stays for non-owners.

SECURITY.md: updated the admin-moderation coverage list and auditor file map.
Build + lint pass, no console errors.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-18 19:17:16 +07:00
parent 13eece3a1e
commit 82a74e01e0
7 changed files with 161 additions and 23 deletions

View file

@ -216,11 +216,14 @@ intentionally *not* author-filtered.
### Admin moderation / takedowns ### Admin moderation / takedowns
Two hardcoded admin pubkeys (`src/lib/admins.ts`, `ADMIN_PUBKEYS`) can take Two hardcoded admin pubkeys (`src/lib/admins.ts`, `ADMIN_PUBKEYS`) can take
down any listing (spot, crew, tour, event, shop, …). A takedown publishes both down any listing — spots, crews, tours, events, shops, Lab profiles (coaches,
a NIP-09 kind:5 deletion request **and** a NIP-32 kind:1985 `hidden` moderation filmers, editors, photographers, builders, artists, graffiti, tattoo, trainers,
label authored by the admin. The client-side layer (`useAdminModeration`) then musicians), and gear/marketplace listings. A takedown publishes both a NIP-09
hides that content across the app, so takedowns are reliable even when a relay kind:5 deletion request **and** a NIP-32 kind:1985 `hidden` moderation label
won't honor a cross-author NIP-09 request. **Trust boundary:** only labels whose authored by the admin. The client-side layer (`useAdminModeration`, wired into
every list/detail hook via React Query `select`) 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 `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 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 attacker-controllable value. Auditors should confirm the admin list is the only
@ -264,7 +267,7 @@ oversights:
| Login / key handling | `src/components/auth/`, Nostrify login (`nostr:login`) | | Login / key handling | `src/components/auth/`, Nostrify login (`nostr:login`) |
| Lightning / NWC | `src/hooks/useNWC.ts`, `src/components/WalletModal.tsx` | | 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` | | 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` | | Admin authority / moderation / takedowns | `src/lib/admins.ts`, `src/hooks/useAdminModeration.ts`, `src/hooks/useDeleteEntity.ts`, `src/components/DeleteEntityDialog.tsx`, `src/components/GearSwapItemCard.tsx` |
| CSP & security headers | `index.html` (`<meta>` — covers mobile), `public/_headers`, `netlify.toml`, `public/boot-guard.js` | | 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'`) | | Native wrapper (same bundle) | `capacitor.config.ts` (`webDir: 'dist'`) |
| NIP-19 routing | `src/pages/NIP19Page.tsx`, `src/AppRouter.tsx` | | NIP-19 routing | `src/pages/NIP19Page.tsx`, `src/AppRouter.tsx` |

View file

@ -8,6 +8,9 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
import { useToast } from '@/hooks/useToast';
import { isAdminPubkey } from '@/lib/admins';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating'; import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating';
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, formatPrice, type MarketplaceItem } from '@/lib/types'; import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, formatPrice, type MarketplaceItem } from '@/lib/types';
@ -21,6 +24,7 @@ import {
AlertTriangle, AlertTriangle,
MoreVertical, MoreVertical,
Flag, Flag,
Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
@ -28,6 +32,16 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ReportDialog } from '@/components/ReportDialog'; import { ReportDialog } from '@/components/ReportDialog';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
@ -42,13 +56,37 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const navigate = useNavigate(); const navigate = useNavigate();
const author = useAuthor(item.pubkey); const author = useAuthor(item.pubkey);
const { mutateAsync: deleteEntity, isPending: isRemoving } = useDeleteEntity();
const { toast } = useToast();
const metadata = author.data?.metadata; const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(item.pubkey); const displayName = metadata?.name ?? genUserName(item.pubkey);
const avatar = metadata?.picture; const avatar = metadata?.picture;
const [showReportDialog, setShowReportDialog] = useState(false); const [showReportDialog, setShowReportDialog] = useState(false);
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const npub = nip19.npubEncode(item.pubkey); const npub = nip19.npubEncode(item.pubkey);
const isOwnListingCheck = user?.pubkey === item.pubkey; const isOwnListingCheck = user?.pubkey === item.pubkey;
const isAdmin = isAdminPubkey(user?.pubkey);
const handleAdminRemove = async () => {
try {
await deleteEntity({
event: item.event,
reason: `SkateSpots admin removed listing "${item.title}"`,
});
toast({
title: 'Listing removed',
description: 'This listing has been taken down and will be hidden across SkateSpots.',
});
setShowRemoveDialog(false);
} catch (error) {
toast({
title: 'Remove failed',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
// Format price display // Format price display
const priceDisplay = formatPrice(item.price, item.priceType); const priceDisplay = formatPrice(item.price, item.priceType);
@ -139,8 +177,9 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
</Badge> </Badge>
)} )}
{/* More options menu */} {/* More options menu — shown to non-owners (Report) and to admins
{!isOwnListingCheck && ( (admin takedown). Owners manage their own listing elsewhere. */}
{(!isOwnListingCheck || isAdmin) && (
<div className="absolute bottom-2 right-2"> <div className="absolute bottom-2 right-2">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -153,17 +192,31 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
<MoreVertical className="h-3.5 w-3.5" /> <MoreVertical className="h-3.5 w-3.5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40"> <DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem {!isOwnListingCheck && (
onClick={(e) => { <DropdownMenuItem
e.stopPropagation(); onClick={(e) => {
setShowReportDialog(true); e.stopPropagation();
}} setShowReportDialog(true);
className="text-red-500 focus:text-red-500" }}
> className="text-red-500 focus:text-red-500"
<Flag className="h-4 w-4 mr-2" /> >
Report <Flag className="h-4 w-4 mr-2" />
</DropdownMenuItem> Report
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowRemoveDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove (admin)
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@ -291,6 +344,37 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
targetEventId={item.id} targetEventId={item.id}
targetTitle={item.title} targetTitle={item.title}
/> />
{/* Admin takedown confirmation */}
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
<AlertDialogContent
className="bg-black border-2 border-red-500/50"
onClick={(e) => e.stopPropagation()}
>
<AlertDialogHeader>
<AlertDialogTitle className="text-white flex items-center gap-2">
<Trash2 className="h-5 w-5 text-red-500" />
Remove this listing? (admin)
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-300">
As a SkateSpots admin you're taking down "{item.title}". It will be hidden across the
app for everyone and a deletion request will be sent to relays.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-gray-600" disabled={isRemoving}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleAdminRemove}
disabled={isRemoving}
className="bg-red-500 hover:bg-red-600 text-white"
>
{isRemoving ? 'Removing…' : 'Remove Listing'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card> </Card>
); );
} }

View file

@ -50,6 +50,29 @@ export function filterAdminHidden<
); );
} }
/**
* Like `filterAdminHidden`, but for a mixed-kind list where each item carries
* its own source `event` (e.g. Lab profiles, which span 10 different kinds).
* The kind is read from `it.event.kind`.
*/
export function filterAdminHiddenMixed<
T extends {
id: string;
pubkey: string;
identifier: string;
event: { kind: number };
},
>(items: T[], hidden: AdminHiddenSet): T[] {
if (hidden.coords.size === 0 && hidden.ids.size === 0) return items;
return items.filter(
(it) =>
!hidden.has({
coord: `${it.event.kind}:${it.pubkey}:${it.identifier}`,
id: it.id,
}),
);
}
export function useAdminModeration() { export function useAdminModeration() {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();

View file

@ -1,12 +1,15 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { SKATE_COACH_KIND, parseSkateCoach, type SkateCoach } from '@/lib/types'; import { SKATE_COACH_KIND, parseSkateCoach, type SkateCoach } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
export function useCoaches(limit = 100) { export function useCoaches(limit = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({ return useQuery({
queryKey: ['coaches', limit, getQueryKeySuffix()], queryKey: ['coaches', limit, getQueryKeySuffix()],
select: (coaches: SkateCoach[]) => filterAdminHidden(coaches, SKATE_COACH_KIND, hidden),
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(10000);
@ -42,9 +45,14 @@ export function useCoaches(limit = 100) {
export function useCoach(identifier: string, pubkey?: string) { export function useCoach(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({ return useQuery({
queryKey: ['coach', identifier, pubkey, getQueryKeySuffix()], queryKey: ['coach', identifier, pubkey, getQueryKeySuffix()],
select: (coach: SkateCoach | null) =>
coach && hidden.has({ coord: `${SKATE_COACH_KIND}:${coach.pubkey}:${coach.identifier}`, id: coach.id })
? null
: coach,
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(10000);

View file

@ -10,6 +10,7 @@ import {
} from '@/lib/types'; } from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHiddenMixed } from '@/hooks/useAdminModeration';
export type LabProfileType = export type LabProfileType =
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder' | 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
@ -82,9 +83,11 @@ export function parseLabProfile(event: NostrEvent): LabProfile | null {
export function useLabProfiles(limit = 100) { export function useLabProfiles(limit = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({ return useQuery({
queryKey: ['lab-profiles', limit, getQueryKeySuffix()], queryKey: ['lab-profiles', limit, getQueryKeySuffix()],
select: (profiles: LabProfile[]) => filterAdminHiddenMixed(profiles, hidden),
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(15000); const signal = AbortSignal.timeout(15000);
@ -120,9 +123,17 @@ export function useLabProfiles(limit = 100) {
export function useLabProfile(identifier: string, pubkey?: string) { export function useLabProfile(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({ return useQuery({
queryKey: ['lab-profile', identifier, pubkey, getQueryKeySuffix()], queryKey: ['lab-profile', identifier, pubkey, getQueryKeySuffix()],
select: (profile: LabProfile | null) =>
profile && hidden.has({
coord: `${profile.event.kind}:${profile.pubkey}:${profile.identifier}`,
id: profile.id,
})
? null
: profile,
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(10000);

View file

@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { MARKETPLACE_ITEM_KIND, parseMarketplaceItem, type MarketplaceItem } from '@/lib/types'; import { MARKETPLACE_ITEM_KIND, parseMarketplaceItem, type MarketplaceItem } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
const STALE_TIME = 5 * 60 * 1000; // 5 min const STALE_TIME = 5 * 60 * 1000; // 5 min
const GC_TIME = 30 * 60 * 1000; // 30 min const GC_TIME = 30 * 60 * 1000; // 30 min
@ -8,9 +9,11 @@ const TIMEOUT = 8000;
export function useMarketplaceItems(limit = 100) { export function useMarketplaceItems(limit = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({ return useQuery({
queryKey: ['marketplace-items', limit, getQueryKeySuffix()], queryKey: ['marketplace-items', limit, getQueryKeySuffix()],
select: (items: MarketplaceItem[]) => filterAdminHidden(items, MARKETPLACE_ITEM_KIND, hidden),
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(TIMEOUT); const signal = AbortSignal.timeout(TIMEOUT);
@ -41,9 +44,11 @@ export function useMarketplaceItems(limit = 100) {
export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string, limit = 20) { export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string, limit = 20) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({ return useQuery({
queryKey: ['marketplace-at-spot', spotPubkey, spotIdentifier, getQueryKeySuffix()], queryKey: ['marketplace-at-spot', spotPubkey, spotIdentifier, getQueryKeySuffix()],
select: (items: MarketplaceItem[]) => filterAdminHidden(items, MARKETPLACE_ITEM_KIND, hidden),
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(TIMEOUT); const signal = AbortSignal.timeout(TIMEOUT);

View file

@ -18,6 +18,7 @@ import { useGigs } from '@/hooks/useGigs';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { isAdminPubkey } from '@/lib/admins';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS, ARTIST_SERVICE_LABELS, ARTIST_SERVICE_DESCRIPTIONS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS, GRAFFITI_STYLE_LABELS, GRAFFITI_STYLE_DESCRIPTIONS, GRAFFITI_MEDIUM_LABELS, GRAFFITI_CONTEXT_LABELS, TATTOO_STYLE_LABELS, TATTOO_STYLE_DESCRIPTIONS, TATTOO_METHOD_LABELS, TRAINER_FOCUS_LABELS, TRAINER_FOCUS_DESCRIPTIONS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS, MUSICIAN_GENRE_LABELS, MUSICIAN_INSTRUMENT_LABELS, SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types'; import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS, ARTIST_SERVICE_LABELS, ARTIST_SERVICE_DESCRIPTIONS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS, GRAFFITI_STYLE_LABELS, GRAFFITI_STYLE_DESCRIPTIONS, GRAFFITI_MEDIUM_LABELS, GRAFFITI_CONTEXT_LABELS, TATTOO_STYLE_LABELS, TATTOO_STYLE_DESCRIPTIONS, TATTOO_METHOD_LABELS, TRAINER_FOCUS_LABELS, TRAINER_FOCUS_DESCRIPTIONS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS, MUSICIAN_GENRE_LABELS, MUSICIAN_INSTRUMENT_LABELS, SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl'; import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
@ -575,17 +576,20 @@ export default function CoachDetailPage() {
accentBg={`${accent.bg} hover:opacity-90`} accentBg={`${accent.bg} hover:opacity-90`}
/> />
{/* Owner controls — delete your own Lab profile */} {/* Owner controls — delete your own Lab profile; admins can take
{user && user.pubkey === profile.pubkey && ( down any profile (DeleteEntityDialog gates owner-or-admin). */}
{user && (user.pubkey === profile.pubkey || isAdminPubkey(user.pubkey)) && (
<Card className="bg-black/90 border-2 border-red-500/30"> <Card className="bg-black/90 border-2 border-red-500/30">
<CardHeader> <CardHeader>
<CardTitle className="text-lg text-red-400 uppercase font-black"> <CardTitle className="text-lg text-red-400 uppercase font-black">
Manage {user.pubkey === profile.pubkey ? 'Manage' : 'Admin'}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
Remove your {typeNoun} profile from The Lab. {user.pubkey === profile.pubkey
? `Remove your ${typeNoun} profile from The Lab.`
: `Take down this ${typeNoun} profile. It will be hidden across SkateSpots.`}
</p> </p>
<DeleteEntityDialog <DeleteEntityDialog
event={profile.event} event={profile.event}