Phase 12: share an event with co-hosts

The owner shares an event by email; a co-host gets full access inside it
but can't delete it or change who it's shared with. Shares are keyed by
email so an admin can be added before their first login, and RLS (not the
UI) is what enforces the owner-only parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 10:14:29 +02:00
co-authored by Claude Opus 5.5
parent 186a112b95
commit c27a3890f8
14 changed files with 353 additions and 27 deletions
+3 -2
View File
@@ -8,7 +8,7 @@ Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast
|---|---|---|
| `/login` | admin | Supabase magic link |
| `/admin` | admin | Events list |
| `/admin/events/[eventId]` | admin | Setup: teams, games, criteria, judges, share links |
| `/admin/events/[eventId]` | admin | Setup: teams, games, criteria, judges, share links, co-hosts |
| `/admin/events/[eventId]/live` | admin | Control room |
| `/s/[scoreboardToken]` | public | Scoreboard |
| `/j/[judgeToken]` | judge | Personal judge page |
@@ -29,7 +29,8 @@ Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middlewa
- `admin.ts` — service role, **server-only** (`import 'server-only'`). Used by public/judge route handlers after token validation.
## Security model
- RLS ON for every table. Policies only for the authenticated event owner. Anon role has no policies → no direct table access.
- RLS ON for every table. Policies only for the authenticated event owner and the co-hosts the owner shared it with. Anon role has no policies → no direct table access.
- Co-hosts are rows in `event_cohosts`, matched on the email in the login token. They get the same access as the owner inside the event; only the owner can delete the event or change its co-hosts (enforced by RLS — the UI just hides those buttons). Sharing is refused for an email that isn't in `ADMIN_EMAILS`, since that person couldn't log in.
- Public/judge access = token in URL → route handler validates with zod → service-role query scoped by that token.
- Judge write checks: judge not revoked, game not locked/done, criterion belongs to judge's game, team belongs to the game's event, value within min/max and on step. (DB trigger also enforces range.)
- Scoreboard API checks `scoreboard_visibility` and strips scores when `hidden`; returns `frozen_snapshot` when `frozen`.
+9 -1
View File
@@ -81,5 +81,13 @@ show who was first within a second, and "Clear buzzers" resets both phones.
**Done when:** team A buzzes then team B; "Wrong" hands the turn to B; "Got it" gives B one point
in the scoring grid and on the scoreboard; "Undo" takes it back; "Next question" resets the phones.
## Phase 12 — Co-hosts
- Apply `supabase/migrations/0005_cohosts.sql`, then `npm run gen:types` and `npm run check:rls`.
- `event_cohosts` table; RLS widened from owner to owner-or-co-host, with delete and sharing owner-only.
- "Co-hosts" section on the setup page (`addCohost` / `removeCohost` server actions); "Shared with you" in the events list.
**Done when:** admin A shares an event with admin B's email; B sees it in their list, can edit it and
run the control room alongside A, but has no Delete button and can't change the co-hosts; removing B
makes the event disappear from B's list.
## Stretch (only if asked)
Participants within teams · co-host admins · CSV export of results.
Participants within teams · CSV export of results.
+3 -1
View File
@@ -1,6 +1,6 @@
# Data model
Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `0003_buzzer.sql`, `0004_buzz_result.sql`).
Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `0003_buzzer.sql`, `0004_buzz_result.sql`, `0005_cohosts.sql`).
| Table | Purpose | Key columns |
|---|---|---|
@@ -12,6 +12,7 @@ Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `000
| `scores` | One value per (team, criterion, judge) | `judge_id` NULL = admin score (direct mode or override) |
| `adjustments` | Bonus/penalty | `points` (+/-), `reason`, optional `game_id` |
| `buzzes` | Who buzzed for the current question | `game_id`, `team_id`, `buzzed_at`, `result` (correct/wrong/NULL) |
| `event_cohosts` | Admins an event is shared with | `event_id`, `email` (lower case), unique together |
## Notes
- `scores` unique on `(team_id, criterion_id, judge_id)` with `NULLS NOT DISTINCT`, so there's exactly one admin row per team+criterion → clean upserts.
@@ -20,4 +21,5 @@ Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `000
- Timer (`0002_timer.sql`): running = `timer_ends_at` set; paused = `timer_remaining_ms` set; reset = both null (full `timer_duration_ms`).
- Buzzers (`0003_buzzer.sql`): `buzzes` is unique on `(game_id, team_id)` — one buzz per team per question; `buzzed_at` defaults to `clock_timestamp()` and decides the order. Clearing the buzzers deletes the game's rows. A trigger checks the team is in the game's event. `teams.buzzer_token` is a 128-bit token like the others; duplicating an event gives the copied teams new ones.
- Buzzer answers (`0004_buzz_result.sql`): `buzzes.result` is the host's mark. The team answering is the earliest buzz with `result IS NULL`, unless some buzz is `correct` (question decided). The point for a correct answer is not stored on the buzz: it is +1 on the admin's `scores` row for the game's first criterion.
- Co-hosts (`0005_cohosts.sql`): a share is keyed by email, not user id, so an admin can be added before they have ever logged in. RLS matches it against the email in the login token. `is_event_admin(event)` = owner or co-host and guards every table inside an event; deleting the event and editing `event_cohosts` stay owner-only. A trigger stops `events.owner_id` from changing.
- `frozen_snapshot` holds the `ScoreboardPayload` captured at freeze time.
+8 -2
View File
@@ -7,7 +7,7 @@ live scoreboard, and judges score from their phones via links. Versatile enough
## 2. Roles
| Role | Access | How they get in |
|---|---|---|
| **Admin** | Everything | Logs in (magic link). Email must be in `ADMIN_EMAILS`. |
| **Admin** | Everything in the events they own or co-host | Logs in (magic link). Email must be in `ADMIN_EMAILS`. |
| **Scoreboard viewer** | Read-only scoreboard for ONE event | Public link `/s/{scoreboardToken}` |
| **Judge** | Score ONE game | Personal link `/j/{judgeToken}` OR open game link `/jo/{openToken}` (enters name first) |
| **Team** | Buzz in for ONE team | Team buzzer link `/b/{buzzerToken}`, one per team for the whole night |
@@ -46,6 +46,12 @@ live scoreboard, and judges score from their phones via links. Versatile enough
- Revoke a judge (link stops working, their scores excluded).
- Share panel: scoreboard link + QR, all judge links per game, and one buzzer link + QR per team
(shown once any game uses buzzers).
- Co-hosts: the admin who created an event (its owner) shares it with other admins by email.
- The email must already be in `ADMIN_EMAILS`; it can be added before that admin's first login.
- A co-host sees the event in their list ("Shared with you") and can do everything in it —
setup, control room, scores — except delete it or change who it is shared with.
- The owner can remove a co-host at any time. Duplicating a shared event gives the co-host
their own copy, which they own.
### 4.2 Admin — Control room (`/admin/events/[id]/live`)
This is the screen used during the night. Must be fast and hard to mess up.
@@ -152,7 +158,7 @@ For "first to answer" games. A game opts in with the "Teams buzz in" checkbox.
## 6. Out of scope (v1)
- Individual contestants within teams (stretch: `participants` per team).
- Multiple admins / co-hosts (stretch).
- Transferring an event to another owner, and read-only co-hosts.
- Per-game timers (stretch). The event countdown timer is in 4.5; team buzzers are in 4.7.
- Buzzers for individual contestants, a configurable points value per correct answer, and
penalties for a wrong answer (use the bonus/penalty panel).
+1 -1
View File
@@ -2,7 +2,7 @@
// Run with: npm run check:rls
import { createClient } from "@supabase/supabase-js";
const TABLES = ["events", "teams", "games", "criteria", "judges", "scores", "adjustments", "buzzes"];
const TABLES = ["events", "teams", "games", "criteria", "judges", "scores", "adjustments", "buzzes", "event_cohosts"];
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
+48 -1
View File
@@ -4,10 +4,12 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireAdmin } from "@/lib/auth";
import { serverEnv } from "@/lib/env.server";
import { criteriaPresets, type PresetKey } from "@/lib/presets";
import { notify } from "@/lib/realtime";
import { idSchema } from "@/lib/tokens";
import {
cohostEmailSchema,
eventSchema,
firstError,
gameSchema,
@@ -23,6 +25,7 @@ const dbFail = (what: string, message: string) => fail(`${what} failed: ${messag
const validIds = (...ids: string[]) => ids.every((id) => idSchema.safeParse(id).success);
const BAD_ID = fail("That item no longer exists. Reload the page.");
const OWNER_ONLY = fail("Only the person who created this game night can do that.");
const eventPath = (eventId: string) => `/admin/events/${eventId}`;
@@ -85,13 +88,57 @@ export async function deleteEvent(eventId: string): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId)) return BAD_ID;
const { error } = await supabase.from("events").delete().eq("id", eventId);
// RLS lets only the owner delete; for a co-host the delete matches nothing instead of failing.
const { data, error } = await supabase.from("events").delete().eq("id", eventId).select("id");
if (error) return dbFail("Deleting the event", error.message);
if (data.length === 0) return OWNER_ONLY;
revalidatePath("/admin");
return ok;
}
// ---------- co-hosts ----------
/** Shares the event with another admin, by email. Owner only (RLS enforces it). */
export async function addCohost(eventId: string, email: string): Promise<ActionResult> {
const { supabase, email: ownEmail } = await requireAdmin();
if (!validIds(eventId)) return BAD_ID;
const parsed = cohostEmailSchema.safeParse(email);
if (!parsed.success) return fail(firstError(parsed.error));
const cohostEmail = parsed.data;
if (cohostEmail === ownEmail) return fail("That's your own email — you already have access.");
// Sharing with someone who can't log in would look like it worked and then not work.
if (!serverEnv.ADMIN_EMAILS.includes(cohostEmail)) {
return fail(`${cohostEmail} can't log in yet. Add it to ADMIN_EMAILS first, then share again.`);
}
const { error } = await supabase.from("event_cohosts").insert({ event_id: eventId, email: cohostEmail });
if (error?.code === "23505") return fail(`Already shared with ${cohostEmail}.`);
if (error?.code === "42501") return OWNER_ONLY;
if (error) return dbFail("Sharing the event", error.message);
revalidatePath(eventPath(eventId));
return ok;
}
export async function removeCohost(eventId: string, cohostId: string): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, cohostId)) return BAD_ID;
const { data, error } = await supabase
.from("event_cohosts")
.delete()
.eq("id", cohostId)
.eq("event_id", eventId)
.select("id");
if (error) return dbFail("Removing the co-host", error.message);
if (data.length === 0) return OWNER_ONLY;
revalidatePath(eventPath(eventId));
return ok;
}
/** Copies teams, games and criteria — not scores, judges or adjustments. */
export async function duplicateEvent(eventId: string): Promise<ActionResult> {
const { supabase, userId } = await requireAdmin();
+12 -2
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { CohostsEditor } from "@/components/admin/cohosts-editor";
import { EventDetailsForm } from "@/components/admin/event-details-form";
import { GamesEditor } from "@/components/admin/games-editor";
import { SharePanel } from "@/components/admin/share-panel";
@@ -11,12 +12,16 @@ import { loadEventSetup } from "@/lib/queries/event";
import { idSchema } from "@/lib/tokens";
export default async function EventSetupPage(props: PageProps<"/admin/events/[eventId]">) {
const { supabase } = await requireAdmin();
const { supabase, userId } = await requireAdmin();
const { eventId } = await props.params;
if (!idSchema.safeParse(eventId).success) notFound();
const setup = await loadEventSetup(supabase, eventId);
const [setup, cohosts] = await Promise.all([
loadEventSetup(supabase, eventId),
supabase.from("event_cohosts").select("id, email").eq("event_id", eventId).order("created_at"),
]);
if (!setup) notFound();
if (cohosts.error) throw new Error(`Could not load the co-hosts: ${cohosts.error.message}`);
const { event, teams, games } = setup;
return (
@@ -65,6 +70,11 @@ export default async function EventSetupPage(props: PageProps<"/admin/events/[ev
<h2 className="text-xl font-bold">Share links</h2>
<SharePanel scoreboardToken={event.scoreboard_token} teams={teams} games={games} />
</section>
<section className="rounded-lg border border-ink/15 bg-white p-4">
<h2 className="text-xl font-bold">Co-hosts</h2>
<CohostsEditor eventId={event.id} cohosts={cohosts.data} isOwner={event.owner_id === userId} />
</section>
</div>
);
}
+9 -3
View File
@@ -7,10 +7,11 @@ import { requireAdmin } from "@/lib/auth";
const dateFormat = new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeZone: "UTC" });
export default async function AdminHome() {
const { supabase } = await requireAdmin();
const { supabase, userId } = await requireAdmin();
// RLS returns the events you own plus the ones shared with you.
const { data: events, error } = await supabase
.from("events")
.select("id, name, event_date")
.select("id, name, event_date, owner_id")
.order("created_at", { ascending: false });
if (error) throw new Error(`Could not load events: ${error.message}`);
@@ -33,9 +34,14 @@ export default async function AdminHome() {
</span>
<span className="text-sm text-ink/70">
{event.event_date ? dateFormat.format(new Date(event.event_date)) : "No date set"}
{event.owner_id !== userId && " · Shared with you"}
</span>
</Link>
<EventRowActions eventId={event.id} eventName={event.name} />
<EventRowActions
eventId={event.id}
eventName={event.name}
canDelete={event.owner_id === userId}
/>
</li>
))}
</ul>
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import { addCohost, removeCohost } from "@/app/admin/actions";
import { Button } from "@/components/ui/button";
import { inputClass } from "@/components/ui/field";
import type { Tables } from "@/lib/database.types";
import { ErrorText } from "./error-text";
import { useAction } from "./use-action";
type Cohost = Pick<Tables<"event_cohosts">, "id" | "email">;
/** Who the event is shared with. Only the owner can change the list; co-hosts just see it. */
export function CohostsEditor({
eventId,
cohosts,
isOwner,
}: {
eventId: string;
cohosts: Cohost[];
isOwner: boolean;
}) {
const [email, setEmail] = useState("");
const { pending, error, run } = useAction();
return (
<div className="mt-3 flex flex-col gap-3">
<p className="text-sm text-ink/70">
{isOwner
? "Co-hosts can set up and run this game night with you. They can't delete it or change who it's shared with."
: "This game night was shared with you. Only the person who created it can change who it's shared with."}
</p>
{cohosts.length === 0 ? (
<p>Not shared with anyone yet.</p>
) : (
<ul className="flex flex-col gap-2">
{cohosts.map((cohost) => (
<li key={cohost.id} className="flex flex-wrap items-center justify-between gap-2">
<span className="min-w-0 break-all">{cohost.email}</span>
{isOwner && (
<Button
variant="danger"
size="sm"
disabled={pending}
onClick={() => {
if (window.confirm(`Stop sharing with ${cohost.email}?`)) {
run(() => removeCohost(eventId, cohost.id));
}
}}
>
Remove
</Button>
)}
</li>
))}
</ul>
)}
{isOwner && (
<form
className="flex flex-wrap items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
run(() => addCohost(eventId, email), () => setEmail(""));
}}
>
<input
type="email"
aria-label="Co-host's email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
maxLength={254}
placeholder="name@example.com"
className={`${inputClass} max-w-xs`}
/>
<Button type="submit" disabled={pending}>
Share
</Button>
</form>
)}
<ErrorText>{error}</ErrorText>
</div>
);
}
+24 -13
View File
@@ -6,7 +6,16 @@ import { Button } from "@/components/ui/button";
import { ErrorText } from "./error-text";
import { useAction } from "./use-action";
export function EventRowActions({ eventId, eventName }: { eventId: string; eventName: string }) {
export function EventRowActions({
eventId,
eventName,
canDelete,
}: {
eventId: string;
eventName: string;
/** Only the owner can delete; the server enforces it too. */
canDelete: boolean;
}) {
const { pending, error, run } = useAction();
return (
@@ -19,18 +28,20 @@ export function EventRowActions({ eventId, eventName }: { eventId: string; event
>
Duplicate
</Button>
<Button
variant="danger"
size="sm"
disabled={pending}
onClick={() => {
if (window.confirm(`Delete "${eventName}" and all its teams, games and scores? This can't be undone.`)) {
run(() => deleteEvent(eventId));
}
}}
>
Delete
</Button>
{canDelete && (
<Button
variant="danger"
size="sm"
disabled={pending}
onClick={() => {
if (window.confirm(`Delete "${eventName}" and all its teams, games and scores? This can't be undone.`)) {
run(() => deleteEvent(eventId));
}
}}
>
Delete
</Button>
)}
<ErrorText>{error}</ErrorText>
</div>
);
+29
View File
@@ -144,6 +144,35 @@ export type Database = {
},
];
};
event_cohosts: {
Row: {
created_at: string;
email: string;
event_id: string;
id: string;
};
Insert: {
created_at?: string;
email: string;
event_id: string;
id?: string;
};
Update: {
created_at?: string;
email?: string;
event_id?: string;
id?: string;
};
Relationships: [
{
foreignKeyName: "event_cohosts_event_id_fkey";
columns: ["event_id"];
isOneToOne: false;
referencedRelation: "events";
referencedColumns: ["id"];
},
];
};
events: {
Row: {
created_at: string;
+13 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { criteriaPresets } from "@/lib/presets";
import { criterionSchema, gameSchema, parsePlacementPoints, type GameDraft } from "./admin";
import { cohostEmailSchema, criterionSchema, gameSchema, parsePlacementPoints, type GameDraft } from "./admin";
const criterion = { label: "Pitch", description: "", inputType: "blocks", min: 1, max: 5, step: 1, weight: 1 } as const;
@@ -65,6 +65,18 @@ describe("gameSchema", () => {
});
});
describe("cohostEmailSchema", () => {
it("trims and lower-cases, so it matches the email in the login token", () => {
expect(cohostEmailSchema.parse(" Sam@Example.COM ")).toBe("sam@example.com");
});
it("rejects things that aren't an email", () => {
expect(cohostEmailSchema.safeParse("").success).toBe(false);
expect(cohostEmailSchema.safeParse("sam").success).toBe(false);
expect(cohostEmailSchema.safeParse("sam@example.com, kim@example.com").success).toBe(false);
});
});
describe("parsePlacementPoints", () => {
it("splits on commas and spaces", () => {
expect(parsePlacementPoints("10, 7,5 3")).toEqual([10, 7, 5, 3]);
+7
View File
@@ -22,6 +22,13 @@ export const eventSchema = z.object({
eventDate: z.union([z.iso.date("Pick a valid date."), z.literal("")]),
});
/** A co-host's email, normalised the way event_cohosts stores it (trimmed, lower case). */
export const cohostEmailSchema = z
.string()
.trim()
.toLowerCase()
.pipe(z.email("Enter a valid email address.").max(254, "That email is too long."));
export const teamSchema = z.object({
name: text(60, "Team name"),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Pick a colour."),
+99
View File
@@ -0,0 +1,99 @@
-- Game Night Live — sharing an event with other admins (co-hosts).
-- Run after 0004_buzz_result.sql.
--
-- The owner shares an event by email. A co-host can do everything in the event (setup, control
-- room, scores) except delete it or change who it is shared with.
--
-- Shares are keyed by email, not user id, so an admin can be added before their first login.
-- The email comes from the signed JWT, which is only as trustworthy as the project's auth
-- settings: keep "Confirm email" on (magic links confirm it anyway). ADMIN_EMAILS relies on
-- the same claim.
create table event_cohosts (
id uuid primary key default gen_random_uuid(),
event_id uuid not null references events(id) on delete cascade,
email text not null check (email = lower(email) and length(email) between 3 and 254),
created_at timestamptz not null default now(),
constraint event_cohosts_one_per_email unique (event_id, email)
);
create index on event_cohosts(email);
-- ---------- helpers ----------
-- security definer: they read events / event_cohosts without going back through RLS.
create or replace function is_event_cohost(eid uuid) returns boolean
language sql stable security definer set search_path = public as $$
select exists (
select 1 from event_cohosts
where event_id = eid and email = lower(auth.jwt() ->> 'email')
)
$$;
create or replace function is_event_admin(eid uuid) returns boolean
language sql stable security definer set search_path = public as $$
select is_event_owner(eid) or is_event_cohost(eid)
$$;
-- A co-host may update the event row, so the policy can't be what protects the owner column.
create or replace function keep_event_owner() returns trigger
language plpgsql as $$
begin
if new.owner_id is distinct from old.owner_id then
raise exception 'the owner of an event cannot be changed';
end if;
return new;
end $$;
create trigger events_keep_owner
before update on events
for each row execute function keep_event_owner();
-- ---------- RLS ----------
-- Still no anon policies anywhere.
alter table event_cohosts enable row level security;
-- Co-hosts can see who else is on the event; only the owner changes the list.
create policy admin_read on event_cohosts for select to authenticated
using (is_event_admin(event_id));
create policy owner_insert on event_cohosts for insert to authenticated
with check (is_event_owner(event_id));
create policy owner_delete on event_cohosts for delete to authenticated
using (is_event_owner(event_id));
-- events: the owner check stays on the column (not is_event_admin) so that
-- INSERT ... RETURNING can see the row it has just inserted.
drop policy owner_all on events;
create policy admin_read on events for select to authenticated
using (owner_id = auth.uid() or is_event_cohost(id));
create policy owner_insert on events for insert to authenticated
with check (owner_id = auth.uid());
create policy admin_update on events for update to authenticated
using (owner_id = auth.uid() or is_event_cohost(id))
with check (owner_id = auth.uid() or is_event_cohost(id));
create policy owner_delete on events for delete to authenticated
using (owner_id = auth.uid());
-- everything inside an event: owner or co-host
drop policy owner_all on teams;
drop policy owner_all on games;
drop policy owner_all on adjustments;
drop policy owner_all on criteria;
drop policy owner_all on judges;
drop policy owner_all on scores;
drop policy owner_all on buzzes;
create policy admin_all on teams for all to authenticated
using (is_event_admin(event_id)) with check (is_event_admin(event_id));
create policy admin_all on games for all to authenticated
using (is_event_admin(event_id)) with check (is_event_admin(event_id));
create policy admin_all on adjustments for all to authenticated
using (is_event_admin(event_id)) with check (is_event_admin(event_id));
create policy admin_all on criteria for all to authenticated
using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));
create policy admin_all on judges for all to authenticated
using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));
create policy admin_all on scores for all to authenticated
using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));
create policy admin_all on buzzes for all to authenticated
using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));