98678ff5a3
Three threads of work land here together to close out Tier 2.
### Block H follow-ups — day-of check-in
- Scanner is now an "open on your phone" magic-link flow. Hosts on
desktop mint a scoped JWT via POST /events/{id}/scanner-ticket and
render its URL into a QR; phone scans it and lands on /scanner with
the ticket as bearer. The ticket carries Audience=scanner so it can
never substitute for a session token.
- Plus-one confirmation at the door: scan → POST /check-in/preview to
fetch guest + expected party size → confirm buttons ("Just them",
"Party of N", custom) → POST /check-in. No more silent arrival_count=1.
- Offline scan queue: failed POSTs go into an IndexedDB store and drain
on the 'online' event with poison-message protection.
- Day-of arrivals headline widget on the event overview, gated to the
host's local calendar date so it doesn't dominate the page weeks out.
- Tab nav restyled with inline heroicons + scrollable segmented control;
Check-in moves to the rightmost slot.
- PWA: manifest + service worker scoped to /scanner, generated 192/512
icons (Go scripted renderer in scripts/gen-scanner-icons.go).
- Confirmation email QR was rendering broken because html/template
rewrites data: URLs to #ZgotmplZ; mark the value as template.URL.
- Email "open your invitation" link 404'd because we had no token to
put after /rsvp/. Threaded AccessLink through the RSVPConfirmed NATS
event from the API at submit time.
### Block G remainder — geolocation + threshold preview
- Pluggable GeoResolver in the fraud engine (NullResolver, IPApiResolver
for the free ip-api.com fallback, MaxMindResolver behind GG_GEOIP_DB_PATH).
Wrapped in a Redis cache (30d TTL). Geo flows through both gRPC and
NATS scoring paths.
- geo_jump scoring feature: >500km in <1h flags ("accessed from Lagos
and Paris within 12 minutes"); >500km in <6h is a softer signal. The
existing single-signal cap keeps a lone geo_jump in MEDIUM.
- FraudScored event carries geo_country/city/lat/lon; ApplyScore uses
COALESCE so a later re-score without geo doesn't wipe earlier data.
- Threshold-slider live preview: GET /events/{id}/security/thresholds/preview
returns band counts the host's existing access events would have
fallen into under the proposed thresholds. Debounced (250ms) widget
under the Advanced sliders so the host gets concrete feedback instead
of guessing.
### Cross-cutting — audit, tier-gating, feature flags
- audit_log table + internal/audit.Recorder (async fire-and-forget on
detached context so an audit blip never fails the real action). Wired
into branding update, thresholds update, allowlist add/remove,
collaborator invite/role-change/remove, message create/send-now/cancel.
- Tier-gating: extended billing.Limits with MaxCollaborators,
CustomBranding, Scanner, Broadcasts. Free = none; Pro = 5 + all;
Business = unlimited. Gates the scanner-ticket, message create,
branding put, and collaborator invite endpoints with 402 +
structured upgrade payload. Auto-reminders, fraud detection, and
analytics deliberately stay on every tier — those are safety + visibility
features, not upsell levers.
- Feature flags: feature_flags table + internal/flags.Store with 30s
in-memory refresh, stable sha256(key + user_id) percent bucketing,
unknown-key-defaults-on. Six Tier 2 flags pre-seeded. Three handlers
(branding, broadcasts, scanner) check the kill switch ahead of the
tier gate so ops can pull a feature back without a redeploy.
### Verified
- go test ./... + fraud-engine pytest (12/12 incl. 3 new geo_jump tests + 5
new flags tests).
- docker compose build + up across api, fraud-engine, notifier, frontend.
- /health endpoints 200; migrations 0014 + 0015 applied; 6 flags
seeded; audit_log table + partial indexes confirmed.
- Fraud-engine logs confirm geo resolver kind=CachedGeoResolver provider=auto.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
724 lines
31 KiB
Vue
724 lines
31 KiB
Vue
<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: "Casual party. It's fine if a few friends share their link with each other.",
|
||
medium: 50, high: 80, block: 95,
|
||
},
|
||
{
|
||
id: 'balanced',
|
||
label: 'Balanced',
|
||
description: "Recommended. Stops forwarded links from being used by people who weren't invited, without nagging anyone normal.",
|
||
medium: 30, high: 60, block: 85,
|
||
},
|
||
{
|
||
id: 'strict',
|
||
label: 'Strict',
|
||
description: "Wedding or private event. Uninvited plus-ones are a problem and the guest list really matters.",
|
||
medium: 25, high: 50, block: 70,
|
||
},
|
||
{
|
||
id: 'very_strict',
|
||
label: 'Very strict',
|
||
description: "VIP or high-profile guest list. Even small mismatches get flagged for your review.",
|
||
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[]>([])
|
||
|
||
// Threshold-preview state. Live counter under the sliders — given the
|
||
// host's *proposed* settings, how would the event's past access events
|
||
// have been classified? Removes the "guess and wait for new scans"
|
||
// step that used to be the only way to feel confident before saving.
|
||
interface ThresholdPreview {
|
||
total: number
|
||
low: number
|
||
medium: number
|
||
high: number
|
||
block: number
|
||
}
|
||
const preview = ref<ThresholdPreview | null>(null)
|
||
const previewLoading = ref(false)
|
||
let previewTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
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)
|
||
|
||
// Debounced preview fetch. Watching the threshold refs catches both
|
||
// slider drags and preset clicks; 250ms is short enough to feel
|
||
// instant while still avoiding a request per pixel of drag.
|
||
async function fetchPreview() {
|
||
try {
|
||
previewLoading.value = true
|
||
const t = thresholds.value
|
||
const data = await useApi<ThresholdPreview>(
|
||
`/events/${props.eventId}/security/thresholds/preview?medium=${t.medium}&high=${t.high}&block=${t.block}`,
|
||
)
|
||
preview.value = data
|
||
} catch {
|
||
// Network blip / 403 from viewer → just hide the panel until next try.
|
||
preview.value = null
|
||
} finally {
|
||
previewLoading.value = false
|
||
}
|
||
}
|
||
watch(
|
||
() => [thresholds.value.medium, thresholds.value.high, thresholds.value.block],
|
||
() => {
|
||
if (previewTimer) clearTimeout(previewTimer)
|
||
previewTimer = setTimeout(fetchPreview, 250)
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// 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-3 flex items-center justify-between">
|
||
<div>
|
||
<h2 class="text-lg font-semibold">Gate</h2>
|
||
<p class="text-xs text-zinc-500">Only your guests get in.</p>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Value-prop paragraph. Plain language, no em-dashes, no example
|
||
names. Hits the actual pain (forwarded / shared invitation
|
||
links) head-on. -->
|
||
<div class="mb-4 rounded-lg border border-brand-700/40 bg-brand-500/[0.04] p-4 text-sm leading-relaxed">
|
||
<p class="text-zinc-200">
|
||
Every guest gets their own personal invitation link. Only one person was meant to use it.
|
||
The <strong class="text-brand-300">Gate</strong> quietly watches each link, so when
|
||
someone forwards or shares an invitation, the people it ends up with can't actually use it.
|
||
</p>
|
||
<p class="mt-2 text-xs text-zinc-400">
|
||
Your invited guests won't notice a thing. There's nothing you need to set up for this
|
||
to work; the Gate runs in the background with sensible defaults from the moment your
|
||
event is live.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- "How does this work?" — collapsed by default so the page stays
|
||
scannable. Plain-language walkthrough; uses a generic placeholder
|
||
name (Sam) rather than family vocabulary so the example fits
|
||
wedding couples, party hosts, and corporate planners equally. -->
|
||
<details class="group mb-6 rounded-lg border border-zinc-800 bg-zinc-950">
|
||
<summary class="flex cursor-pointer items-center justify-between p-3 text-sm font-medium text-zinc-200">
|
||
<span class="flex items-center gap-2">
|
||
<svg class="h-4 w-4 text-zinc-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2h-1V9z" clip-rule="evenodd" />
|
||
</svg>
|
||
How does the Gate work?
|
||
</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-3 border-t border-zinc-900 p-4 text-sm text-zinc-300">
|
||
<p class="text-zinc-400">
|
||
Here's the short version, using "Sam" as a stand-in for any one of your invited guests.
|
||
</p>
|
||
<ol class="space-y-3">
|
||
<li class="flex gap-3">
|
||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-brand-500/15 text-xs font-semibold text-brand-300">1</span>
|
||
<p>
|
||
When you send Sam an invitation, the link inside it is just for Sam. Nobody else
|
||
has the same link.
|
||
</p>
|
||
</li>
|
||
<li class="flex gap-3">
|
||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-brand-500/15 text-xs font-semibold text-brand-300">2</span>
|
||
<p>
|
||
The first time Sam opens it, the Gate quietly notes a few details about the visit:
|
||
the phone or browser Sam is on, roughly where Sam is in the world, and the kind of
|
||
network Sam is connected to. Together those become Sam's "this is really me" picture.
|
||
</p>
|
||
</li>
|
||
<li class="flex gap-3">
|
||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-brand-500/15 text-xs font-semibold text-brand-300">3</span>
|
||
<p>
|
||
Every later click on Sam's link, including Sam coming back to update their reply,
|
||
gets compared to that first visit. If it looks like the same person, they go straight
|
||
through with no friction at all.
|
||
</p>
|
||
</li>
|
||
<li class="flex gap-3">
|
||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-brand-500/15 text-xs font-semibold text-brand-300">4</span>
|
||
<p>
|
||
If a completely different device on a completely different network tries to use Sam's
|
||
link, say because Sam forwarded it to a friend, the Gate notices. Depending on how
|
||
strict you've set it, the Gate will either flag that click for you to review, or stop
|
||
the RSVP from going through at all.
|
||
</p>
|
||
</li>
|
||
</ol>
|
||
<p class="rounded-md border border-zinc-800 bg-zinc-900/60 p-3 text-xs text-zinc-400">
|
||
<strong class="text-zinc-300">Worth knowing:</strong> guests can switch between Wi-Fi
|
||
and mobile data, change rooms, or open their link a few days later without being
|
||
flagged. The Gate only cares about <em>meaningful</em> differences from that first
|
||
visit, not normal day-to-day variation. And if a real guest of yours ever does end up
|
||
flagged, you can clear them with one click under <em>Recent reviews</em> below.
|
||
</p>
|
||
</div>
|
||
</details>
|
||
|
||
<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. You can change it any time.
|
||
</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 — three sliders that map directly to the engine's
|
||
band thresholds. Heavy intro copy is intentional: most
|
||
hosts who open this are curious rather than expert, and a
|
||
short plain-language explanation here keeps them from
|
||
feeling lost. -->
|
||
<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">
|
||
<div class="space-y-2 text-xs text-zinc-400">
|
||
<p class="text-zinc-300">
|
||
For most hosts, the four presets above are all you need. These sliders are
|
||
here if you want to fine-tune exactly when each reaction kicks in.
|
||
</p>
|
||
<p>
|
||
As a click on a guest's link looks more and more different from their first
|
||
visit, the Gate moves through three reactions: watch silently, flag for your
|
||
review, or refuse the RSVP. The numbers below (from 0 to 100) are how much
|
||
difference is enough to trigger each one. Lower numbers make the Gate more
|
||
sensitive; higher numbers make it more forgiving.
|
||
</p>
|
||
</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 class="flex items-center gap-1.5">
|
||
<span class="inline-block h-2 w-2 rounded-full bg-brand-500"></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')" />
|
||
<p class="mt-1 text-[11px] leading-snug text-zinc-500">
|
||
When the Gate starts paying closer attention. Quiet for guests; visible only
|
||
to you in activity logs.
|
||
</p>
|
||
</label>
|
||
<label class="block text-sm">
|
||
<span class="mb-1 flex items-center justify-between text-xs text-zinc-400">
|
||
<span class="flex items-center gap-1.5">
|
||
<span class="inline-block h-2 w-2 rounded-full bg-amber-500"></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')" />
|
||
<p class="mt-1 text-[11px] leading-snug text-zinc-500">
|
||
When the Gate marks the click for your review. The RSVP still goes through;
|
||
you decide whether to clear it or flag the guest.
|
||
</p>
|
||
</label>
|
||
<label class="block text-sm">
|
||
<span class="mb-1 flex items-center justify-between text-xs text-zinc-400">
|
||
<span class="flex items-center gap-1.5">
|
||
<span class="inline-block h-2 w-2 rounded-full bg-red-500"></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')" />
|
||
<p class="mt-1 text-[11px] leading-snug text-zinc-500">
|
||
Where the Gate refuses the RSVP outright. The visitor sees a polite "this
|
||
invitation can't be used" and you get a notification.
|
||
</p>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Threshold preview. Concrete counts answer the host's
|
||
unspoken "is this too strict?" question better than any
|
||
amount of explainer copy. Empty state when nothing's
|
||
been scored yet keeps the panel honest. -->
|
||
<div class="mt-4 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 text-xs">
|
||
<div class="mb-2 flex items-center justify-between text-zinc-400">
|
||
<span>
|
||
With these settings applied to your last
|
||
<span class="font-medium text-zinc-200">{{ preview?.total ?? 0 }}</span>
|
||
{{ (preview?.total ?? 0) === 1 ? 'access event' : 'access events' }}:
|
||
</span>
|
||
<span v-if="previewLoading" class="text-zinc-500">refreshing…</span>
|
||
</div>
|
||
<div v-if="!preview || preview.total === 0" class="text-zinc-500">
|
||
No scored access events yet. The preview will fill in as guests start opening invitations.
|
||
</div>
|
||
<div v-else class="grid grid-cols-4 gap-2">
|
||
<div class="rounded-md border border-zinc-800 bg-zinc-950 p-2 text-center">
|
||
<div class="text-[10px] uppercase tracking-wider text-zinc-500">Quiet</div>
|
||
<div class="mt-0.5 text-lg font-semibold tabular-nums text-zinc-200">{{ preview.low }}</div>
|
||
</div>
|
||
<div class="rounded-md border border-brand-700/40 bg-brand-500/[0.06] p-2 text-center">
|
||
<div class="text-[10px] uppercase tracking-wider text-brand-400">Watched</div>
|
||
<div class="mt-0.5 text-lg font-semibold tabular-nums text-brand-200">{{ preview.medium }}</div>
|
||
</div>
|
||
<div class="rounded-md border border-amber-800/40 bg-amber-500/[0.06] p-2 text-center">
|
||
<div class="text-[10px] uppercase tracking-wider text-amber-300">Flagged</div>
|
||
<div class="mt-0.5 text-lg font-semibold tabular-nums text-amber-200">{{ preview.high }}</div>
|
||
</div>
|
||
<div class="rounded-md border border-red-800/40 bg-red-500/[0.06] p-2 text-center">
|
||
<div class="text-[10px] uppercase tracking-wider text-red-300">Refused</div>
|
||
<div class="mt-0.5 text-lg font-semibold tabular-nums text-red-200">{{ preview.block }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<!-- Trusted networks -->
|
||
<div>
|
||
<div class="mb-2 flex items-center gap-2">
|
||
<h3 class="text-sm font-semibold text-zinc-100">Trusted networks</h3>
|
||
<span class="rounded-full border border-zinc-700 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-zinc-500">
|
||
Optional · most hosts don't need this
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Direct address to the misconception the user raised. "Does that
|
||
mean everyone outside is treated as fraud?" — explicit "no".
|
||
This sits *above* the inputs so a host can't miss it. -->
|
||
<div class="mb-3 rounded-md border border-zinc-800 bg-zinc-950 p-3 text-xs text-zinc-400">
|
||
<p class="mb-1 text-zinc-300">
|
||
<strong>What this is and isn't:</strong>
|
||
</p>
|
||
<p class="mb-2">
|
||
Adding a network here tells the Gate to always wave through clicks coming from
|
||
that Wi-Fi, because the people on it are already with you. It's useful when
|
||
you're hosting at home or in the office and your guests will be on your network
|
||
when they reply.
|
||
</p>
|
||
<p>
|
||
<strong class="text-zinc-300">It doesn't change how anyone else is treated.</strong>
|
||
Guests connecting from their own homes, from mobile data, or from anywhere else
|
||
still get the regular check. They aren't suspected of anything just because they
|
||
aren't on your network. The Gate works perfectly well with this list empty, and
|
||
most events stay that way.
|
||
</p>
|
||
</div>
|
||
|
||
<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…' : 'Trust the network I’m on right now' }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="text-sm text-zinc-400 hover:text-zinc-200"
|
||
@click="showAddNetwork = !showAddNetwork"
|
||
>or add one 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">
|
||
You haven't added any trusted networks, and that's fine. The Gate is still doing its job.
|
||
</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">Nothing 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>
|