fix(tier2-g): rebrand Security → Gate with event-planner language

The original Block G UI was correct but felt like a firewall config panel.
The audience for GuestGuard is wedding couples, party hosts, and event
planners — not network admins. Three concrete problems:

1. "Fraud detection" / "Risk score" frames every guest as a suspect.
   "Gate" is the metaphor every host already uses ("gate the guest
   list", "doorman"). It's the same idea, friendlier.

2. Three threshold sliders (0–100) are abstract. Hosts can't reason
   about "block at 85" without context. Replaced by four presets —
   Relaxed / Balanced / Strict / Very strict — each with a sentence
   that explains the kind of event it fits. The sliders survive under
   an Advanced disclosure for the rare power user.

3. CIDR notation is gibberish to a layperson. "Trusted networks" is
   what they're actually doing. A "Use my current network" button
   detects the host's apparent IP, widens IPv4 to /24 (typical home
   block), and pre-fills the form. Manual entry stays for cases
   where the host knows what they're doing.

Plus two leaks fixed on the guest-facing RSVP page: removed the
"Risk score 72 · high" line from both the confirmation and the
blocked-attempt cards. Guests should never see internal scoring
detail — the blocked message now reads "Something about this
attempt looked off" instead of "suspicious access attempt".

Backend
- New GET /me/public-ip (authed) — echoes the caller's apparent IP
  so the frontend's auto-detect button doesn't need a third-party
  ipify call

Frontend
- New components/GateCard.vue with preset cards + advanced disclosure
  + trusted-networks UX with auto-detect
- Removed components/SecurityCard.vue
- Event tab nav: Security → Gate. Hash-link alias preserves any
  /events/<id>#security bookmarks
- RSVP page: leaked risk-score lines removed; blocked-attempt
  message reworded for non-technical guests

Internal storage / API names stay (FraudThresholds, fraud_v2,
/security/* endpoints) — they're never user-visible and renaming
them would be a breaking change for no end-user benefit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-19 22:15:11 +01:00
parent b873012191
commit 3629ab8c79
6 changed files with 520 additions and 401 deletions
+489
View File
@@ -0,0 +1,489 @@
<script setup lang="ts">
// Tier 2 Block G — "Gate" for one event. This is the user-facing rebrand
// of what the internal codebase still calls the fraud detector.
//
// The metaphor is the bouncer at the door of an event: a host tells the
// gate how strict to be, lists a few "trusted places" their guests will
// be arriving from, and reviews any decisions the gate has already made.
// No CIDRs, no risk scores, no sliders by default — those live under an
// Advanced disclosure for the rare power user.
interface Thresholds {
medium: number
high: number
block: number
defaults: { medium: number; high: number; block: number }
}
interface AllowlistEntry {
event_id: string
cidr: string
label?: string
created_at: string
}
interface FeedbackEntry {
access_log_id: string
verdict: 'legitimate' | 'suspicious'
note?: string
created_at: string
}
// Friendly presets. Each maps to a (medium, high, block) triple. The
// preset detector picks the one whose block-threshold is closest to the
// current value; if nothing matches we render "Custom".
//
// Tuning rationale:
// - Relaxed: only obvious crashers stopped. block=95 = "needs near-perfect
// confidence to refuse".
// - Balanced: matches the engineering defaults (30/60/85). Most events.
// - Strict: tight check for private events. block=70.
// - Very strict: VIP-list level. block=55 — even mild signal mismatches
// get refused.
type PresetId = 'relaxed' | 'balanced' | 'strict' | 'very_strict' | 'custom'
interface Preset {
id: PresetId
label: string
description: string
medium: number
high: number
block: number
}
const PRESETS: Preset[] = [
{ id: 'relaxed', label: 'Relaxed', description: 'Only stop obvious gate-crashers.', medium: 50, high: 80, block: 95 },
{ id: 'balanced', label: 'Balanced', description: 'Recommended for most parties and gatherings.', medium: 30, high: 60, block: 85 },
{ id: 'strict', label: 'Strict', description: 'Tight check — good for weddings and private events.', medium: 25, high: 50, block: 70 },
{ id: 'very_strict', label: 'Very strict', description: 'VIP or high-profile guest list.', medium: 15, high: 35, block: 55 },
]
const props = defineProps<{
eventId: string
yourRole?: 'owner' | 'editor' | 'viewer' | null
}>()
const canEdit = computed(() => props.yourRole === 'owner' || props.yourRole === 'editor')
const config = useRuntimeConfig()
const auth = useAuth()
// --- state ---
const loading = ref(true)
const error = ref<string | null>(null)
const saving = ref(false)
const thresholds = ref<Thresholds>({ medium: 30, high: 60, block: 85, defaults: { medium: 30, high: 60, block: 85 } })
const allowlist = ref<AllowlistEntry[]>([])
const feedback = ref<FeedbackEntry[]>([])
const showAdvanced = ref(false)
const showAddNetwork = ref(false)
const newNetworkIP = ref('')
const newNetworkLabel = ref('')
const addingNetwork = ref(false)
const detectingIP = ref(false)
// Toast — same pattern used elsewhere.
type Toast = { kind: 'success' | 'error'; text: string }
const toast = ref<Toast | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | null = null
function showToast(t: Toast, ms = 4000) {
toast.value = t
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toast.value = null }, ms)
}
async function refresh() {
loading.value = true
error.value = null
try {
const [th, al, fb] = await Promise.all([
useApi<Thresholds>(`/events/${props.eventId}/security/thresholds`),
useApi<{ entries: AllowlistEntry[] }>(`/events/${props.eventId}/security/allowlist`),
useApi<{ feedback: FeedbackEntry[] }>(`/events/${props.eventId}/security/feedback`),
])
thresholds.value = th
allowlist.value = al.entries || []
feedback.value = fb.feedback || []
} catch (e: any) {
error.value = useErrMessage(e, 'Could not load gate settings')
} finally {
loading.value = false
}
}
onMounted(refresh)
// Which preset does the current threshold triple correspond to?
// Exact match wins; otherwise "Custom". Selecting a preset writes its
// triple over the current values and saves on the next click.
const currentPreset = computed<PresetId>(() => {
const t = thresholds.value
for (const p of PRESETS) {
if (p.medium === t.medium && p.high === t.high && p.block === t.block) {
return p.id
}
}
return 'custom'
})
async function selectPreset(p: Preset) {
if (!canEdit.value) return
thresholds.value.medium = p.medium
thresholds.value.high = p.high
thresholds.value.block = p.block
await saveThresholds(true)
}
// Keep the three sliders ordered while the user drags. Without this you
// can briefly create an invalid ordering and the server 400s on save.
function clampOrder(which: 'medium' | 'high' | 'block') {
const t = thresholds.value
if (which === 'medium' && t.medium > t.high) t.high = t.medium
if (which === 'high') {
if (t.high < t.medium) t.medium = t.high
if (t.high > t.block) t.block = t.high
}
if (which === 'block' && t.block < t.high) t.high = t.block
}
async function saveThresholds(silent = false) {
saving.value = true
try {
await useApi(`/events/${props.eventId}/security/thresholds`, {
method: 'PUT',
body: {
medium: thresholds.value.medium,
high: thresholds.value.high,
block: thresholds.value.block,
},
})
if (!silent) showToast({ kind: 'success', text: 'Strictness saved.' })
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not save') })
} finally {
saving.value = false
}
}
// --- trusted networks ---
// "Use my current network" — pulls the host's apparent IP from the API,
// widens IPv4 to /24 (typical residential block) and lets the user
// confirm before saving. /24 is a reasonable default: covers your whole
// home network even with ISP-shuffled addresses, doesn't open up the
// entire internet.
async function detectCurrentNetwork() {
detectingIP.value = true
try {
const res = await useApi<{ ip: string }>('/me/public-ip')
const ip = res.ip
if (!ip) {
showToast({ kind: 'error', text: 'Could not detect your IP.' })
return
}
if (ip.includes(':')) {
// IPv6 — too risky to auto-widen at /64; default to single-host.
newNetworkIP.value = ip
} else {
const parts = ip.split('.')
if (parts.length === 4) {
newNetworkIP.value = `${parts[0]}.${parts[1]}.${parts[2]}.0/24`
} else {
newNetworkIP.value = ip
}
}
if (!newNetworkLabel.value) newNetworkLabel.value = 'My network'
showAddNetwork.value = true
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not detect your IP') })
} finally {
detectingIP.value = false
}
}
async function addNetwork() {
if (!newNetworkIP.value.trim()) return
addingNetwork.value = true
try {
await useApi(`/events/${props.eventId}/security/allowlist`, {
method: 'POST',
body: { cidr: newNetworkIP.value.trim(), label: newNetworkLabel.value.trim() || 'My network' },
})
newNetworkIP.value = ''
newNetworkLabel.value = ''
showAddNetwork.value = false
showToast({ kind: 'success', text: 'Trusted network added.' })
await refresh()
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not add network') })
} finally {
addingNetwork.value = false
}
}
async function removeNetwork(entry: AllowlistEntry) {
if (!confirm(`Stop trusting ${entry.label || entry.cidr}?`)) return
try {
await useApi(
`/events/${props.eventId}/security/allowlist?cidr=${encodeURIComponent(entry.cidr)}`,
{ method: 'DELETE' },
)
showToast({ kind: 'success', text: 'Trusted network removed.' })
await refresh()
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not remove') })
}
}
function fmtDate(iso?: string | null) {
if (!iso) return ''
try { return new Date(iso).toLocaleDateString() } catch { return iso }
}
// Friendly relabelling for verdict history.
function verdictLabel(v: string) {
return v === 'legitimate' ? 'Cleared by host' : 'Flagged by host'
}
</script>
<template>
<section class="card">
<header class="mb-1 flex items-center justify-between">
<h2 class="text-lg font-semibold">Gate</h2>
</header>
<p class="mb-5 text-sm text-zinc-400">
The gate watches every guest arriving via your invitation links. Tell it how strict to be,
let it know about networks you trust, and it'll wave through anyone normal while flagging
anything that looks like an uninvited crash.
</p>
<p v-if="error" class="mb-3 text-sm text-red-400">{{ error }}</p>
<p v-if="loading" class="text-sm text-zinc-500">Loading gate…</p>
<div v-else class="space-y-8">
<!-- Strictness presets -->
<div>
<h3 class="mb-1 text-sm font-semibold text-zinc-100">How strict should the gate be?</h3>
<p class="mb-3 text-xs text-zinc-500">
Pick the option that fits the kind of event you're hosting.
</p>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
v-for="p in PRESETS"
:key="p.id"
type="button"
:disabled="!canEdit || saving"
:class="[
'rounded-lg border p-3 text-left transition disabled:cursor-not-allowed disabled:opacity-60',
currentPreset === p.id
? 'border-brand-700/60 bg-brand-500/10 ring-1 ring-brand-500/30'
: 'border-zinc-800 bg-zinc-950 hover:border-zinc-700 hover:bg-zinc-900',
]"
@click="selectPreset(p)"
>
<div class="flex items-center justify-between">
<span :class="['text-sm font-medium', currentPreset === p.id ? 'text-brand-200' : 'text-zinc-100']">
{{ p.label }}
</span>
<span
v-if="currentPreset === p.id"
class="text-xs font-medium uppercase tracking-wider text-brand-400"
>Active</span>
</div>
<p class="mt-1 text-xs text-zinc-500">{{ p.description }}</p>
</button>
</div>
<p v-if="currentPreset === 'custom'" class="mt-3 rounded-md border border-amber-900/40 bg-amber-950/10 px-3 py-2 text-xs text-amber-300">
You're running with custom strictness settings (set under Advanced).
</p>
<!-- Advanced sliders for power users who want to dial individual bands. -->
<details class="group mt-4 rounded-lg border border-zinc-800 bg-zinc-950">
<summary class="flex cursor-pointer items-center justify-between p-3 text-sm text-zinc-300">
<span>Advanced strictness controls</span>
<svg class="h-4 w-4 text-zinc-500 transition-transform group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</summary>
<div class="space-y-4 border-t border-zinc-900 p-4">
<p class="text-xs text-zinc-500">
These directly drive the band thresholds (0100). The presets above just write
sensible triples for you.
</p>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<label class="block text-sm">
<span class="mb-1 flex items-center justify-between text-xs text-zinc-400">
<span>Watch from</span>
<span class="font-mono tabular-nums text-zinc-300">{{ thresholds.medium }}</span>
</span>
<input v-model.number="thresholds.medium" type="range" min="0" max="100"
:disabled="!canEdit" class="w-full accent-brand-500"
@change="saveThresholds()"
@input="clampOrder('medium')" />
</label>
<label class="block text-sm">
<span class="mb-1 flex items-center justify-between text-xs text-zinc-400">
<span>Flag from</span>
<span class="font-mono tabular-nums text-zinc-300">{{ thresholds.high }}</span>
</span>
<input v-model.number="thresholds.high" type="range" min="0" max="100"
:disabled="!canEdit" class="w-full accent-amber-500"
@change="saveThresholds()"
@input="clampOrder('high')" />
</label>
<label class="block text-sm">
<span class="mb-1 flex items-center justify-between text-xs text-zinc-400">
<span>Refuse from</span>
<span class="font-mono tabular-nums text-zinc-300">{{ thresholds.block }}</span>
</span>
<input v-model.number="thresholds.block" type="range" min="0" max="100"
:disabled="!canEdit" class="w-full accent-red-500"
@change="saveThresholds()"
@input="clampOrder('block')" />
</label>
</div>
</div>
</details>
</div>
<!-- Trusted networks -->
<div>
<h3 class="mb-1 text-sm font-semibold text-zinc-100">Trusted networks</h3>
<p class="mb-3 text-xs text-zinc-500">
Guests arriving from one of these networks skip the gate entirely.
Useful for your home Wi-Fi, the venue's guest network, or your office —
places where the people connecting are already known to be your guests.
</p>
<div v-if="canEdit" class="mb-3 flex flex-wrap items-center gap-2">
<button
type="button"
class="btn-ghost text-sm"
:disabled="detectingIP"
@click="detectCurrentNetwork"
>
<svg class="mr-1 inline-block h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10 18a8 8 0 100-16 8 8 0 000 16zm0-2a6 6 0 100-12 6 6 0 000 12zm0-3a3 3 0 100-6 3 3 0 000 6z" />
</svg>
{{ detectingIP ? 'Detecting' : 'Use my current network' }}
</button>
<button
type="button"
class="text-sm text-zinc-400 hover:text-zinc-200"
@click="showAddNetwork = !showAddNetwork"
>or add manually</button>
</div>
<!-- Manual add form — only shown when the host opens it. Inputs
are labelled in plain language; the placeholder hints at the
shape but the user can paste an IP, an IP/24, etc., and the
backend canonicalises. -->
<div v-if="canEdit && showAddNetwork" class="mb-3 rounded-md border border-zinc-800 bg-zinc-950 p-3">
<div class="grid grid-cols-1 gap-3 md:grid-cols-[2fr_2fr_auto]">
<div>
<label class="label">Network</label>
<input
v-model="newNetworkIP"
type="text"
class="input font-mono text-sm"
placeholder="e.g. 203.0.113.0/24 or 203.0.113.5"
/>
<p class="mt-1 text-xs text-zinc-500">
Single IP or a range. <code class="text-zinc-400">/24</code> covers a typical home or office.
</p>
</div>
<div>
<label class="label">Name</label>
<input
v-model="newNetworkLabel"
type="text"
class="input text-sm"
placeholder="Office Wi-Fi"
/>
</div>
<div class="flex items-end">
<button
type="button"
class="btn-primary text-sm"
:disabled="addingNetwork || !newNetworkIP.trim()"
@click="addNetwork"
>{{ addingNetwork ? 'Adding' : 'Add' }}</button>
</div>
</div>
</div>
<ul v-if="allowlist.length" class="space-y-1.5">
<li
v-for="entry in allowlist"
:key="entry.cidr"
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">
<p class="text-sm font-medium text-zinc-100">{{ entry.label || 'Trusted network' }}</p>
<p class="font-mono text-xs text-zinc-500">{{ entry.cidr }} · added {{ fmtDate(entry.created_at) }}</p>
</div>
<button
v-if="canEdit"
type="button"
class="text-xs text-zinc-400 hover:text-red-300"
@click="removeNetwork(entry)"
>Remove</button>
</li>
</ul>
<p v-else class="text-sm text-zinc-500">No trusted networks yet.</p>
</div>
<!-- Recent gate decisions -->
<div>
<h3 class="mb-1 text-sm font-semibold text-zinc-100">Recent gate decisions</h3>
<p class="mb-3 text-xs text-zinc-500">
Reviews you've made on flagged guests. Helps the gate learn what's normal for your event.
</p>
<ul v-if="feedback.length" class="space-y-1.5">
<li
v-for="f in feedback.slice(0, 10)"
:key="f.access_log_id"
class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm"
>
<div class="min-w-0 flex-1">
<p class="text-zinc-200">
<strong :class="f.verdict === 'legitimate' ? 'text-brand-300' : 'text-red-300'">
{{ verdictLabel(f.verdict) }}
</strong>
<span class="ml-1 text-xs text-zinc-500">· {{ fmtDate(f.created_at) }}</span>
</p>
<p v-if="f.note" class="truncate text-xs text-zinc-500">{{ f.note }}</p>
</div>
</li>
<li v-if="feedback.length > 10" class="text-xs text-zinc-500">
and {{ feedback.length - 10 }} more.
</li>
</ul>
<p v-else class="text-sm text-zinc-500">No decisions to review yet — the gate hasn't flagged anyone.</p>
</div>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-2 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-2 opacity-0"
>
<button
v-if="toast"
type="button"
class="fixed bottom-6 right-6 z-50 flex max-w-sm items-center gap-2 rounded-lg border px-4 py-3 text-left text-sm shadow-lg backdrop-blur"
:class="toast.kind === 'success'
? 'border-brand-700/60 bg-brand-950/90 text-brand-100'
: 'border-red-800/60 bg-red-950/90 text-red-100'"
role="status"
@click="toast = null"
>{{ toast.text }}</button>
</Transition>
</Teleport>
</section>
</template>
-386
View File
@@ -1,386 +0,0 @@
<script setup lang="ts">
// Tier 2 Block G — security controls for one event.
//
// Three concerns share this card because they're conceptually one
// surface ("tune the fraud detector for this event"):
// 1. Thresholds — what score counts as medium/high/block.
// 2. Allowlist — CIDRs that bypass scoring entirely.
// 3. Feedback inbox — host verdicts on past flagged accesses (the
// seed corpus for the future ML model).
//
// Reads are viewer+; writes (PUT/POST/DELETE) are editor+. The server
// enforces the role gate; this component just hides write affordances
// from viewers so the buttons don't promise something a click would
// reject.
interface Thresholds {
medium: number
high: number
block: number
defaults: { medium: number; high: number; block: number }
}
interface AllowlistEntry {
event_id: string
cidr: string
label?: string
created_at: string
}
interface FeedbackEntry {
access_log_id: string
verdict: 'legitimate' | 'suspicious'
note?: string
created_at: string
}
const props = defineProps<{
eventId: string
yourRole?: 'owner' | 'editor' | 'viewer' | null
}>()
const canEdit = computed(() => props.yourRole === 'owner' || props.yourRole === 'editor')
// --- state ---
const loading = ref(true)
const error = ref<string | null>(null)
const saving = ref(false)
const thresholds = ref<Thresholds>({ medium: 30, high: 60, block: 85, defaults: { medium: 30, high: 60, block: 85 } })
const allowlist = ref<AllowlistEntry[]>([])
const feedback = ref<FeedbackEntry[]>([])
// Inline-add form for new CIDR rows.
const newCIDR = ref('')
const newLabel = ref('')
const addingCIDR = ref(false)
// Toast — same shape as the BrandingCard toast.
type Toast = { kind: 'success' | 'error'; text: string }
const toast = ref<Toast | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | null = null
function showToast(t: Toast, ms = 4000) {
toast.value = t
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toast.value = null }, ms)
}
async function refresh() {
loading.value = true
error.value = null
try {
const [th, al, fb] = await Promise.all([
useApi<Thresholds>(`/events/${props.eventId}/security/thresholds`),
useApi<{ entries: AllowlistEntry[] }>(`/events/${props.eventId}/security/allowlist`),
useApi<{ feedback: FeedbackEntry[] }>(`/events/${props.eventId}/security/feedback`),
])
thresholds.value = th
allowlist.value = al.entries || []
feedback.value = fb.feedback || []
} catch (e: any) {
error.value = useErrMessage(e, 'Could not load security settings')
} finally {
loading.value = false
}
}
onMounted(refresh)
// --- thresholds ---
// Keep the three sliders mutually ordered: dragging medium past high
// pushes high; same for high past block. Without this the host can
// momentarily produce an invalid ordering and submit it — server
// would 400 but the form feels broken.
function clampOrder(which: 'medium' | 'high' | 'block') {
if (which === 'medium' && thresholds.value.medium > thresholds.value.high) {
thresholds.value.high = thresholds.value.medium
}
if (which === 'high') {
if (thresholds.value.high < thresholds.value.medium) thresholds.value.medium = thresholds.value.high
if (thresholds.value.high > thresholds.value.block) thresholds.value.block = thresholds.value.high
}
if (which === 'block' && thresholds.value.block < thresholds.value.high) {
thresholds.value.high = thresholds.value.block
}
}
async function saveThresholds() {
saving.value = true
try {
await useApi(`/events/${props.eventId}/security/thresholds`, {
method: 'PUT',
body: {
medium: thresholds.value.medium,
high: thresholds.value.high,
block: thresholds.value.block,
},
})
showToast({ kind: 'success', text: 'Thresholds saved.' })
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not save thresholds') })
} finally {
saving.value = false
}
}
function resetThresholds() {
thresholds.value.medium = thresholds.value.defaults.medium
thresholds.value.high = thresholds.value.defaults.high
thresholds.value.block = thresholds.value.defaults.block
}
// --- allowlist ---
async function addCIDR() {
if (!newCIDR.value.trim()) return
addingCIDR.value = true
try {
await useApi(`/events/${props.eventId}/security/allowlist`, {
method: 'POST',
body: { cidr: newCIDR.value.trim(), label: newLabel.value.trim() },
})
newCIDR.value = ''
newLabel.value = ''
showToast({ kind: 'success', text: 'Allowlist entry added.' })
await refresh()
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not add allowlist entry') })
} finally {
addingCIDR.value = false
}
}
async function removeCIDR(entry: AllowlistEntry) {
if (!confirm(`Remove allowlist entry ${entry.cidr}?`)) return
try {
await useApi(
`/events/${props.eventId}/security/allowlist?cidr=${encodeURIComponent(entry.cidr)}`,
{ method: 'DELETE' },
)
showToast({ kind: 'success', text: 'Allowlist entry removed.' })
await refresh()
} catch (e: any) {
showToast({ kind: 'error', text: useErrMessage(e, 'Could not remove allowlist entry') })
}
}
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">
<div>
<h2 class="text-lg font-semibold">Security</h2>
<p class="text-xs text-zinc-500">
Tune the fraud detector for this event.
</p>
</div>
</header>
<p v-if="error" class="mb-3 text-sm text-red-400">{{ error }}</p>
<p v-if="loading" class="text-sm text-zinc-500">Loading security settings</p>
<div v-else class="space-y-8">
<!-- Thresholds -->
<div>
<div class="mb-3 flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-zinc-100">Risk thresholds</h3>
<p class="text-xs text-zinc-500">
Scores at or above each cut-off land in that band. Defaults work for most events.
</p>
</div>
<div class="flex items-center gap-2">
<button
v-if="canEdit"
type="button"
class="text-xs text-zinc-400 hover:text-zinc-200"
@click="resetThresholds"
>Reset to defaults</button>
<button
v-if="canEdit"
type="button"
class="btn-primary text-sm"
:disabled="saving"
@click="saveThresholds"
>{{ saving ? 'Saving…' : 'Save thresholds' }}</button>
</div>
</div>
<!-- Visual band ribbon: each segment width matches its threshold
range. Gives the host an at-a-glance read of where their
cut-offs sit, instead of three abstract numbers. -->
<div class="mb-4 h-2 w-full overflow-hidden rounded-full bg-zinc-900">
<div class="flex h-full w-full">
<div :style="{ width: thresholds.medium + '%' }" class="bg-brand-700"></div>
<div :style="{ width: (thresholds.high - thresholds.medium) + '%' }" class="bg-amber-700"></div>
<div :style="{ width: (thresholds.block - thresholds.high) + '%' }" class="bg-orange-700"></div>
<div :style="{ width: (100 - thresholds.block) + '%' }" class="bg-red-700"></div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<label class="block text-sm">
<span class="mb-1 flex items-center justify-between text-xs text-zinc-400">
<span><span class="inline-block h-2 w-2 rounded-full bg-brand-500 mr-1.5"></span>Medium</span>
<span class="font-mono tabular-nums text-zinc-300">{{ thresholds.medium }}</span>
</span>
<input
v-model.number="thresholds.medium"
type="range"
min="0"
max="100"
:disabled="!canEdit"
class="w-full accent-brand-500"
@input="clampOrder('medium')"
/>
</label>
<label class="block text-sm">
<span class="mb-1 flex items-center justify-between text-xs text-zinc-400">
<span><span class="inline-block h-2 w-2 rounded-full bg-amber-500 mr-1.5"></span>High</span>
<span class="font-mono tabular-nums text-zinc-300">{{ thresholds.high }}</span>
</span>
<input
v-model.number="thresholds.high"
type="range"
min="0"
max="100"
:disabled="!canEdit"
class="w-full accent-amber-500"
@input="clampOrder('high')"
/>
</label>
<label class="block text-sm">
<span class="mb-1 flex items-center justify-between text-xs text-zinc-400">
<span><span class="inline-block h-2 w-2 rounded-full bg-red-500 mr-1.5"></span>Block</span>
<span class="font-mono tabular-nums text-zinc-300">{{ thresholds.block }}</span>
</span>
<input
v-model.number="thresholds.block"
type="range"
min="0"
max="100"
:disabled="!canEdit"
class="w-full accent-red-500"
@input="clampOrder('block')"
/>
</label>
</div>
</div>
<!-- Allowlist -->
<div>
<h3 class="mb-1 text-sm font-semibold text-zinc-100">IP allowlist</h3>
<p class="mb-3 text-xs text-zinc-500">
Networks that bypass scoring. Use for office Wi-Fi, the venue's
guest network, or a family router. Accepts a single IP or a CIDR
range. Score is forced to 0 for matching requests.
</p>
<div v-if="canEdit" class="mb-3 flex items-end gap-2">
<div class="flex-1">
<label class="label">CIDR</label>
<input
v-model="newCIDR"
type="text"
placeholder="203.0.113.0/24 or 10.0.0.42"
class="input font-mono text-sm"
/>
</div>
<div class="flex-1">
<label class="label">Label (optional)</label>
<input
v-model="newLabel"
type="text"
placeholder="Office Wi-Fi"
class="input text-sm"
/>
</div>
<button
type="button"
class="btn-primary text-sm"
:disabled="addingCIDR || !newCIDR.trim()"
@click="addCIDR"
>{{ addingCIDR ? 'Adding' : 'Add' }}</button>
</div>
<ul v-if="allowlist.length" class="space-y-1.5">
<li
v-for="entry in allowlist"
:key="entry.cidr"
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">
<p class="font-mono text-sm text-zinc-100">{{ entry.cidr }}</p>
<p class="text-xs text-zinc-500">
{{ entry.label || 'no label' }} · added {{ fmtDate(entry.created_at) }}
</p>
</div>
<button
v-if="canEdit"
type="button"
class="text-xs text-zinc-400 hover:text-red-300"
@click="removeCIDR(entry)"
>Remove</button>
</li>
</ul>
<p v-else class="text-sm text-zinc-500">No allowlist entries yet.</p>
</div>
<!-- Feedback inbox — read-only summary; the actual "mark legitimate / suspicious"
lives on the live monitor / activity feed where the host sees the
access in context. This is the audit trail. -->
<div>
<h3 class="mb-1 text-sm font-semibold text-zinc-100">Verdict history</h3>
<p class="mb-3 text-xs text-zinc-500">
Your previous "legitimate" / "suspicious" marks on flagged accesses. Seeds the model and silences known false positives.
</p>
<ul v-if="feedback.length" class="space-y-1.5">
<li
v-for="f in feedback.slice(0, 10)"
:key="f.access_log_id"
class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm"
>
<div class="min-w-0 flex-1">
<p class="text-zinc-200">
Marked <strong :class="f.verdict === 'legitimate' ? 'text-brand-300' : 'text-red-300'">{{ f.verdict }}</strong>
<span class="ml-1 text-xs text-zinc-500">· {{ fmtDate(f.created_at) }}</span>
</p>
<p v-if="f.note" class="truncate text-xs text-zinc-500">{{ f.note }}</p>
</div>
</li>
<li v-if="feedback.length > 10" class="text-xs text-zinc-500">
and {{ feedback.length - 10 }} more.
</li>
</ul>
<p v-else class="text-sm text-zinc-500">No verdicts yet.</p>
</div>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-2 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-2 opacity-0"
>
<button
v-if="toast"
type="button"
class="fixed bottom-6 right-6 z-50 flex max-w-sm items-center gap-2 rounded-lg border px-4 py-3 text-left text-sm shadow-lg backdrop-blur"
:class="toast.kind === 'success'
? 'border-brand-700/60 bg-brand-950/90 text-brand-100'
: 'border-red-800/60 bg-red-950/90 text-red-100'"
role="status"
@click="toast = null"
>{{ toast.text }}</button>
</Transition>
</Teleport>
</section>
</template>