Files
Kwaku Danso b873012191 feat(tier2): smarter fraud detection — Block G
Per-event fraud tuning. Hosts can now dial the medium / high / block
boundaries, allowlist trusted networks, and feed verdicts back on
flagged accesses — the seed corpus for a future ML model.

Schema (migration 0011)
- events.fraud_{medium,high,block}_threshold default 30/60/85 so
  existing events behave identically until a host changes them
- access_logs.geo_{country,city,lat,lon} for future enrichment
- fraud_feedback table — verdict ('legitimate' | 'suspicious') + note,
  PK on access_log_id so re-mark is an upsert
- event_allowlists table — (event_id, ip_cidr) primary key, inet column
  so containment checks use the native >>= operator (indexed lookup)

Domain
- FraudThresholds with Valid() + Band() helpers; Default trio echoed
  through GET responses so the frontend doesn't duplicate constants
- ParseAllowlistCIDR accepts bare IPs (auto-widens to /32 or /128) and
  canonicalises the output (203.0.113.42 → 203.0.113.42/32)
- Event.Thresholds() falls back to defaults if columns weren't
  populated yet, so the API never wedges every score into "low"

Storage
- AllowlistRepo: List / Add / Remove + Matches() — the latter pushes
  CIDR containment into Postgres rather than streaming rows back
- FeedbackRepo: Record (upserts) + ListForEvent (joined through guests)
- EventRepo.GetThresholds + UpdateThresholds, plus the threshold
  columns baked into scanEvent so every event load carries them
- AccessLogRepo.BelongsToEvent — stops a hostile editor on event A
  from marking event B's access logs

API
- GET/PUT /events/{id}/security/thresholds (viewer/editor)
- GET/POST/DELETE /events/{id}/security/allowlist
- POST /events/{id}/access-logs/{log_id}/feedback (editor)
- GET /events/{id}/security/feedback
- RSVP scoring path: allowlist short-circuit fires before the fraud
  engine; the engine's score is then re-banded against the event's
  thresholds (engine.Risk becomes advisory — API is the source of
  truth for "what counts as block here")
- CORS Allow-Methods already includes PUT (Block D fix)

Fraud engine
- Single-signal cap: it now takes ≥2 sub-scores of ≥70 to push the
  final into HIGH. Fixes the well-known "second visit with a slightly
  shifted fingerprint scores 60+" false positive
- Engine band remains advisory; API re-bands using per-event
  thresholds before deciding to block

Frontend
- SecurityCard.vue: visual band ribbon (proportional to thresholds),
  three sliders with mutual clamping so dragging medium past high
  pushes high (not an invalid ordering), reset-to-defaults button,
  CIDR allowlist with inline add + per-row remove, verdict-history
  inbox. Toast feedback on save/add/remove
- "Security" tab added to the event-detail tab nav (5th tab,
  right of Analytics)
- Viewer role hides write affordances; server enforces too

Tests
- Domain: ThresholdsBand, ThresholdsValid, ParseAllowlistCIDR (bare
  IP widening + traversal/typo rejection), FraudFeedbackValid
- Integration: thresholds round-trip + invalid ordering rejection,
  allowlist CRUD + duplicate 409 + invalid CIDR 400 + IP auto-widen,
  feedback record + upsert + cross-tenant 404 + invalid verdict 400,
  viewer can read / editor can write / outsider gets 404
- Full integration suite green (315.8s, all 36 top-level tests pass)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 21:33:57 +01:00

268 lines
8.8 KiB
Vue

<script setup lang="ts">
// Tier 2 Block C — multi-host team management. Shown as a card on the event
// detail page; visible to anyone with viewer+ role. Action buttons (invite,
// role change, remove) are gated to owners — the server enforces this too.
interface Collaborator {
user_id: string
name: string
email: string
role: 'owner' | 'editor' | 'viewer'
invited_at: string
accepted_at?: string | null
}
interface PendingInvite {
email: string
role: 'owner' | 'editor' | 'viewer'
expires_at: string
created_at: string
}
interface TeamResponse {
collaborators: Collaborator[]
pending: PendingInvite[]
your_role: 'owner' | 'editor' | 'viewer'
}
const props = defineProps<{
eventId: string
yourRole?: 'owner' | 'editor' | 'viewer' | null
}>()
const collaborators = ref<Collaborator[]>([])
const pending = ref<PendingInvite[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const isOwner = computed(() => props.yourRole === 'owner')
async function refresh() {
try {
const data = await useApi<TeamResponse>(`/events/${props.eventId}/collaborators`)
collaborators.value = data.collaborators || []
pending.value = data.pending || []
} catch (e: any) {
error.value = useErrMessage(e, 'Could not load team')
} finally {
loading.value = false
}
}
onMounted(refresh)
// --- invite modal ---
const inviting = ref(false)
const inviteEmail = ref('')
const inviteRole = ref<'editor' | 'viewer'>('editor')
const inviteInFlight = ref(false)
const inviteError = ref<string | null>(null)
function openInvite() {
inviteEmail.value = ''
inviteRole.value = 'editor'
inviteError.value = null
inviting.value = true
}
async function sendInvite() {
inviteInFlight.value = true
inviteError.value = null
try {
await useApi(`/events/${props.eventId}/collaborators`, {
method: 'POST',
body: { email: inviteEmail.value.trim(), role: inviteRole.value },
})
inviting.value = false
await refresh()
} catch (e: any) {
inviteError.value = useErrMessage(e, 'Could not send invite')
} finally {
inviteInFlight.value = false
}
}
// --- role change ---
async function changeRole(c: Collaborator, role: 'owner' | 'editor' | 'viewer') {
if (c.role === role) return
try {
await useApi(`/events/${props.eventId}/collaborators/${c.user_id}`, {
method: 'PATCH',
body: { role },
})
await refresh()
} catch (e: any) {
error.value = useErrMessage(e, 'Could not change role')
}
}
// --- remove ---
async function removeCollaborator(c: Collaborator) {
if (!confirm(`Remove ${c.name || c.email} from this event?`)) return
try {
await useApi(`/events/${props.eventId}/collaborators/${c.user_id}`, { method: 'DELETE' })
await refresh()
} catch (e: any) {
error.value = useErrMessage(e, 'Could not remove collaborator')
}
}
async function cancelPending(p: PendingInvite) {
if (!confirm(`Cancel invitation to ${p.email}?`)) return
try {
await useApi(`/events/${props.eventId}/collaborators/pending?email=${encodeURIComponent(p.email)}`, {
method: 'DELETE',
})
await refresh()
} catch (e: any) {
error.value = useErrMessage(e, 'Could not cancel invitation')
}
}
function fmtDate(iso?: string | null) {
if (!iso) return ''
try { return new Date(iso).toLocaleDateString() } catch { return iso }
}
</script>
<template>
<section class="card">
<header class="mb-3 flex items-center justify-between">
<h2 class="text-lg font-semibold">Collaborators</h2>
<button
v-if="isOwner"
type="button"
class="btn-primary text-sm"
@click="openInvite"
>Invite</button>
</header>
<p class="mb-4 text-xs text-zinc-500">
Collaborators can help manage this event. Owners can invite + change roles;
editors can manage guests + send messages; viewers can read but not change.
</p>
<p v-if="error" class="mb-3 text-sm text-red-400">{{ error }}</p>
<div v-if="loading" class="text-sm text-zinc-500">Loading team</div>
<ul v-else-if="collaborators.length" class="space-y-2">
<li
v-for="c in collaborators"
:key="c.user_id"
class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2"
>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-zinc-100">{{ c.name || c.email }}</div>
<div class="truncate text-xs text-zinc-500">{{ c.email }}</div>
</div>
<div class="flex items-center gap-2">
<select
v-if="isOwner"
:value="c.role"
class="rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
:aria-label="`Change role for ${c.name || c.email}`"
@change="(e) => changeRole(c, ((e.target as HTMLSelectElement).value as any))"
>
<option value="owner">Owner</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<span v-else class="badge bg-zinc-800 capitalize text-zinc-300">{{ c.role }}</span>
<button
v-if="isOwner"
type="button"
class="rounded-md p-1 text-zinc-500 transition hover:bg-red-500/10 hover:text-red-300"
:title="`Remove ${c.name || c.email}`"
:aria-label="`Remove ${c.name || c.email}`"
@click="removeCollaborator(c)"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6 6L14 14M14 6L6 14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
</li>
</ul>
<p v-else class="text-sm text-zinc-500">No collaborators yet.</p>
<div v-if="pending.length" class="mt-4">
<p class="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">Pending invitations</p>
<ul class="space-y-2">
<li
v-for="p in pending"
:key="p.email"
class="flex items-center justify-between rounded-md border border-amber-900/40 bg-amber-950/10 px-3 py-2"
>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-zinc-100">{{ p.email }}</div>
<div class="text-xs text-zinc-500">
<span class="capitalize">{{ p.role }}</span> · expires {{ fmtDate(p.expires_at) }}
</div>
</div>
<button
v-if="isOwner"
type="button"
class="text-xs text-zinc-400 hover:text-red-300"
@click="cancelPending(p)"
>Cancel</button>
</li>
</ul>
</div>
<!-- Invite modal -->
<Teleport to="body">
<div
v-if="inviting"
class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
@click.self="inviting = false"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="invite-title"
class="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
>
<h3 id="invite-title" class="mb-1 text-base font-semibold">Invite a collaborator</h3>
<p class="mb-4 text-xs text-zinc-500">
They'll get an email with a link that's valid for 7 days. If they don't have a
GuestGuard account yet, they'll be prompted to create one.
</p>
<form class="space-y-3" @submit.prevent="sendInvite">
<div>
<label class="label">Email</label>
<input
v-model="inviteEmail"
type="email"
required
class="input"
placeholder="teammate@example.com"
/>
</div>
<div>
<label class="label">Role</label>
<select v-model="inviteRole" class="input">
<option value="editor">Editor manage guests + send messages</option>
<option value="viewer">Viewer read-only access</option>
</select>
</div>
<p v-if="inviteError" class="text-sm text-red-400">{{ inviteError }}</p>
<div class="flex items-center justify-end gap-2 pt-2">
<button
type="button"
class="text-sm text-zinc-400 hover:text-zinc-200"
:disabled="inviteInFlight"
@click="inviting = false"
>Cancel</button>
<button
type="submit"
class="btn-primary"
:disabled="inviteInFlight || !inviteEmail.trim()"
>{{ inviteInFlight ? 'Sending…' : 'Send invite' }}</button>
</div>
</form>
</div>
</div>
</Teleport>
</section>
</template>