From 3973e4058d7266b93f4ad39b2e8744cda89d5ab5 Mon Sep 17 00:00:00 2001 From: Kwaku Danso <72142185+cloud-dev101@users.noreply.github.com> Date: Sun, 17 May 2026 22:14:50 +0100 Subject: [PATCH] =?UTF-8?q?feat(tier2):=20multi-host=20/=20collaborators?= =?UTF-8?q?=20=E2=80=94=20Block=20C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/components/TeamCard.vue | 267 ++++++++++ frontend/pages/dashboard/events/[id].vue | 9 + frontend/pages/invites/[token].vue | 136 ++++++ internal/api/activity.go | 4 +- internal/api/authz.go | 49 ++ internal/api/collaborators.go | 455 ++++++++++++++++++ internal/api/csv_import.go | 8 +- internal/api/events.go | 36 +- internal/api/guests.go | 11 +- internal/api/server.go | 42 +- internal/api/tokens.go | 7 +- internal/api/ws_auth.go | 6 +- internal/auth/email.go | 15 +- internal/domain/collaborator.go | 93 ++++ internal/domain/collaborator_test.go | 43 ++ internal/notification/email_ses.go | 12 + internal/notification/factory.go | 7 + internal/notification/resend_sender.go | 12 + internal/notification/smtp_sender.go | 11 + internal/notification/templates.go | 11 +- .../templates/collaborator_invite.html | 16 + .../templates/collaborator_invite.txt | 7 + internal/storage/collaborators.go | 381 +++++++++++++++ internal/storage/events.go | 96 +++- .../migrations/0008_collaborators.down.sql | 7 + .../migrations/0008_collaborators.up.sql | 65 +++ test/integration/auth_test.go | 6 + test/integration/collaborators_test.go | 328 +++++++++++++ 28 files changed, 2108 insertions(+), 32 deletions(-) create mode 100644 frontend/components/TeamCard.vue create mode 100644 frontend/pages/invites/[token].vue create mode 100644 internal/api/collaborators.go create mode 100644 internal/domain/collaborator.go create mode 100644 internal/domain/collaborator_test.go create mode 100644 internal/notification/templates/collaborator_invite.html create mode 100644 internal/notification/templates/collaborator_invite.txt create mode 100644 internal/storage/collaborators.go create mode 100644 internal/storage/migrations/0008_collaborators.down.sql create mode 100644 internal/storage/migrations/0008_collaborators.up.sql create mode 100644 test/integration/collaborators_test.go diff --git a/frontend/components/TeamCard.vue b/frontend/components/TeamCard.vue new file mode 100644 index 0000000..a8b3297 --- /dev/null +++ b/frontend/components/TeamCard.vue @@ -0,0 +1,267 @@ + + + diff --git a/frontend/pages/dashboard/events/[id].vue b/frontend/pages/dashboard/events/[id].vue index 0bc2cf9..e145d97 100644 --- a/frontend/pages/dashboard/events/[id].vue +++ b/frontend/pages/dashboard/events/[id].vue @@ -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 { + +
+ +
+