From b60c9db4f43f81709c39932d7a72ef6b5efe84c6 Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Sat, 3 Oct 2026 13:47:36 +0200 Subject: [PATCH] Phase 3: admin magic-link auth and event setup screens - /login sends a Supabase magic link (admin emails only); /auth/confirm completes sign-in; proxy.ts guards /admin and every server action re-checks with requireAdmin(), since actions are plain POST endpoints. - /admin lists, creates, duplicates and deletes events. - /admin/events/[eventId] edits teams (colour, order) and games with criteria, presets and notes. All input goes through zod schemas that mirror the database constraints so errors read as plain sentences. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 10 + docs/ARCHITECTURE.md | 2 +- docs/BUILD_PLAN.md | 2 +- src/app/admin/actions.ts | 448 ++++++++++++++++++++ src/app/admin/events/[eventId]/page.tsx | 50 +++ src/app/admin/layout.tsx | 29 ++ src/app/admin/page.tsx | 51 +++ src/app/auth/confirm/route.ts | 30 ++ src/app/login/actions.ts | 48 +++ src/app/login/login-form.tsx | 37 ++ src/app/login/page.tsx | 23 + src/components/admin/create-event-form.tsx | 30 ++ src/components/admin/error-text.tsx | 7 + src/components/admin/event-details-form.tsx | 51 +++ src/components/admin/event-row-actions.tsx | 37 ++ src/components/admin/game-form.tsx | 340 +++++++++++++++ src/components/admin/games-editor.tsx | 138 ++++++ src/components/admin/teams-editor.tsx | 137 ++++++ src/components/admin/use-action.ts | 29 ++ src/components/ui/button.tsx | 28 ++ src/components/ui/field.tsx | 27 ++ src/lib/admin-emails.test.ts | 19 + src/lib/admin-emails.ts | 11 + src/lib/auth.ts | 21 + src/lib/presets.ts | 49 +++ src/lib/queries/event.ts | 39 ++ src/lib/supabase/proxy.ts | 36 ++ src/lib/validation/admin.test.ts | 73 ++++ src/lib/validation/admin.ts | 99 +++++ src/proxy.ts | 31 ++ 30 files changed, 1930 insertions(+), 2 deletions(-) create mode 100644 src/app/admin/actions.ts create mode 100644 src/app/admin/events/[eventId]/page.tsx create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/auth/confirm/route.ts create mode 100644 src/app/login/actions.ts create mode 100644 src/app/login/login-form.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/components/admin/create-event-form.tsx create mode 100644 src/components/admin/error-text.tsx create mode 100644 src/components/admin/event-details-form.tsx create mode 100644 src/components/admin/event-row-actions.tsx create mode 100644 src/components/admin/game-form.tsx create mode 100644 src/components/admin/games-editor.tsx create mode 100644 src/components/admin/teams-editor.tsx create mode 100644 src/components/admin/use-action.ts create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/field.tsx create mode 100644 src/lib/admin-emails.test.ts create mode 100644 src/lib/admin-emails.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/presets.ts create mode 100644 src/lib/queries/event.ts create mode 100644 src/lib/supabase/proxy.ts create mode 100644 src/lib/validation/admin.test.ts create mode 100644 src/lib/validation/admin.ts create mode 100644 src/proxy.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1d97082..3115642 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,3 +40,13 @@ don't rely on memory for SDK method names (esp. `@supabase/ssr` and Realtime bro ## Commands - `npm run dev` / `npm run build` / `npm run test` / `npm run lint` - Migrations live in `supabase/migrations/`. Apply via Supabase CLI or paste into SQL editor. + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 002ea79..b1adf33 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,7 +18,7 @@ Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast | `PUT /api/judge/[token]/scores` | judge | Upsert one score `{teamId, criterionId, value}` | | `POST /api/judge-open/[openToken]` | judge | `{name}` → `{judgeToken}` | -Admin mutations = server actions. Middleware protects `/admin/*`: must be logged in AND email in `ADMIN_EMAILS`. +Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middleware) protects `/admin/*`: must be logged in AND email in `ADMIN_EMAILS`. Every server action re-checks via `requireAdmin()`. ## Supabase clients (`src/lib/supabase/`) - `browser.ts` — anon key, used for Realtime subscribe + admin auth session. diff --git a/docs/BUILD_PLAN.md b/docs/BUILD_PLAN.md index 94a6d3b..4fb6681 100644 --- a/docs/BUILD_PLAN.md +++ b/docs/BUILD_PLAN.md @@ -21,7 +21,7 @@ human a short "how to test this manually" checklist. Don't start the next phase **Done when:** tests green, 100% branch coverage on the scoring module. ## Phase 3 — Admin auth + setup screens -- `/login` magic link; middleware guards `/admin/*` with `ADMIN_EMAILS`. +- `/login` magic link; `proxy.ts` guards `/admin/*` with `ADMIN_EMAILS`. - Events list + create/duplicate/delete. - Event setup: teams CRUD (colour picker, reorder), games builder with criteria + presets, notes. **Done when:** I can build a "Worship Game Night" with 4 teams and a Karaoke game with 5 criteria (1–5) and a Guess the Lyric game with 1 number criterion. diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts new file mode 100644 index 0000000..ccfda19 --- /dev/null +++ b/src/app/admin/actions.ts @@ -0,0 +1,448 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +import { requireAdmin } from "@/lib/auth"; +import { criteriaPresets, type PresetKey } from "@/lib/presets"; +import { idSchema } from "@/lib/tokens"; +import { + eventSchema, + firstError, + gameSchema, + teamSchema, + type GameDraft, +} from "@/lib/validation/admin"; + +export type ActionResult = { ok: true } | { ok: false; error: string }; + +const ok: ActionResult = { ok: true }; +const fail = (error: string): ActionResult => ({ ok: false, error }); +const dbFail = (what: string, message: string) => fail(`${what} failed: ${message}. Try again.`); + +const validIds = (...ids: string[]) => ids.every((id) => idSchema.safeParse(id).success); +const BAD_ID = fail("That item no longer exists. Reload the page."); + +const eventPath = (eventId: string) => `/admin/events/${eventId}`; + +// New teams cycle through these so they're distinguishable without any effort. +const TEAM_COLORS = ["#E63946", "#3A86FF", "#2EC4B6", "#FFB703", "#8338EC", "#FB5607", "#06D6A0", "#FF006E"]; + +/** Returns the row order after moving `id` one place up or down. */ +function moved(rows: T[], id: string, direction: "up" | "down"): T[] { + const from = rows.findIndex((row) => row.id === id); + const to = direction === "up" ? from - 1 : from + 1; + if (from < 0 || to < 0 || to >= rows.length) return rows; + const next = [...rows]; + [next[from], next[to]] = [next[to], next[from]]; + return next; +} + +// ---------- events ---------- + +export async function createEvent(_previous: ActionResult | null, formData: FormData): Promise { + const { supabase, userId } = await requireAdmin(); + const parsed = eventSchema.safeParse({ + name: String(formData.get("name") ?? ""), + eventDate: String(formData.get("eventDate") ?? ""), + }); + if (!parsed.success) return fail(firstError(parsed.error)); + + const { data, error } = await supabase + .from("events") + .insert({ owner_id: userId, name: parsed.data.name, event_date: parsed.data.eventDate || null }) + .select("id") + .single(); + if (error) return dbFail("Creating the event", error.message); + + revalidatePath("/admin"); + redirect(eventPath(data.id)); +} + +export async function updateEvent( + eventId: string, + input: { name: string; eventDate: string }, +): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId)) return BAD_ID; + const parsed = eventSchema.safeParse(input); + if (!parsed.success) return fail(firstError(parsed.error)); + + const { error } = await supabase + .from("events") + .update({ name: parsed.data.name, event_date: parsed.data.eventDate || null }) + .eq("id", eventId); + if (error) return dbFail("Saving the event", error.message); + + revalidatePath("/admin"); + revalidatePath(eventPath(eventId)); + return ok; +} + +export async function deleteEvent(eventId: string): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId)) return BAD_ID; + + const { error } = await supabase.from("events").delete().eq("id", eventId); + if (error) return dbFail("Deleting the event", error.message); + + revalidatePath("/admin"); + return ok; +} + +/** Copies teams, games and criteria — not scores, judges or adjustments. */ +export async function duplicateEvent(eventId: string): Promise { + const { supabase, userId } = await requireAdmin(); + if (!validIds(eventId)) return BAD_ID; + + const [source, teams, games] = await Promise.all([ + supabase.from("events").select("name, event_date").eq("id", eventId).maybeSingle(), + supabase.from("teams").select("name, color, sort_order").eq("event_id", eventId), + supabase.from("games").select("*, criteria(*)").eq("event_id", eventId), + ]); + const loadError = source.error ?? teams.error ?? games.error; + if (loadError) return dbFail("Copying the event", loadError.message); + if (!source.data) return BAD_ID; + + const copy = await supabase + .from("events") + .insert({ + owner_id: userId, + name: `${source.data.name} (copy)`.slice(0, 120), + event_date: source.data.event_date, + }) + .select("id") + .single(); + if (copy.error) return dbFail("Copying the event", copy.error.message); + const newEventId = copy.data.id; + + if (teams.data?.length) { + const { error } = await supabase + .from("teams") + .insert(teams.data.map((team) => ({ ...team, event_id: newEventId }))); + if (error) return dbFail("Copying the teams", error.message); + } + + for (const game of games.data ?? []) { + const error = await insertGameCopy(supabase, game, newEventId, game.title, game.sort_order); + if (error) return dbFail("Copying the games", error); + } + + revalidatePath("/admin"); + return ok; +} + +// ---------- teams ---------- + +export async function addTeam(eventId: string, name: string): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId)) return BAD_ID; + + const existing = await supabase.from("teams").select("sort_order").eq("event_id", eventId); + if (existing.error) return dbFail("Adding the team", existing.error.message); + const count = existing.data.length; + + const parsed = teamSchema.safeParse({ name, color: TEAM_COLORS[count % TEAM_COLORS.length] }); + if (!parsed.success) return fail(firstError(parsed.error)); + + const { error } = await supabase.from("teams").insert({ + event_id: eventId, + name: parsed.data.name, + color: parsed.data.color, + sort_order: Math.max(-1, ...existing.data.map((team) => team.sort_order)) + 1, + }); + if (error) return dbFail("Adding the team", error.message); + + revalidatePath(eventPath(eventId)); + return ok; +} + +export async function updateTeam( + eventId: string, + teamId: string, + input: { name: string; color: string }, +): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId, teamId)) return BAD_ID; + const parsed = teamSchema.safeParse(input); + if (!parsed.success) return fail(firstError(parsed.error)); + + const { error } = await supabase + .from("teams") + .update(parsed.data) + .eq("id", teamId) + .eq("event_id", eventId); + if (error) return dbFail("Saving the team", error.message); + + revalidatePath(eventPath(eventId)); + return ok; +} + +export async function deleteTeam(eventId: string, teamId: string): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId, teamId)) return BAD_ID; + + const { error } = await supabase.from("teams").delete().eq("id", teamId).eq("event_id", eventId); + if (error) return dbFail("Deleting the team", error.message); + + revalidatePath(eventPath(eventId)); + return ok; +} + +export async function moveTeam( + eventId: string, + teamId: string, + direction: "up" | "down", +): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId, teamId)) return BAD_ID; + + const { data, error } = await supabase + .from("teams") + .select("id, sort_order") + .eq("event_id", eventId) + .order("sort_order") + .order("created_at"); + if (error) return dbFail("Reordering", error.message); + + // Rewrite every position so gaps or duplicates in sort_order heal themselves. + const results = await Promise.all( + moved(data, teamId, direction).map((team, index) => + team.sort_order === index + ? null + : supabase.from("teams").update({ sort_order: index }).eq("id", team.id), + ), + ); + const failed = results.find((result) => result?.error); + if (failed?.error) return dbFail("Reordering", failed.error.message); + + revalidatePath(eventPath(eventId)); + return ok; +} + +// ---------- games ---------- + +type Supabase = Awaited>["supabase"]; +type GameRowWithCriteria = NonNullable< + Awaited>["data"] +>; + +function loadGame(supabase: Supabase, eventId: string, gameId: string) { + return supabase + .from("games") + .select("*, criteria(*)") + .eq("id", gameId) + .eq("event_id", eventId) + .maybeSingle(); +} + +/** Inserts a fresh copy of a game (new tokens, status upcoming, no scores). Returns an error message or null. */ +async function insertGameCopy( + supabase: Supabase, + game: GameRowWithCriteria, + eventId: string, + title: string, + sortOrder: number, +): Promise { + const copy = await supabase + .from("games") + .insert({ + event_id: eventId, + title, + description: game.description, + sort_order: sortOrder, + scoring_mode: game.scoring_mode, + points_mode: game.points_mode, + multiplier: game.multiplier, + placement_points: game.placement_points, + admin_notes: game.admin_notes, + judge_notes: game.judge_notes, + }) + .select("id") + .single(); + if (copy.error) return copy.error.message; + + if (game.criteria.length) { + const { error } = await supabase.from("criteria").insert( + game.criteria.map((criterion) => ({ + game_id: copy.data.id, + label: criterion.label, + description: criterion.description, + input_type: criterion.input_type, + min_value: criterion.min_value, + max_value: criterion.max_value, + step: criterion.step, + weight: criterion.weight, + sort_order: criterion.sort_order, + })), + ); + if (error) return error.message; + } + return null; +} + +async function nextGameOrder(supabase: Supabase, eventId: string) { + const { data } = await supabase.from("games").select("sort_order").eq("event_id", eventId); + return Math.max(-1, ...(data ?? []).map((game) => game.sort_order)) + 1; +} + +export async function addGame(eventId: string, presetKey: PresetKey): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId) || !Object.hasOwn(criteriaPresets, presetKey)) return BAD_ID; + + const game = await supabase + .from("games") + .insert({ + event_id: eventId, + title: "New game", + sort_order: await nextGameOrder(supabase, eventId), + // Karaoke is the one preset that only makes sense with judges. + scoring_mode: presetKey === "karaoke" ? "judged" : "direct", + }) + .select("id") + .single(); + if (game.error) return dbFail("Adding the game", game.error.message); + + const { error } = await supabase.from("criteria").insert( + criteriaPresets[presetKey].criteria.map((criterion, index) => ({ + game_id: game.data.id, + label: criterion.label, + description: criterion.description || null, + input_type: criterion.inputType, + min_value: criterion.min, + max_value: criterion.max, + step: criterion.step, + weight: criterion.weight, + sort_order: index, + })), + ); + if (error) return dbFail("Adding the criteria", error.message); + + revalidatePath(eventPath(eventId)); + return ok; +} + +export async function saveGame(eventId: string, gameId: string, input: GameDraft): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId, gameId)) return BAD_ID; + const parsed = gameSchema.safeParse(input); + if (!parsed.success) return fail(firstError(parsed.error)); + const game = parsed.data; + + const updated = await supabase + .from("games") + .update({ + title: game.title, + description: game.description || null, + scoring_mode: game.scoringMode, + points_mode: game.pointsMode, + multiplier: game.multiplier, + placement_points: game.placementPoints, + admin_notes: game.adminNotes || null, + judge_notes: game.judgeNotes || null, + }) + .eq("id", gameId) + .eq("event_id", eventId) + .select("id"); + if (updated.error) return dbFail("Saving the game", updated.error.message); + if (updated.data.length === 0) return BAD_ID; + + // Criteria: delete the ones removed in the editor (their scores cascade), then + // update kept ones in place so their scores survive, and insert new ones. + const existing = await supabase.from("criteria").select("id").eq("game_id", gameId); + if (existing.error) return dbFail("Saving the criteria", existing.error.message); + const existingIds = new Set(existing.data.map((criterion) => criterion.id)); + const keptIds = new Set(game.criteria.map((criterion) => criterion.id).filter(Boolean)); + const removedIds = [...existingIds].filter((id) => !keptIds.has(id)); + + if (removedIds.length) { + const { error } = await supabase.from("criteria").delete().in("id", removedIds); + if (error) return dbFail("Removing criteria", error.message); + } + + const results = await Promise.all( + game.criteria.map((criterion, index) => { + const row = { + game_id: gameId, + label: criterion.label, + description: criterion.description || null, + input_type: criterion.inputType, + min_value: criterion.min, + max_value: criterion.max, + step: criterion.step, + weight: criterion.weight, + sort_order: index, + }; + return criterion.id && existingIds.has(criterion.id) + ? supabase.from("criteria").update(row).eq("id", criterion.id).eq("game_id", gameId) + : supabase.from("criteria").insert(row); + }), + ); + const failed = results.find((result) => result.error); + if (failed?.error) return dbFail("Saving the criteria", failed.error.message); + + revalidatePath(eventPath(eventId)); + return ok; +} + +export async function duplicateGame(eventId: string, gameId: string): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId, gameId)) return BAD_ID; + + const source = await loadGame(supabase, eventId, gameId); + if (source.error) return dbFail("Copying the game", source.error.message); + if (!source.data) return BAD_ID; + + const error = await insertGameCopy( + supabase, + source.data, + eventId, + `${source.data.title} (copy)`.slice(0, 120), + await nextGameOrder(supabase, eventId), + ); + if (error) return dbFail("Copying the game", error); + + revalidatePath(eventPath(eventId)); + return ok; +} + +export async function deleteGame(eventId: string, gameId: string): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId, gameId)) return BAD_ID; + + const { error } = await supabase.from("games").delete().eq("id", gameId).eq("event_id", eventId); + if (error) return dbFail("Deleting the game", error.message); + + revalidatePath(eventPath(eventId)); + return ok; +} + +export async function moveGame( + eventId: string, + gameId: string, + direction: "up" | "down", +): Promise { + const { supabase } = await requireAdmin(); + if (!validIds(eventId, gameId)) return BAD_ID; + + const { data, error } = await supabase + .from("games") + .select("id, sort_order") + .eq("event_id", eventId) + .order("sort_order") + .order("created_at"); + if (error) return dbFail("Reordering", error.message); + + const results = await Promise.all( + moved(data, gameId, direction).map((game, index) => + game.sort_order === index + ? null + : supabase.from("games").update({ sort_order: index }).eq("id", game.id), + ), + ); + const failed = results.find((result) => result?.error); + if (failed?.error) return dbFail("Reordering", failed.error.message); + + revalidatePath(eventPath(eventId)); + return ok; +} diff --git a/src/app/admin/events/[eventId]/page.tsx b/src/app/admin/events/[eventId]/page.tsx new file mode 100644 index 0000000..cc0bedc --- /dev/null +++ b/src/app/admin/events/[eventId]/page.tsx @@ -0,0 +1,50 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { EventDetailsForm } from "@/components/admin/event-details-form"; +import { GamesEditor } from "@/components/admin/games-editor"; +import { TeamsEditor } from "@/components/admin/teams-editor"; +import { requireAdmin } from "@/lib/auth"; +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 { eventId } = await props.params; + if (!idSchema.safeParse(eventId).success) notFound(); + + const setup = await loadEventSetup(supabase, eventId); + if (!setup) notFound(); + const { event, teams, games } = setup; + + return ( +
+
+ + ← All game nights + +

{event.name}

+
+ +
+

Details

+ +
+ +
+

Teams

+ +
+ +
+

Games

+ +
+
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..e4474bb --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,29 @@ +import Link from "next/link"; + +import { signOut } from "@/app/login/actions"; +import { Button } from "@/components/ui/button"; +import { requireAdmin } from "@/lib/auth"; +import { publicEnv } from "@/lib/env.public"; + +export default async function AdminLayout({ children }: LayoutProps<"/admin">) { + const { email } = await requireAdmin(); + + return ( +
+
+
+ + {publicEnv.NEXT_PUBLIC_APP_NAME} + +
+ {email} + +
+
+
+
{children}
+
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..0d0023b --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,51 @@ +import Link from "next/link"; + +import { CreateEventForm } from "@/components/admin/create-event-form"; +import { EventRowActions } from "@/components/admin/event-row-actions"; +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 { data: events, error } = await supabase + .from("events") + .select("id, name, event_date") + .order("created_at", { ascending: false }); + if (error) throw new Error(`Could not load events: ${error.message}`); + + return ( +
+
+

Your game nights

+ {events.length === 0 ? ( +

No game nights yet. Create your first one below.

+ ) : ( +
    + {events.map((event) => ( +
  • + + + {event.name} + + + {event.event_date ? dateFormat.format(new Date(event.event_date)) : "No date set"} + + + +
  • + ))} +
+ )} +
+ +
+

New game night

+ +
+
+ ); +} diff --git a/src/app/auth/confirm/route.ts b/src/app/auth/confirm/route.ts new file mode 100644 index 0000000..4355ea2 --- /dev/null +++ b/src/app/auth/confirm/route.ts @@ -0,0 +1,30 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; + +import { createClient } from "@/lib/supabase/server"; + +const otpTypeSchema = z.enum(["magiclink", "email", "signup", "invite", "recovery", "email_change"]); + +// Landing point of the magic link. Handles both link styles Supabase can send: +// ?code=... (default email template, PKCE) and ?token_hash=...&type=... (custom template). +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const code = searchParams.get("code"); + const tokenHash = searchParams.get("token_hash"); + const type = otpTypeSchema.safeParse(searchParams.get("type")); + + const supabase = await createClient(); + let signedIn = false; + if (code) { + const { error } = await supabase.auth.exchangeCodeForSession(code); + signedIn = !error; + } else if (tokenHash && type.success) { + const { error } = await supabase.auth.verifyOtp({ token_hash: tokenHash, type: type.data }); + signedIn = !error; + } + + const url = request.nextUrl.clone(); + url.pathname = signedIn ? "/admin" : "/login"; + url.search = signedIn ? "" : "?error=link"; + return NextResponse.redirect(url); +} diff --git a/src/app/login/actions.ts b/src/app/login/actions.ts new file mode 100644 index 0000000..7654425 --- /dev/null +++ b/src/app/login/actions.ts @@ -0,0 +1,48 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { z } from "zod"; + +import { serverEnv } from "@/lib/env.server"; +import { createClient } from "@/lib/supabase/server"; + +export type LoginState = { status: "idle" | "sent" | "error"; message: string }; + +export async function sendMagicLink(_previous: LoginState, formData: FormData): Promise { + const parsed = z.email().safeParse(String(formData.get("email") ?? "").trim().toLowerCase()); + if (!parsed.success) { + return { status: "error", message: "That doesn't look like an email address. Check it and try again." }; + } + const email = parsed.data; + + // Checked before calling Supabase so strangers can't create accounts or trigger emails. + if (!serverEnv.ADMIN_EMAILS.includes(email)) { + return { + status: "error", + message: "This email isn't on the admin list. Add it to ADMIN_EMAILS and restart the app.", + }; + } + + const supabase = await createClient(); + const { error } = await supabase.auth.signInWithOtp({ + email, + options: { emailRedirectTo: `${serverEnv.NEXT_PUBLIC_SITE_URL}/auth/confirm` }, + }); + if (error) { + const message = + error.status === 429 + ? "Too many sign-in emails were sent. Wait a few minutes and try again." + : `The sign-in email couldn't be sent (${error.message}). Try again.`; + return { status: "error", message }; + } + return { + status: "sent", + message: `Sign-in link sent to ${email}. Open it in this same browser.`, + }; +} + +export async function signOut() { + const supabase = await createClient(); + await supabase.auth.signOut(); + redirect("/login"); +} diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx new file mode 100644 index 0000000..74fc8e3 --- /dev/null +++ b/src/app/login/login-form.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useActionState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Field, inputClass } from "@/components/ui/field"; + +import { sendMagicLink, type LoginState } from "./actions"; + +const initialState: LoginState = { status: "idle", message: "" }; + +export function LoginForm({ initialError }: { initialError: string | null }) { + const [state, formAction, pending] = useActionState(sendMagicLink, initialState); + const error = state.status === "error" ? state.message : state.status === "idle" ? initialError : null; + + return ( +
+ + + + +

+ {state.status === "sent" && {state.message}} + {error && {error}} +

+
+ ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..08b9fac --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,23 @@ +import { publicEnv } from "@/lib/env.public"; + +import { LoginForm } from "./login-form"; + +const errors: Record = { + link: "That sign-in link didn't work. It may have expired or been opened in a different browser. Send a new one.", + "not-admin": "You're signed in, but this email isn't on the admin list.", +}; + +export default async function LoginPage(props: PageProps<"/login">) { + const { error } = await props.searchParams; + const initialError = typeof error === "string" ? (errors[error] ?? null) : null; + + return ( +
+
+

{publicEnv.NEXT_PUBLIC_APP_NAME}

+

Sign in as host. We'll email you a link — no password.

+
+ +
+ ); +} diff --git a/src/components/admin/create-event-form.tsx b/src/components/admin/create-event-form.tsx new file mode 100644 index 0000000..bd43f73 --- /dev/null +++ b/src/components/admin/create-event-form.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useActionState } from "react"; + +import { createEvent } from "@/app/admin/actions"; +import { Button } from "@/components/ui/button"; +import { Field, inputClass } from "@/components/ui/field"; + +import { ErrorText } from "./error-text"; + +export function CreateEventForm() { + const [state, formAction, pending] = useActionState(createEvent, null); + + return ( +
+ + + + + + + +
+ {state && !state.ok ? state.error : null} +
+
+ ); +} diff --git a/src/components/admin/error-text.tsx b/src/components/admin/error-text.tsx new file mode 100644 index 0000000..78781c5 --- /dev/null +++ b/src/components/admin/error-text.tsx @@ -0,0 +1,7 @@ +export function ErrorText({ children }: { children: string | null }) { + return ( +

+ {children} +

+ ); +} diff --git a/src/components/admin/event-details-form.tsx b/src/components/admin/event-details-form.tsx new file mode 100644 index 0000000..f85d38b --- /dev/null +++ b/src/components/admin/event-details-form.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useState } from "react"; + +import { updateEvent } from "@/app/admin/actions"; +import { Button } from "@/components/ui/button"; +import { Field, inputClass } from "@/components/ui/field"; + +import { ErrorText } from "./error-text"; +import { useAction } from "./use-action"; + +export function EventDetailsForm(props: { eventId: string; name: string; eventDate: string }) { + const [name, setName] = useState(props.name); + const [eventDate, setEventDate] = useState(props.eventDate); + const { pending, error, run } = useAction(); + const dirty = name !== props.name || eventDate !== props.eventDate; + + return ( +
{ + event.preventDefault(); + run(() => updateEvent(props.eventId, { name, eventDate })); + }} + > + + setName(event.target.value)} + required + maxLength={120} + className={inputClass} + /> + + + setEventDate(event.target.value)} + className={inputClass} + /> + + +
+ {error} +
+
+ ); +} diff --git a/src/components/admin/event-row-actions.tsx b/src/components/admin/event-row-actions.tsx new file mode 100644 index 0000000..c104db5 --- /dev/null +++ b/src/components/admin/event-row-actions.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { deleteEvent, duplicateEvent } from "@/app/admin/actions"; +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 }) { + const { pending, error, run } = useAction(); + + return ( +
+ + + {error} +
+ ); +} diff --git a/src/components/admin/game-form.tsx b/src/components/admin/game-form.tsx new file mode 100644 index 0000000..8b9f872 --- /dev/null +++ b/src/components/admin/game-form.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { useState } from "react"; + +import { saveGame } from "@/app/admin/actions"; +import { Button } from "@/components/ui/button"; +import { Field, inputClass, textareaClass } from "@/components/ui/field"; +import { criteriaPresets, type PresetKey } from "@/lib/presets"; +import type { GameWithCriteria } from "@/lib/queries/event"; +import { parsePlacementPoints, type CriterionDraft, type GameDraft } from "@/lib/validation/admin"; + +import { ErrorText } from "./error-text"; +import { useAction } from "./use-action"; + +// A stable React key per criterion row, including rows that aren't saved yet. +type CriterionRow = CriterionDraft & { key: string }; +type Draft = Omit & { + placementPoints: string; + criteria: CriterionRow[]; +}; + +let nextKey = 0; +const newKey = () => `new-${nextKey++}`; + +const blankCriterion = (): CriterionRow => ({ + key: newKey(), + label: "", + description: "", + inputType: "blocks", + min: 1, + max: 5, + step: 1, + weight: 1, +}); + +function toDraft(game: GameWithCriteria): Draft { + return { + title: game.title, + description: game.description ?? "", + scoringMode: game.scoring_mode, + pointsMode: game.points_mode, + multiplier: game.multiplier, + placementPoints: game.placement_points.join(", "), + adminNotes: game.admin_notes ?? "", + judgeNotes: game.judge_notes ?? "", + criteria: game.criteria.map((criterion) => ({ + key: criterion.id, + id: criterion.id, + label: criterion.label, + description: criterion.description ?? "", + inputType: criterion.input_type, + min: criterion.min_value, + max: criterion.max_value, + step: criterion.step, + weight: criterion.weight, + })), + }; +} + +const presetKeys = Object.keys(criteriaPresets) as PresetKey[]; + +export function GameForm({ eventId, game }: { eventId: string; game: GameWithCriteria }) { + const [draft, setDraft] = useState(() => toDraft(game)); + const [dirty, setDirty] = useState(false); + const { pending, error, run } = useAction(); + + const change = (patch: Partial) => { + setDraft((current) => ({ ...current, ...patch })); + setDirty(true); + }; + const changeCriterion = (key: string, patch: Partial) => + change({ + criteria: draft.criteria.map((criterion) => + criterion.key === key ? { ...criterion, ...patch } : criterion, + ), + }); + const moveCriterion = (index: number, offset: -1 | 1) => { + const criteria = [...draft.criteria]; + [criteria[index], criteria[index + offset]] = [criteria[index + offset], criteria[index]]; + change({ criteria }); + }; + + const applyPreset = (key: PresetKey) => { + const hasSaved = draft.criteria.some((criterion) => criterion.id); + if ( + hasSaved && + !window.confirm("Replace this game's criteria with the preset? Scores for the old criteria are deleted when you save.") + ) { + return; + } + change({ + criteria: criteriaPresets[key].criteria.map((criterion) => ({ ...criterion, key: newKey() })), + }); + }; + + const save = () => + run( + () => + saveGame(eventId, game.id, { + ...draft, + placementPoints: parsePlacementPoints(draft.placementPoints), + criteria: draft.criteria.map((row) => { + const criterion: CriterionDraft & { key?: string } = { ...row }; + delete criterion.key; + return criterion; + }), + }), + () => setDirty(false), + ); + + return ( +
{ + event.preventDefault(); + save(); + }} + > +
+ + change({ title: event.target.value })} + required + maxLength={120} + className={inputClass} + /> + + + change({ description: event.target.value })} + className={inputClass} + /> + + + + + + + + + change({ multiplier })} /> + + {draft.pointsMode === "placement" && ( + + change({ placementPoints: event.target.value })} + className={inputClass} + placeholder="10, 7, 5, 3" + /> + + )} +
+ +
+ Criteria +
+ Replace with preset: + {presetKeys.map((key) => ( + + ))} +
+ +
    + {draft.criteria.map((criterion, index) => ( +
  1. +
    + + changeCriterion(criterion.key, { label: event.target.value })} + required + maxLength={60} + className={inputClass} + placeholder="Pitch" + /> + + + changeCriterion(criterion.key, { description: event.target.value })} + maxLength={300} + className={inputClass} + /> + +
    +
    + + + + + changeCriterion(criterion.key, { min })} /> + + + changeCriterion(criterion.key, { max })} /> + + + changeCriterion(criterion.key, { step })} /> + + + changeCriterion(criterion.key, { weight })} /> + +
    + + + +
    +
    +
  2. + ))} +
+
+ +
+
+ +
+ +