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:
@@ -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>
|
||||
Reference in New Issue
Block a user