SkateSpots/src/components/EventUpdates.tsx
shakespeare.diy 854d18c322 Give admins moderator control over events + fix a spoofing hole
Admins (the two pubkeys in src/lib/admins.ts) can now moderate any event:
- Post authoritative Event Updates (incl. cancel/reschedule) on events they don't own,
  clearly labelled "Admin" and marked with an ["admin","true"] tag
- Take down any event (already wired via DeleteEntityDialog + isAdminPubkey)

SECURITY FIX: kind-7103 Event Updates are now only trusted from the event's host
(pubkey in the `a` coordinate) or a SkateSpots admin. Previously ANY npub could
publish a fake "CANCELLED" update — spoofing the detail-page banner and spamming
RSVPers' notification bells. Enforced in both parseEventUpdate() and
useActivityNotifications.

Note: admins intentionally CANNOT open the edit form on others' events — an
addressable event (31923) can only be replaced by the key that signed it; editing
under a different key would fork it, not edit it. Moderation is done via
authoritative updates + takedown instead.

Docs: NIP.md updated with the host/admin trust rule and the admin marker tag.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 14:40:17 +07:00

233 lines
8.6 KiB
TypeScript

import { useState } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { Megaphone, Loader2, Send, CalendarClock, MapPin, XCircle, Info } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import { useEventUpdates, usePostEventUpdate } from '@/hooks/useEventUpdates';
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { genUserName } from '@/lib/genUserName';
import { isAdminPubkey } from '@/lib/admins';
import { cn } from '@/lib/utils';
import type { EventUpdate, EventUpdateKind, SkateEvent } from '@/lib/types';
const UPDATE_KIND_META: Record<
EventUpdateKind,
{ icon: typeof Info; label: string; color: string; ring: string }
> = {
update: { icon: Info, label: 'Update', color: 'text-sky-400', ring: 'bg-sky-500/15' },
reschedule: { icon: CalendarClock, label: 'New time', color: 'text-amber-400', ring: 'bg-amber-500/15' },
relocation: { icon: MapPin, label: 'New spot', color: 'text-[#ff0040]', ring: 'bg-[#ff0040]/15' },
cancelled: { icon: XCircle, label: 'Cancelled', color: 'text-red-400', ring: 'bg-red-500/15' },
};
function UpdateItem({ update }: { update: EventUpdate }) {
const author = useAuthor(update.pubkey);
const name = author.data?.metadata?.name ?? genUserName(update.pubkey);
const pic = author.data?.metadata?.picture;
const meta = UPDATE_KIND_META[update.updateKind];
const Icon = meta.icon;
return (
<div className="flex gap-3">
<div className="relative flex-shrink-0">
<Avatar className="h-8 w-8">
<AvatarImage src={pic} alt={name} />
<AvatarFallback className="text-[10px] font-bold">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span
className={cn(
'absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full ring-2 ring-background',
meta.ring,
)}
>
<Icon className={cn('h-2.5 w-2.5', meta.color)} />
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-white">{name}</span>
{update.authorRole === 'admin' && (
<span className="rounded-full border border-yellow-500/40 bg-yellow-500/15 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-wide text-yellow-400">
Admin
</span>
)}
{update.updateKind !== 'update' && (
<span
className={cn(
'rounded-full px-1.5 py-0.5 text-[9px] font-black uppercase tracking-wide',
meta.ring,
meta.color,
)}
>
{meta.label}
</span>
)}
<span className="text-[11px] text-gray-600">
{formatDistanceToNow(new Date(update.createdAt * 1000), { addSuffix: true })}
</span>
</div>
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm text-gray-300">
{update.message}
</p>
</div>
</div>
);
}
/**
* Host update composer (only for the event author) + a public feed of updates.
* Posting p-tags every RSVPer so they get a notification.
*/
export function EventUpdates({ event }: { event: SkateEvent }) {
const { user } = useCurrentUser();
const { toast } = useToast();
const coord = eventCoord(event);
const { data: updates, isLoading } = useEventUpdates(coord);
const { going, tentative } = useEventRsvps(coord);
const { mutateAsync: post, isPending } = usePostEventUpdate();
const [message, setMessage] = useState('');
const [kind, setKind] = useState<EventUpdateKind>('update');
const isHost = user?.pubkey === event.pubkey;
const isAdmin = isAdminPubkey(user?.pubkey);
// Admins can moderate any event; the host runs their own.
const canPost = isHost || isAdmin;
const handlePost = async () => {
const text = message.trim();
if (!text) return;
try {
await post({
event,
message: text,
updateKind: kind,
notifyPubkeys: [...going, ...tentative],
});
setMessage('');
setKind('update');
const reach = new Set([...going, ...tentative]).size;
toast({
title: 'Update posted',
description: reach > 0 ? `${reach} ${reach === 1 ? 'person' : 'people'} notified.` : 'Everyone will see it on the event.',
});
} catch (err) {
toast({
title: 'Could not post update',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
const hasUpdates = (updates?.length ?? 0) > 0;
return (
<div className="space-y-4">
{/* Composer — host or SkateSpots admin */}
{canPost && (
<div
className={cn(
'rounded-xl border p-3',
isHost
? 'border-[#ccff00]/25 bg-[#ccff00]/[0.04]'
: 'border-yellow-500/30 bg-yellow-500/[0.05]',
)}
>
<div
className={cn(
'mb-2 flex items-center gap-2 text-sm font-black uppercase tracking-wide',
isHost ? 'text-[#ccff00]' : 'text-yellow-400',
)}
>
<Megaphone className="h-4 w-4" />
{isHost ? 'Post an update' : 'Post an admin update'}
</div>
{!isHost && (
<p className="mb-2 text-[11px] text-yellow-200/80">
You're posting as a SkateSpots admin. It'll be clearly labelled as an admin
notice, not from the host.
</p>
)}
<div className="mb-2 flex flex-wrap gap-1.5">
{(Object.keys(UPDATE_KIND_META) as EventUpdateKind[]).map((k) => {
const m = UPDATE_KIND_META[k];
const Icon = m.icon;
const active = kind === k;
return (
<button
key={k}
type="button"
onClick={() => setKind(k)}
className={cn(
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-all active:scale-95',
active
? cn('border-transparent', m.ring, m.color)
: 'border-white/10 bg-white/5 text-gray-500 hover:text-white',
)}
>
<Icon className="h-3 w-3" />
{m.label}
</button>
);
})}
</div>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Rain — moved to Sunday 2pm. Meet at the back ledge…"
rows={3}
maxLength={500}
className="resize-none border-white/10 bg-black/40 text-sm"
/>
<div className="mt-2 flex items-center justify-between">
<span className="text-[11px] text-gray-500">
{going.length + tentative.length > 0
? `Notifies ${new Set([...going, ...tentative]).size} RSVP${new Set([...going, ...tentative]).size === 1 ? '' : "'s"}`
: 'Visible to everyone on this event'}
</span>
<Button
size="sm"
onClick={handlePost}
disabled={isPending || !message.trim()}
className={cn(
'gap-1.5 font-bold text-black',
isHost
? 'bg-[#ccff00] hover:bg-[#ccff00]/90'
: 'bg-yellow-400 hover:bg-yellow-400/90',
)}
>
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
Post
</Button>
</div>
</div>
)}
{/* Feed */}
{isLoading && !hasUpdates ? (
<div className="flex items-center gap-2 py-4 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> Loading updates…
</div>
) : hasUpdates ? (
<div className="space-y-4">
{updates!.map((u) => (
<UpdateItem key={u.id} update={u} />
))}
</div>
) : (
!canPost && (
<p className="text-sm text-muted-foreground">
No updates from the host yet. RSVP and you'll be notified if anything changes.
</p>
)
)}
</div>
);
}