Files
guestguard/frontend/pages/rsvp/[token].vue
T
Kwaku Danso dbddf17e3b fix(rsvp): defend the edit flow against forwarded invitation links
When a guest submitted via their invitation and then forwarded the link
(or someone copied the URL), the recipient was shown the original guest's
response and a "Change my response" button. Two real problems:
  - Privacy leak: the original guest's reply was visible
  - Integrity: the recipient could silently overwrite the response

The fix is two layered defences plus a recovery path, matching the
industry pattern used by Eventbrite / Partiful / Lu.ma:

Backend
  - GET /access/{token} now compares the device fingerprint of the
    current request to the fingerprint stored on the existing RSVP.
    When they don't match, the rsvp field is omitted from the
    response and a new rsvp_submitted_elsewhere flag is set instead.
    The original guest's reply stays private.
  - PATCH /rsvp/{token} runs the same gate before scoring. A foreign
    device gets 403 with a hint to request an edit link.
  - The fingerprint check is intentionally narrow (user_agent only),
    so a guest jumping between Wi-Fi and mobile data on the same
    phone still sails through.

Recovery path
  - New POST /access/{token}/request-edit-link mints a short-lived
    edit nonce (Redis, 30-min TTL, SHA-256-hashed), then emails it
    to the guest's address on file via the existing EmailSender.
    Rate-limited to 3 per token per hour.
  - GET /access/{token}?edit=<nonce> and PATCH /rsvp with edit_nonce
    in the body both accept the nonce as a bypass for the
    same-device check. Lets the real guest edit from a new phone
    when their original device is gone.
  - New SendRSVPEditLink method on auth.EmailSender, implemented by
    every concrete sender (log stub / Resend / SMTP / SES), with a
    branded HTML+text template that explains the "we sent this
    because we didn't recognise the device" framing.

Frontend
  - rsvp/[token].vue learns the new "responded elsewhere" state.
    Renders "This invitation has already been used" + a
    "Send me an edit link" CTA when the access response says we
    have somewhere to deliver it. Empty-state copy reads "If you
    forwarded the link, please ask the original guest to reach
    out to the host".
  - When the URL carries ?edit=<nonce>, the page passes it on the
    /access call (so the backend unhides the RSVP), opens the edit
    form pre-populated, and forwards the nonce on PATCH.
  - Removed two leftover leaks from earlier — the page no longer
    shows internal "Risk score N · band" to confirmed or blocked
    guests; the blocked-attempt copy now reads "Something about
    this attempt looked off" rather than "suspicious access
    attempt".

Defensive nil-guard
  - The access handler's NATS publish goroutine now skips when
    deps.AccessPublisher is nil (matches the rsvp publisher's
    existing guard); without it the handler nil-panicked in tests
    that don't wire NATS.

Tests
  - TestFingerprintsSimilar (unit) covers the same-UA / different-UA
    / missing-UA matrix.
  - TestForwardedInvitationLinkDefence (integration) walks the full
    flow: submit from UA-A, hide on UA-B, request link, follow nonce
    from UA-B and edit, then verify a UA-C with a forged nonce is
    still refused.
  - Full integration suite passes (183.5s).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 15:09:07 +01:00

469 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
interface ExistingRSVP {
id: string
response: 'attending' | 'declined' | 'maybe'
plus_ones: number
dietary_notes?: string | null
submitted_at: string
edit_count: number
}
interface CalendarLinks {
google: string
outlook: string
yahoo: string
ics: string
}
interface BrandingPayload {
primary_color?: string | null
accent_color?: string | null
logo_url?: string | null
cover_image_url?: string | null
font_family?: string | null
greeting_message?: string | null
}
interface AccessResponse {
guest: { id: string; name: string; email?: string | null; plus_ones: number }
event: { id: string; name: string; venue: string; event_date: string }
token: { id: string; status: string; expires_at: string }
access_log_id: string
rsvp?: ExistingRSVP | null
// Forwarded-link defence (Block G follow-up). When the Gate didn't
// recognise the device, the existing RSVP is hidden from the
// response and these flags drive the alternative UX.
rsvp_submitted_elsewhere?: boolean
can_request_edit_link?: boolean
calendar?: CalendarLinks
branding?: BrandingPayload | null
}
interface RSVPSubmitResponse {
rsvp?: ExistingRSVP & { risk_score?: number }
fraud: { score: number; risk: string; reasons: string[]; used: boolean }
blocked: boolean
edited?: boolean
}
const MAX_EDITS = 5
const route = useRoute()
const token = route.params.token as string
const loading = ref(true)
const access = ref<AccessResponse | null>(null)
const loadError = ref<string | null>(null)
const response = ref<'attending' | 'declined' | 'maybe'>('attending')
const plusOnes = ref(0)
const dietary = ref('')
const submitting = ref(false)
const result = ref<RSVPSubmitResponse | null>(null)
const submitError = ref<string | null>(null)
// existing tracks the RSVP that was on file when the page loaded.
// editing toggles the form open over the "already responded" summary.
const existing = ref<ExistingRSVP | null>(null)
const editing = ref(false)
// Forwarded-link defence state.
// When the access response sets rsvp_submitted_elsewhere=true, an RSVP
// exists but the Gate didn't recognise this device, so we don't surface
// the response details. The original guest (if it really is them on a
// new phone) can request a one-time edit link to their email.
const respondedElsewhere = ref(false)
const canRequestEditLink = ref(false)
const requestingEditLink = ref(false)
const editLinkSent = ref(false)
const editLinkError = ref<string | null>(null)
// editNonce comes from the magic edit link the guest receives by email
// (?edit=<nonce> in the URL). When present we pass it on /access and
// later on PATCH so the backend treats this device as the original.
const editNonce = computed(() => {
const q = route.query.edit
return typeof q === 'string' ? q : ''
})
const editsRemaining = computed(() => {
const used = existing.value?.edit_count ?? 0
return Math.max(0, MAX_EDITS - used)
})
const editLimitReached = computed(() => editsRemaining.value <= 0)
function prefillFromRSVP(rsvp: ExistingRSVP) {
response.value = rsvp.response
plusOnes.value = rsvp.plus_ones
dietary.value = rsvp.dietary_notes ?? ''
}
onMounted(async () => {
try {
// Pass through the edit nonce if the guest arrived via the magic
// edit link. The backend uses it to unhide the existing RSVP.
const path = editNonce.value
? `/access/${token}?edit=${encodeURIComponent(editNonce.value)}`
: `/access/${token}`
access.value = await useApi<AccessResponse>(path)
if (!access.value) return
plusOnes.value = access.value.guest.plus_ones || 0
if (access.value.rsvp) {
existing.value = access.value.rsvp
prefillFromRSVP(access.value.rsvp)
// Magic-link arrival: open the form straight into edit mode so
// the guest doesn't have to click "Change my response" first.
if (editNonce.value) editing.value = true
} else if (access.value.rsvp_submitted_elsewhere) {
respondedElsewhere.value = true
canRequestEditLink.value = !!access.value.can_request_edit_link
}
} catch (e: any) {
loadError.value = e?.data?.error || e?.message || 'Invitation not found'
} finally {
loading.value = false
}
})
async function requestEditLink() {
requestingEditLink.value = true
editLinkError.value = null
try {
await useApi(`/access/${token}/request-edit-link`, { method: 'POST' })
editLinkSent.value = true
} catch (e: any) {
editLinkError.value = e?.data?.error || e?.message || 'Could not send the edit link'
} finally {
requestingEditLink.value = false
}
}
async function submit() {
submitting.value = true
submitError.value = null
const isEdit = !!existing.value
try {
const fp = useFingerprint()
result.value = await useApi<RSVPSubmitResponse>(`/rsvp/${token}`, {
method: isEdit ? 'PATCH' : 'POST',
body: {
response: response.value,
plus_ones: plusOnes.value,
dietary_notes: dietary.value || null,
fingerprint: fp,
// Forwarded-link defence: pass the edit nonce on PATCH so the
// backend can bypass the same-device check when the guest is
// legitimately on a new device. Empty on POST and on
// same-device edits.
...(editNonce.value ? { edit_nonce: editNonce.value } : {}),
},
})
// Refresh the cached "existing" view so a back-to-summary toggle shows
// the new state and the edit counter without a page reload.
if (result.value?.rsvp) {
existing.value = {
id: result.value.rsvp.id,
response: result.value.rsvp.response,
plus_ones: result.value.rsvp.plus_ones,
dietary_notes: result.value.rsvp.dietary_notes ?? null,
submitted_at: result.value.rsvp.submitted_at,
edit_count: result.value.rsvp.edit_count,
}
}
editing.value = false
} catch (e: any) {
// BLOCK band returns 403 with the fraud decision; surface it the same
// way the first-submit path does.
if (e?.data?.fraud) {
result.value = e.data
} else {
submitError.value = e?.data?.error || e?.message || 'Could not submit RSVP'
}
} finally {
submitting.value = false
}
}
function startEditing() {
if (existing.value) prefillFromRSVP(existing.value)
result.value = null
editing.value = true
}
function cancelEditing() {
if (existing.value) prefillFromRSVP(existing.value)
editing.value = false
}
function fmtDate(iso?: string) {
if (!iso) return ''
try { return new Date(iso).toLocaleString() } catch { return iso }
}
// Calendar links come from the backend so we don't reimplement Google /
// Outlook / Yahoo encoding rules per provider. They're available as soon
// as /access loads — the guest can grab them before submitting.
const calendar = computed<CalendarLinks | null>(() => access.value?.calendar ?? null)
// Branding (Tier 2 Block D). Null = use defaults. Each field is optional;
// missing pieces fall back to the GuestGuard look.
const branding = computed<BrandingPayload | null>(() => access.value?.branding ?? null)
const brandingStyle = computed(() => {
const b = branding.value
if (!b) return {}
const style: Record<string, string> = {}
if (b.primary_color) style['--brand-primary'] = b.primary_color
if (b.accent_color) style['--brand-accent'] = b.accent_color
if (b.font_family) style.fontFamily = b.font_family
return style
})
const greetingMessage = computed(() => branding.value?.greeting_message || '')
// showForm = no prior submission, or the guest has clicked "Change my response"
const showForm = computed(() => editing.value || !existing.value)
const submitLabel = computed(() => {
if (submitting.value) return existing.value ? 'Updating…' : 'Submitting…'
return existing.value ? 'Update RSVP' : 'Submit RSVP'
})
</script>
<template>
<!-- Branding (Tier 2 Block D) lives at the section root so every card
below inherits the CSS vars + font. brandingStyle is empty when the
host hasn't customised, so defaults apply naturally. -->
<section class="mx-auto max-w-xl py-8" :style="brandingStyle">
<div v-if="loading" class="text-sm text-zinc-500">Looking up your invitation…</div>
<div v-else-if="loadError" class="card border-red-900/60 bg-red-950/30">
<h1 class="mb-2 text-xl font-semibold text-red-200">Invitation unavailable</h1>
<p class="text-sm text-red-300">{{ loadError }}</p>
</div>
<div v-else-if="result?.blocked" class="card border-red-900/60 bg-red-950/30">
<h1 class="mb-2 text-xl font-semibold text-red-200">This invitation cannot be used</h1>
<p class="text-sm text-red-300">
Something about this attempt looked off and the host has been notified.
If you believe this is a mistake, please reach out to them directly.
</p>
</div>
<!-- Forwarded-link landing. There's an RSVP on file for this invitation,
but we don't recognise this device, so we don't show the response
or let it be changed here. If we have the original guest's email
on file, they can request a one-time edit link to recover. -->
<div v-else-if="respondedElsewhere" class="card">
<h1 class="mb-2 text-xl font-semibold">This invitation has already been used</h1>
<p class="mb-4 text-sm text-zinc-300">
Someone has already replied with this invitation, so the response
is private. If you forwarded the link, please ask the original guest
to reach out to the host.
</p>
<div v-if="canRequestEditLink && !editLinkSent" class="rounded-md border border-brand-900/40 bg-brand-500/[0.04] p-4">
<p class="mb-3 text-sm text-zinc-200">
<strong>Is this your invitation, just on a new device?</strong>
We can send a one-time edit link to the email we have on file
so you can review and update your reply.
</p>
<button
type="button"
class="btn-primary text-sm"
:disabled="requestingEditLink"
@click="requestEditLink"
>
{{ requestingEditLink ? 'Sending' : 'Send me an edit link' }}
</button>
<p v-if="editLinkError" class="mt-3 text-sm text-red-400">{{ editLinkError }}</p>
<p class="mt-3 text-xs text-zinc-500">
The link expires in 30 minutes. If you don't see the email shortly,
check your spam folder or ask your host to resend your invitation.
</p>
</div>
<div v-else-if="editLinkSent" class="rounded-md border border-brand-900/40 bg-brand-500/[0.04] p-4">
<p class="text-sm text-brand-200">
<strong>Edit link on its way.</strong>
</p>
<p class="mt-2 text-sm text-zinc-300">
Check your inbox for an email from us. The link expires in 30 minutes,
so do click it as soon as you can.
</p>
</div>
<p v-else class="text-xs text-zinc-500">
If you think you're the original guest, please contact your host for help.
</p>
</div>
<div v-else-if="result?.rsvp && !editing" class="card border-brand-900/60 bg-brand-950/20">
<h1 class="mb-2 text-xl font-semibold text-brand-200">
{{ result.edited ? 'Your response has been updated' : "You're confirmed" }}
</h1>
<p class="text-sm text-brand-300">
Response recorded as <strong>{{ result.rsvp.response }}</strong> with
+{{ result.rsvp.plus_ones }} plus-ones.
</p>
<AddToCalendar
v-if="calendar && result.rsvp.response === 'attending'"
:links="calendar"
:token="token"
class="mt-5 border-t border-zinc-800 pt-4"
/>
<div v-if="!editLimitReached" class="mt-5 flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
<p class="text-xs text-zinc-500">
Need to change something? You have {{ editsRemaining }}
{{ editsRemaining === 1 ? 'edit' : 'edits' }} left.
</p>
<button type="button" class="btn-ghost text-sm" @click="startEditing">Change my response</button>
</div>
<p v-else class="mt-5 border-t border-zinc-800 pt-4 text-xs text-zinc-500">
You've used all {{ MAX_EDITS }} edits on this invitation — contact your host if you need
to change anything else.
</p>
</div>
<div v-else-if="existing && !editing" class="card border-brand-900/60 bg-brand-950/20">
<p class="text-xs uppercase tracking-widest text-brand-500">RSVP on file</p>
<h1 class="mb-1 text-2xl font-semibold">{{ access?.event.name }}</h1>
<p class="mb-5 text-sm text-zinc-400">
{{ access?.event.venue }} · {{ fmtDate(access?.event.event_date) }}
</p>
<p class="mb-4 text-sm">
You responded <strong class="capitalize">{{ existing.response }}</strong>
<span v-if="existing.plus_ones > 0"> with +{{ existing.plus_ones }} plus-ones</span>
on {{ fmtDate(existing.submitted_at) }}.
</p>
<AddToCalendar
v-if="calendar && existing.response === 'attending'"
:links="calendar"
:token="token"
class="mb-4 border-t border-zinc-800 pt-4"
/>
<div v-if="!editLimitReached" class="flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
<p class="text-xs text-zinc-500">
{{ editsRemaining }} {{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining.
</p>
<button type="button" class="btn-ghost text-sm" @click="startEditing">Change my response</button>
</div>
<p v-else class="border-t border-zinc-800 pt-4 text-xs text-zinc-500">
You've used all {{ MAX_EDITS }} edits on this invitation.
</p>
</div>
<div v-else-if="access && showForm" class="card overflow-hidden p-0">
<!-- Cover image — only renders when the host uploaded one. -->
<div
v-if="branding?.cover_image_url"
class="h-32 w-full bg-cover bg-center"
:style="{ backgroundImage: `url(${branding.cover_image_url})` }"
></div>
<div class="p-6">
<div class="mb-3 flex items-center gap-3">
<img
v-if="branding?.logo_url"
:src="branding.logo_url"
alt=""
class="h-10 w-10 rounded object-contain bg-zinc-900"
/>
<div>
<!-- Eyebrow text takes the accent colour when the host set
one; otherwise falls back to the default brand green. -->
<p
class="text-xs uppercase tracking-widest"
:style="branding?.accent_color ? { color: 'var(--brand-accent)' } : undefined"
:class="branding?.accent_color ? '' : 'text-brand-500'"
>
{{ existing ? 'Update your response' : 'Invitation' }}
</p>
<h1 class="text-2xl font-semibold">{{ access.event.name }}</h1>
</div>
</div>
<p class="mb-4 text-sm text-zinc-400">
{{ access.event.venue }} · {{ fmtDate(access.event.event_date) }}
</p>
<p
v-if="greetingMessage"
class="mb-4 rounded-r border-l-[3px] bg-brand-500/[0.04] py-1.5 pl-3 pr-2 text-sm text-zinc-300"
:style="branding?.accent_color ? { borderColor: 'var(--brand-accent)' } : { borderColor: 'rgb(34 197 94 / 0.6)' }"
>{{ greetingMessage }}</p>
<p class="mb-6 text-sm">
Hi <span class="font-medium text-zinc-100">{{ access.guest.name }}</span> —
<template v-if="existing">change your response below — {{ editsRemaining }}
{{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining.</template>
<template v-else>please confirm your response below.</template>
</p>
<div class="mb-4">
<label class="label">Response</label>
<div class="flex gap-2">
<button
v-for="opt in (['attending', 'declined', 'maybe'] as const)"
:key="opt"
type="button"
class="btn-ghost flex-1 capitalize"
:class="response === opt ? 'border border-brand-500 text-brand-300' : 'border border-zinc-800'"
@click="response = opt"
>{{ opt }}</button>
</div>
</div>
<div v-if="access.guest.plus_ones > 0" class="mb-4">
<label class="label">
Plus-ones
<span class="ml-1 font-normal normal-case text-zinc-500">
(you may bring up to {{ access.guest.plus_ones }})
</span>
</label>
<div class="flex items-center overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900">
<button
type="button"
class="flex h-11 w-12 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-30"
:disabled="plusOnes <= 0"
@click="plusOnes = Math.max(0, plusOnes - 1)"
></button>
<span class="flex-1 text-center text-base font-semibold tabular-nums text-zinc-100">{{ plusOnes }}</span>
<button
type="button"
class="flex h-11 w-12 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-30"
:disabled="plusOnes >= access.guest.plus_ones"
@click="plusOnes = Math.min(access.guest.plus_ones, plusOnes + 1)"
>+</button>
</div>
</div>
<p v-else class="mb-4 text-xs text-zinc-500">
This invitation is for one person only — no plus-ones for this one.
</p>
<div class="mb-6">
<label class="label">Dietary notes (optional)</label>
<input v-model="dietary" class="input" placeholder="e.g. vegetarian" />
</div>
<div class="flex items-center gap-3">
<button
class="btn-primary flex-1"
:style="branding?.primary_color ? { background: 'var(--brand-primary)' } : undefined"
:disabled="submitting"
@click="submit"
>
{{ submitLabel }}
</button>
<button v-if="existing" type="button" class="btn-ghost" :disabled="submitting" @click="cancelEditing">
Cancel
</button>
</div>
<p v-if="submitError" class="mt-3 text-sm text-red-400">{{ submitError }}</p>
</div>
</div>
</section>
</template>