feat(tier2): multi-host / collaborators — Block C

Events can now have multiple users with distinct roles:
  owner   — manage collaborators, delete event, full access
  editor  — manage guests, tokens, CSV import, patch event
  viewer  — read-only access to everything

Schema (migration 0008)
- collaborator_role ENUM + event_collaborators + collaborator_invites
- Backfill: every existing events.host_id becomes an owner row
- EventRepo.Create seeds the owner row in the same transaction so
  no future event can exist without one

Authz
- New requireRole(eventID, userID, minRole) helper. Non-members 404;
  insufficient role 403. Replaces requireEventOwner across every
  shared-role handler (events.get/update, guests CRUD, tokens issue/
  rotate/bulk, csv preview/commit/template, activity, ws-ticket)
- events.delete + collaborator management stay owner-only
- GET /events lists every event the user has any role on
- /events/{id} response now embeds your_role for UI branching

Collaborator endpoints
- GET    /events/{id}/collaborators           (viewer+)
- POST   /events/{id}/collaborators           (owner)  — sends invite email
- PATCH  /events/{id}/collaborators/{user_id} (owner)  — role change
- DELETE /events/{id}/collaborators/{user_id} (owner)  — refuses last owner
- DELETE /events/{id}/collaborators/pending   (owner)  — cancel invite
- GET    /invites/{token}                     (public) — preview summary
- POST   /invites/{token}/accept              (authed) — atomic accept

Invitations
- SHA-256 hashed in DB; raw value only lives in the email link
- 7-day TTL, single-use, email-bound (caller's email must match)
- New SendCollaboratorInvite on auth.EmailSender + Resend/SMTP/SES
  senders + log stub; collaborator_invite.html/txt branded template

Frontend
- TeamCard.vue on the event detail page: lists collaborators with
  inline role-change + remove, pending-invites with cancel, invite
  modal (email + role). Owner-only actions hidden for editors/viewers
- /invites/[token] accept page: shows invite summary, prompts signup
  or sign-in with pre-filled email, refuses mismatched accounts

Tests (all 6 pass on the existing testcontainers harness)
- backfill: legacy host gets owner role
- role enforcement: viewer can read, editor can write guests but not
  delete/manage team, non-member 404s everywhere
- last-owner removal refused (400)
- shared events show up in collaborator's /events list
- invite flow: create → preview → accept → role granted → replay 410
- email mismatch on accept returns 403
- expired invite returns 410

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-17 22:14:50 +01:00
parent 6803d700b4
commit 3973e4058d
28 changed files with 2108 additions and 32 deletions
+267
View File
@@ -0,0 +1,267 @@
<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">Team</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>
+9
View File
@@ -35,6 +35,9 @@ interface EventDetail {
status: string
created_at: string
updated_at: string
// Tier 2 Block C — caller's role on this event. The dashboard branches
// UI affordances off this rather than the legacy host_id check.
your_role?: 'owner' | 'editor' | 'viewer'
}
interface IssuedToken {
@@ -1117,6 +1120,12 @@ function checkLabel(band?: string): string {
</aside>
</div>
<!-- Team (Tier 2 Block C). Visible to anyone with viewer+ access; action
buttons gated to owners. -->
<div v-if="event" class="mt-6">
<TeamCard :event-id="eventId" :your-role="event.your_role" />
</div>
<!-- ===== Modals ===== -->
<!-- All modals share the same pattern: backdrop click + Esc close,
role=dialog + aria-modal, primary action on the right.
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
// Collaborator invite acceptance Tier 2 Block C.
//
// Flow:
// 1. Preview the invite (unauthed) so we can show the event name + role.
// 2. If the visitor isn't signed in, redirect them to signup (preserving
// the invite path as redirect). The signup must use the invited email.
// 3. After signup/login the user lands back here; POST /invites/{token}/accept
// and on success bounce them to /dashboard/events/{event_id}.
interface InviteSummary {
event_id: string
event_name: string
role: 'owner' | 'editor' | 'viewer'
email: string
expires_at: string
}
const route = useRoute()
const auth = useAuth()
const token = route.params.token as string
const loading = ref(true)
const summary = ref<InviteSummary | null>(null)
const loadError = ref<string | null>(null)
const accepting = ref(false)
const acceptError = ref<string | null>(null)
onMounted(async () => {
try {
summary.value = await useApi<InviteSummary>(`/invites/${token}`)
} catch (e: any) {
loadError.value = useErrMessage(e, 'Invitation is invalid or expired.')
} finally {
loading.value = false
}
})
const signedIn = computed(() => !!auth.user.value)
const emailMatches = computed(() => {
if (!summary.value || !auth.user.value) return false
return auth.user.value.email.trim().toLowerCase() === summary.value.email.trim().toLowerCase()
})
function goToSignup() {
// Pre-fill the email so the visitor isn't tempted to register a different
// one. The accept handler refuses to consume the invite if emails differ.
if (!summary.value) return
const target = `/signup?email=${encodeURIComponent(summary.value.email)}&redirect=${encodeURIComponent(route.fullPath)}`
navigateTo(target)
}
function goToLogin() {
if (!summary.value) return
const target = `/login?email=${encodeURIComponent(summary.value.email)}&redirect=${encodeURIComponent(route.fullPath)}`
navigateTo(target)
}
async function accept() {
if (!summary.value) return
accepting.value = true
acceptError.value = null
try {
const res = await useApi<{ event_id: string }>(`/invites/${token}/accept`, { method: 'POST' })
navigateTo(`/dashboard/events/${res.event_id}`)
} catch (e: any) {
acceptError.value = useErrMessage(e, 'Could not accept invitation')
} finally {
accepting.value = false
}
}
function fmtDate(iso?: string) {
if (!iso) return ''
try { return new Date(iso).toLocaleString() } catch { return iso }
}
</script>
<template>
<section class="mx-auto max-w-xl py-10">
<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>
<p class="mt-4 text-xs text-zinc-500">
Ask the person who sent the invitation to resend it from the event's Team tab.
</p>
</div>
<div v-else-if="summary" class="card border-brand-900/60 bg-brand-950/20">
<p class="text-xs uppercase tracking-widest text-brand-500">Team invitation</p>
<h1 class="mb-1 text-2xl font-semibold">{{ summary.event_name }}</h1>
<p class="mb-5 text-sm text-zinc-400">
You've been invited as
<strong class="capitalize text-brand-300">{{ summary.role }}</strong>.
</p>
<div v-if="!signedIn" class="space-y-3">
<p class="text-sm">
Sign in or create a GuestGuard account for
<strong class="text-zinc-100">{{ summary.email }}</strong>
to accept.
</p>
<div class="flex gap-2">
<button class="btn-primary flex-1" @click="goToSignup">Create account</button>
<button class="btn-ghost flex-1" @click="goToLogin">Sign in</button>
</div>
</div>
<div v-else-if="!emailMatches" class="rounded-md border border-amber-900/60 bg-amber-950/20 p-3 text-sm">
<p class="text-amber-200">
You're signed in as <strong>{{ auth.user.value?.email }}</strong>,
but this invitation was sent to
<strong>{{ summary.email }}</strong>.
</p>
<p class="mt-2 text-xs text-amber-300/80">
Sign out and sign back in with the right account to accept.
</p>
</div>
<div v-else class="space-y-3">
<p class="text-sm">Ready to join? Accept the invitation below.</p>
<button class="btn-primary w-full" :disabled="accepting" @click="accept">
{{ accepting ? 'Accepting…' : `Accept and open event` }}
</button>
<p v-if="acceptError" class="text-sm text-red-400">{{ acceptError }}</p>
</div>
<p class="mt-5 border-t border-zinc-800 pt-4 text-xs text-zinc-500">
Expires {{ fmtDate(summary.expires_at) }}.
</p>
</div>
</section>
</template>