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 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 13:47:36 +02:00
co-authored by Claude Opus 5.5
parent 0c66e9b80e
commit b60c9db4f4
30 changed files with 1930 additions and 2 deletions
+10
View File
@@ -40,3 +40,13 @@ don't rely on memory for SDK method names (esp. `@supabase/ssr` and Realtime bro
## Commands ## Commands
- `npm run dev` / `npm run build` / `npm run test` / `npm run lint` - `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. - Migrations live in `supabase/migrations/`. Apply via Supabase CLI or paste into SQL editor.
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->
+1 -1
View File
@@ -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}` | | `PUT /api/judge/[token]/scores` | judge | Upsert one score `{teamId, criterionId, value}` |
| `POST /api/judge-open/[openToken]` | judge | `{name}` → `{judgeToken}` | | `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/`) ## Supabase clients (`src/lib/supabase/`)
- `browser.ts` — anon key, used for Realtime subscribe + admin auth session. - `browser.ts` — anon key, used for Realtime subscribe + admin auth session.
+1 -1
View File
@@ -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. **Done when:** tests green, 100% branch coverage on the scoring module.
## Phase 3 — Admin auth + setup screens ## 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. - Events list + create/duplicate/delete.
- Event setup: teams CRUD (colour picker, reorder), games builder with criteria + presets, notes. - 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. **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.
+448
View File
@@ -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<T extends { id: string }>(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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ReturnType<typeof requireAdmin>>["supabase"];
type GameRowWithCriteria = NonNullable<
Awaited<ReturnType<typeof loadGame>>["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<string | null> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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;
}
+50
View File
@@ -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 (
<div className="flex flex-col gap-8">
<div>
<Link href="/admin" className="text-sm underline underline-offset-4">
← All game nights
</Link>
<h1 className="mt-1 font-display text-4xl font-bold">{event.name}</h1>
</div>
<section className="rounded-lg border border-ink/15 bg-white p-4">
<h2 className="text-xl font-bold">Details</h2>
<EventDetailsForm
key={event.updated_at}
eventId={event.id}
name={event.name}
eventDate={event.event_date ?? ""}
/>
</section>
<section className="rounded-lg border border-ink/15 bg-white p-4">
<h2 className="text-xl font-bold">Teams</h2>
<TeamsEditor eventId={event.id} teams={teams} />
</section>
<section>
<h2 className="text-xl font-bold">Games</h2>
<GamesEditor eventId={event.id} games={games} />
</section>
</div>
);
}
+29
View File
@@ -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 (
<div className="min-h-dvh">
<header className="border-b border-ink/15 bg-white">
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-4 py-2">
<Link href="/admin" className="font-display text-2xl font-bold">
{publicEnv.NEXT_PUBLIC_APP_NAME}
</Link>
<form action={signOut} className="flex items-center gap-3">
<span className="text-sm text-ink/70">{email}</span>
<Button type="submit" variant="secondary" size="sm">
Sign out
</Button>
</form>
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main>
</div>
);
}
+51
View File
@@ -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 (
<div className="flex flex-col gap-8">
<section>
<h1 className="font-display text-4xl font-bold">Your game nights</h1>
{events.length === 0 ? (
<p className="mt-3 text-lg">No game nights yet. Create your first one below.</p>
) : (
<ul className="mt-4 flex flex-col gap-3">
{events.map((event) => (
<li
key={event.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-ink/15 bg-white p-4"
>
<Link href={`/admin/events/${event.id}`} className="min-w-0 flex-1">
<span className="block truncate text-xl font-bold underline-offset-4 hover:underline">
{event.name}
</span>
<span className="text-sm text-ink/70">
{event.event_date ? dateFormat.format(new Date(event.event_date)) : "No date set"}
</span>
</Link>
<EventRowActions eventId={event.id} eventName={event.name} />
</li>
))}
</ul>
)}
</section>
<section className="rounded-lg border border-ink/15 bg-white p-4">
<h2 className="text-xl font-bold">New game night</h2>
<CreateEventForm />
</section>
</div>
);
}
+30
View File
@@ -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);
}
+48
View File
@@ -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<LoginState> {
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");
}
+37
View File
@@ -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 (
<form action={formAction} className="flex flex-col gap-4">
<Field label="Email">
<input
name="email"
type="email"
autoComplete="email"
required
className={inputClass}
placeholder="you@example.com"
/>
</Field>
<Button type="submit" disabled={pending}>
{pending ? "Sending…" : "Send sign-in link"}
</Button>
<p role="status" aria-live="polite" className="min-h-6">
{state.status === "sent" && <span className="font-bold text-ink">{state.message}</span>}
{error && <span className="font-bold text-alert">{error}</span>}
</p>
</form>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { publicEnv } from "@/lib/env.public";
import { LoginForm } from "./login-form";
const errors: Record<string, string> = {
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 (
<main className="mx-auto flex min-h-dvh max-w-md flex-col justify-center gap-6 p-6">
<div>
<h1 className="font-display text-5xl font-bold">{publicEnv.NEXT_PUBLIC_APP_NAME}</h1>
<p className="mt-2 text-lg">Sign in as host. We&apos;ll email you a link — no password.</p>
</div>
<LoginForm initialError={initialError} />
</main>
);
}
@@ -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 (
<form action={formAction} className="mt-3 flex flex-wrap items-end gap-3">
<Field label="Name" className="min-w-56 flex-1">
<input name="name" required maxLength={120} className={inputClass} placeholder="Worship Game Night" />
</Field>
<Field label="Date (optional)">
<input name="eventDate" type="date" className={inputClass} />
</Field>
<Button type="submit" disabled={pending}>
{pending ? "Creating…" : "Create game night"}
</Button>
<div className="basis-full">
<ErrorText>{state && !state.ok ? state.error : null}</ErrorText>
</div>
</form>
);
}
+7
View File
@@ -0,0 +1,7 @@
export function ErrorText({ children }: { children: string | null }) {
return (
<p role="alert" className="font-bold text-alert empty:hidden">
{children}
</p>
);
}
@@ -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 (
<form
className="mt-3 flex flex-wrap items-end gap-3"
onSubmit={(event) => {
event.preventDefault();
run(() => updateEvent(props.eventId, { name, eventDate }));
}}
>
<Field label="Name" className="min-w-56 flex-1">
<input
value={name}
onChange={(event) => setName(event.target.value)}
required
maxLength={120}
className={inputClass}
/>
</Field>
<Field label="Date (optional)">
<input
type="date"
value={eventDate}
onChange={(event) => setEventDate(event.target.value)}
className={inputClass}
/>
</Field>
<Button type="submit" variant="secondary" disabled={pending || !dirty}>
{pending ? "Saving…" : dirty ? "Save details" : "Saved"}
</Button>
<div className="basis-full">
<ErrorText>{error}</ErrorText>
</div>
</form>
);
}
@@ -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 (
<div className="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
size="sm"
disabled={pending}
onClick={() => run(() => duplicateEvent(eventId))}
>
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>
<ErrorText>{error}</ErrorText>
</div>
);
}
+340
View File
@@ -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<GameDraft, "criteria" | "placementPoints"> & {
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<Draft>) => {
setDraft((current) => ({ ...current, ...patch }));
setDirty(true);
};
const changeCriterion = (key: string, patch: Partial<CriterionDraft>) =>
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 (
<form
className="flex flex-col gap-5"
onSubmit={(event) => {
event.preventDefault();
save();
}}
>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Title">
<input
value={draft.title}
onChange={(event) => change({ title: event.target.value })}
required
maxLength={120}
className={inputClass}
/>
</Field>
<Field label="Description (optional)">
<input
value={draft.description}
onChange={(event) => change({ description: event.target.value })}
className={inputClass}
/>
</Field>
<Field
label="Who scores"
hint={draft.scoringMode === "judged" ? "Judges score from their phones via links." : "You enter the scores yourself."}
>
<select
value={draft.scoringMode}
onChange={(event) => change({ scoringMode: event.target.value as Draft["scoringMode"] })}
className={inputClass}
>
<option value="direct">Direct — host enters scores</option>
<option value="judged">Judged — judges score via links</option>
</select>
</Field>
<Field
label="Leaderboard points"
hint={
draft.pointsMode === "placement"
? "Teams are ranked, then get the placement points below."
: "Teams get their criteria total as points."
}
>
<select
value={draft.pointsMode}
onChange={(event) => change({ pointsMode: event.target.value as Draft["pointsMode"] })}
className={inputClass}
>
<option value="raw">Raw — criteria total</option>
<option value="placement">Placement — points by rank</option>
</select>
</Field>
<Field label="Multiplier" hint="Points are multiplied by this. Use 2 for a double-points game.">
<NumberInput value={draft.multiplier} min={0} step="any" onChange={(multiplier) => change({ multiplier })} />
</Field>
{draft.pointsMode === "placement" && (
<Field label="Placement points" hint="1st, 2nd, 3rd… separated by commas. Teams beyond the list get 0.">
<input
value={draft.placementPoints}
onChange={(event) => change({ placementPoints: event.target.value })}
className={inputClass}
placeholder="10, 7, 5, 3"
/>
</Field>
)}
</div>
<fieldset className="flex flex-col gap-3">
<legend className="text-lg font-bold">Criteria</legend>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-bold">Replace with preset:</span>
{presetKeys.map((key) => (
<Button key={key} variant="secondary" size="sm" onClick={() => applyPreset(key)}>
{criteriaPresets[key].label}
</Button>
))}
</div>
<ol className="flex flex-col gap-3">
{draft.criteria.map((criterion, index) => (
<li key={criterion.key} className="rounded-md border border-ink/15 bg-chalk p-3">
<div className="grid gap-3 sm:grid-cols-[2fr_3fr]">
<Field label="Label">
<input
value={criterion.label}
onChange={(event) => changeCriterion(criterion.key, { label: event.target.value })}
required
maxLength={60}
className={inputClass}
placeholder="Pitch"
/>
</Field>
<Field label="Description (shown to whoever scores)">
<input
value={criterion.description}
onChange={(event) => changeCriterion(criterion.key, { description: event.target.value })}
maxLength={300}
className={inputClass}
/>
</Field>
</div>
<div className="mt-3 flex flex-wrap items-end gap-3">
<Field label="Input">
<select
value={criterion.inputType}
onChange={(event) =>
changeCriterion(criterion.key, {
inputType: event.target.value as CriterionDraft["inputType"],
})
}
className={inputClass}
>
<option value="blocks">Blocks (tap a number)</option>
<option value="number">Number (type or +/−)</option>
</select>
</Field>
<Field label="Min" className="w-20">
<NumberInput value={criterion.min} step="any" onChange={(min) => changeCriterion(criterion.key, { min })} />
</Field>
<Field label="Max" className="w-20">
<NumberInput value={criterion.max} step="any" onChange={(max) => changeCriterion(criterion.key, { max })} />
</Field>
<Field label="Step" className="w-20">
<NumberInput value={criterion.step} step="any" onChange={(step) => changeCriterion(criterion.key, { step })} />
</Field>
<Field label="Weight" className="w-20">
<NumberInput value={criterion.weight} min={0} step="any" onChange={(weight) => changeCriterion(criterion.key, { weight })} />
</Field>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
aria-label={`Move ${criterion.label || "criterion"} up`}
disabled={index === 0}
onClick={() => moveCriterion(index, -1)}
>
↑
</Button>
<Button
variant="secondary"
size="sm"
aria-label={`Move ${criterion.label || "criterion"} down`}
disabled={index === draft.criteria.length - 1}
onClick={() => moveCriterion(index, 1)}
>
↓
</Button>
<Button
variant="danger"
size="sm"
disabled={draft.criteria.length === 1}
onClick={() =>
change({ criteria: draft.criteria.filter((other) => other.key !== criterion.key) })
}
>
Remove
</Button>
</div>
</div>
</li>
))}
</ol>
<div>
<Button
variant="secondary"
disabled={draft.criteria.length >= 20}
onClick={() => change({ criteria: [...draft.criteria, blankCriterion()] })}
>
Add criterion
</Button>
</div>
</fieldset>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Notes for judges" hint="Judges see this on their page.">
<textarea
value={draft.judgeNotes}
onChange={(event) => change({ judgeNotes: event.target.value })}
rows={3}
className={textareaClass}
/>
</Field>
<Field label="Notes for you" hint="Only you see this, in the control room.">
<textarea
value={draft.adminNotes}
onChange={(event) => change({ adminNotes: event.target.value })}
rows={3}
className={textareaClass}
/>
</Field>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" disabled={pending || !dirty}>
{pending ? "Saving…" : "Save game"}
</Button>
<span role="status" className="text-sm font-bold">
{dirty ? "Unsaved changes" : "Saved ✓"}
</span>
<ErrorText>{error}</ErrorText>
</div>
</form>
);
}
/** Numeric input that reports NaN while empty, so validation can say "must be a number". */
function NumberInput({
value,
onChange,
...props
}: {
value: number;
onChange: (value: number) => void;
min?: number;
step?: number | "any";
}) {
return (
<input
type="number"
inputMode="decimal"
value={Number.isNaN(value) ? "" : value}
onChange={(event) => onChange(event.target.value === "" ? Number.NaN : Number(event.target.value))}
className={inputClass}
{...props}
/>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { addGame, deleteGame, duplicateGame, moveGame } from "@/app/admin/actions";
import { Button } from "@/components/ui/button";
import { inputClass } from "@/components/ui/field";
import { criteriaPresets, type PresetKey } from "@/lib/presets";
import type { GameWithCriteria } from "@/lib/queries/event";
import { ErrorText } from "./error-text";
import { GameForm } from "./game-form";
import { useAction } from "./use-action";
const presetKeys = Object.keys(criteriaPresets) as PresetKey[];
export function GamesEditor({ eventId, games }: { eventId: string; games: GameWithCriteria[] }) {
const [preset, setPreset] = useState<PresetKey>("karaoke");
const { pending, error, run } = useAction();
return (
<div className="mt-3 flex flex-col gap-3">
{games.length === 0 && <p>No games yet. Pick a starting point below and add one.</p>}
<ol className="flex flex-col gap-3">
{games.map((game, index) => (
<GameCard
key={game.id}
eventId={eventId}
game={game}
number={index + 1}
isFirst={index === 0}
isLast={index === games.length - 1}
/>
))}
</ol>
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-ink/15 bg-white p-4">
<label className="flex flex-wrap items-center gap-2">
<span className="font-bold">Start from</span>
<select
value={preset}
onChange={(event) => setPreset(event.target.value as PresetKey)}
className={`${inputClass} w-auto`}
>
{presetKeys.map((key) => (
<option key={key} value={key}>
{criteriaPresets[key].label}
</option>
))}
</select>
</label>
<Button disabled={pending} onClick={() => run(() => addGame(eventId, preset))}>
Add game
</Button>
<ErrorText>{error}</ErrorText>
</div>
</div>
);
}
function GameCard({
eventId,
game,
number,
isFirst,
isLast,
}: {
eventId: string;
game: GameWithCriteria;
number: number;
isFirst: boolean;
isLast: boolean;
}) {
const { pending, error, run } = useAction();
const criteriaCount = game.criteria.length;
return (
<li className="rounded-lg border border-ink/15 bg-white">
<details>
<summary className="flex min-h-11 cursor-pointer flex-wrap items-center gap-x-3 gap-y-1 p-4">
<span className="text-xl font-bold">
{number}. {game.title}
</span>
<span className="text-sm text-ink/70">
{game.scoring_mode === "judged" ? "Judged" : "Direct"} ·{" "}
{game.points_mode === "placement" ? "placement points" : "raw points"} · {criteriaCount}{" "}
{criteriaCount === 1 ? "criterion" : "criteria"}
</span>
</summary>
<div className="flex flex-col gap-4 border-t border-ink/15 p-4">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
size="sm"
disabled={pending || isFirst}
onClick={() => run(() => moveGame(eventId, game.id, "up"))}
>
↑ Move up
</Button>
<Button
variant="secondary"
size="sm"
disabled={pending || isLast}
onClick={() => run(() => moveGame(eventId, game.id, "down"))}
>
↓ Move down
</Button>
<Button
variant="secondary"
size="sm"
disabled={pending}
onClick={() => run(() => duplicateGame(eventId, game.id))}
>
Duplicate
</Button>
<Button
variant="danger"
size="sm"
disabled={pending}
onClick={() => {
if (window.confirm(`Delete "${game.title}" and its scores? This can't be undone.`)) {
run(() => deleteGame(eventId, game.id));
}
}}
>
Delete
</Button>
<ErrorText>{error}</ErrorText>
</div>
{/* Re-mount after each save so the form picks up ids of newly created criteria. */}
<GameForm key={game.updated_at} eventId={eventId} game={game} />
</div>
</details>
</li>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import { addTeam, deleteTeam, moveTeam, updateTeam } 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 Team = Tables<"teams">;
export function TeamsEditor({ eventId, teams }: { eventId: string; teams: Team[] }) {
const [newName, setNewName] = useState("");
const { pending, error, run } = useAction();
return (
<div className="mt-3 flex flex-col gap-3">
{teams.length === 0 && <p>No teams yet. Add the first one below.</p>}
<ul className="flex flex-col gap-2">
{teams.map((team, index) => (
<TeamRow
// Re-mount when saved values change so local edits reset to the server's copy.
key={`${team.id}:${team.name}:${team.color}`}
eventId={eventId}
team={team}
isFirst={index === 0}
isLast={index === teams.length - 1}
/>
))}
</ul>
<form
className="flex flex-wrap items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
run(() => addTeam(eventId, newName), () => setNewName(""));
}}
>
<input
aria-label="New team name"
value={newName}
onChange={(event) => setNewName(event.target.value)}
required
maxLength={60}
placeholder="Team name"
className={`${inputClass} max-w-xs`}
/>
<Button type="submit" disabled={pending}>
Add team
</Button>
</form>
<ErrorText>{error}</ErrorText>
</div>
);
}
function TeamRow({
eventId,
team,
isFirst,
isLast,
}: {
eventId: string;
team: Team;
isFirst: boolean;
isLast: boolean;
}) {
const [name, setName] = useState(team.name);
const [color, setColor] = useState(team.color);
const { pending, error, run } = useAction();
// Saves on blur rather than per keystroke: one request per edit.
const save = () => {
if (name === team.name && color === team.color) return;
run(() => updateTeam(eventId, team.id, { name, color }));
};
return (
<li className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<input
type="color"
aria-label={`Colour for ${team.name}`}
value={color}
onChange={(event) => setColor(event.target.value)}
onBlur={save}
className="h-11 w-14 cursor-pointer rounded-md border border-ink/25 bg-white p-1"
/>
<input
aria-label="Team name"
value={name}
onChange={(event) => setName(event.target.value)}
onBlur={save}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
}}
maxLength={60}
className={`${inputClass} min-w-40 flex-1`}
/>
<Button
variant="secondary"
size="sm"
aria-label={`Move ${team.name} up`}
disabled={pending || isFirst}
onClick={() => run(() => moveTeam(eventId, team.id, "up"))}
>
↑
</Button>
<Button
variant="secondary"
size="sm"
aria-label={`Move ${team.name} down`}
disabled={pending || isLast}
onClick={() => run(() => moveTeam(eventId, team.id, "down"))}
>
↓
</Button>
<Button
variant="danger"
size="sm"
disabled={pending}
onClick={() => {
if (window.confirm(`Delete team "${team.name}" and its scores?`)) {
run(() => deleteTeam(eventId, team.id));
}
}}
>
Delete
</Button>
</div>
<ErrorText>{error}</ErrorText>
</li>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useCallback, useState, useTransition } from "react";
import type { ActionResult } from "@/app/admin/actions";
/** Runs a server action, tracking pending state and the last error message. */
export function useAction() {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const run = useCallback((action: () => Promise<ActionResult>, onSuccess?: () => void) => {
startTransition(async () => {
try {
const result = await action();
if (result.ok) {
setError(null);
onSuccess?.();
} else {
setError(result.error);
}
} catch {
setError("Couldn't reach the server. Check your connection and try again.");
}
});
}, []);
return { pending, error, run };
}
+28
View File
@@ -0,0 +1,28 @@
import type { ButtonHTMLAttributes } from "react";
const variants = {
primary: "bg-ink text-white hover:bg-ink/90",
secondary: "border border-ink/25 bg-white text-ink hover:bg-ink/5",
danger: "border border-alert/50 bg-white text-alert hover:bg-alert/10",
} as const;
const sizes = {
// min-h-11 = 44px tap target
md: "min-h-11 px-4 text-base",
sm: "min-h-11 min-w-11 px-3 text-sm",
} as const;
export type ButtonVariant = keyof typeof variants;
export function buttonClass(variant: ButtonVariant = "primary", size: keyof typeof sizes = "md") {
return `inline-flex items-center justify-center gap-2 rounded-md font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${variants[variant]} ${sizes[size]}`;
}
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: keyof typeof sizes;
};
export function Button({ variant, size, className = "", type = "button", ...props }: ButtonProps) {
return <button type={type} className={`${buttonClass(variant, size)} ${className}`} {...props} />;
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
export const inputClass =
"min-h-11 w-full rounded-md border border-ink/25 bg-white px-3 text-base text-ink placeholder:text-ink/40";
export const textareaClass = `${inputClass} py-2`;
/** Label wrapping its control, so the whole thing is one tap target and needs no ids. */
export function Field({
label,
hint,
className = "",
children,
}: {
label: string;
hint?: string;
className?: string;
children: ReactNode;
}) {
return (
<label className={`flex flex-col gap-1 ${className}`}>
<span className="text-sm font-bold">{label}</span>
{children}
{hint && <span className="text-sm text-ink/70">{hint}</span>}
</label>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { isAdminEmail } from "./admin-emails";
describe("isAdminEmail", () => {
const list = "Host@Example.com, second@example.com";
it("matches case-insensitively and ignores whitespace", () => {
expect(isAdminEmail("host@example.com", list)).toBe(true);
expect(isAdminEmail(" SECOND@example.com ", list)).toBe(true);
});
it("rejects everyone else, including missing emails and an empty list", () => {
expect(isAdminEmail("stranger@example.com", list)).toBe(false);
expect(isAdminEmail(undefined, list)).toBe(false);
expect(isAdminEmail("", "")).toBe(false);
expect(isAdminEmail("host@example.com", undefined)).toBe(false);
});
});
+11
View File
@@ -0,0 +1,11 @@
/** Parses the comma-separated ADMIN_EMAILS env var. */
export function parseAdminEmails(raw: string | undefined): string[] {
return (raw ?? "")
.split(",")
.map((email) => email.trim().toLowerCase())
.filter(Boolean);
}
export function isAdminEmail(email: unknown, raw: string | undefined): boolean {
return typeof email === "string" && parseAdminEmails(raw).includes(email.trim().toLowerCase());
}
+21
View File
@@ -0,0 +1,21 @@
import "server-only";
import { redirect } from "next/navigation";
import { serverEnv } from "@/lib/env.server";
import { createClient } from "@/lib/supabase/server";
/**
* Call at the top of every admin page and server action. The proxy already guards
* /admin, but server actions are plain POST endpoints, so each one re-checks.
*/
export async function requireAdmin() {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
const email = data?.claims.email?.toLowerCase();
if (!data || !email) redirect("/login");
if (!serverEnv.ADMIN_EMAILS.includes(email)) redirect("/login?error=not-admin");
return { supabase, userId: data.claims.sub, email };
}
+49
View File
@@ -0,0 +1,49 @@
import type { CriterionDraft } from "@/lib/validation/admin";
const blocks = (label: string, min: number, max: number, description = ""): CriterionDraft => ({
label,
description,
inputType: "blocks",
min,
max,
step: 1,
weight: 1,
});
/** Quick presets for a game's criteria (PRD 4.1). */
export const criteriaPresets = {
karaoke: {
label: "Karaoke (5 × 1–5)",
criteria: [
blocks("Pitch", 1, 5, "Did they hit the notes?"),
blocks("Energy", 1, 5, "How much did they give it?"),
blocks("Lyrics accuracy", 1, 5, "Right words, right time"),
blocks("Stage presence", 1, 5, "Confidence and movement"),
blocks("Crowd reaction", 1, 5, "How loud was the room?"),
],
},
single: {
label: "Single score (0–10)",
criteria: [blocks("Score", 0, 10)],
},
points: {
label: "Points (number)",
criteria: [
{
label: "Points",
description: "",
inputType: "number",
min: 0,
max: 100,
step: 1,
weight: 1,
},
],
},
winLose: {
label: "Win/Lose (0–1)",
criteria: [blocks("Win", 0, 1, "1 = won, 0 = lost")],
},
} satisfies Record<string, { label: string; criteria: CriterionDraft[] }>;
export type PresetKey = keyof typeof criteriaPresets;
+39
View File
@@ -0,0 +1,39 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database, Tables } from "@/lib/database.types";
type Client = SupabaseClient<Database>;
export type GameWithCriteria = Tables<"games"> & { criteria: Tables<"criteria">[] };
export type EventSetup = {
event: Tables<"events">;
teams: Tables<"teams">[];
games: GameWithCriteria[];
};
/** Everything the setup page needs. Works with either the admin's RLS client or the service client. */
export async function loadEventSetup(supabase: Client, eventId: string): Promise<EventSetup | null> {
const [event, teams, games] = await Promise.all([
supabase.from("events").select("*").eq("id", eventId).maybeSingle(),
supabase
.from("teams")
.select("*")
.eq("event_id", eventId)
.order("sort_order")
.order("created_at"),
supabase
.from("games")
.select("*, criteria(*)")
.eq("event_id", eventId)
.order("sort_order")
.order("created_at")
.order("sort_order", { referencedTable: "criteria" }),
]);
const error = event.error ?? teams.error ?? games.error;
if (error) throw new Error(`Could not load the event: ${error.message}`);
if (!event.data) return null;
return { event: event.data, teams: teams.data ?? [], games: games.data ?? [] };
}
+36
View File
@@ -0,0 +1,36 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
import type { Database } from "@/lib/database.types";
import { publicEnv } from "@/lib/env.public";
/**
* Refreshes the admin's auth cookie (Server Components can't write cookies, so the proxy
* does it) and returns the verified JWT claims, or null when signed out.
*/
export async function updateSession(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient<Database>(
publicEnv.NEXT_PUBLIC_SUPABASE_URL,
publicEnv.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options),
);
},
},
},
);
// getClaims() verifies the token's signature; never trust the raw cookie.
const { data } = await supabase.auth.getClaims();
return { response, claims: data?.claims ?? null };
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { criteriaPresets } from "@/lib/presets";
import { criterionSchema, gameSchema, parsePlacementPoints, type GameDraft } from "./admin";
const criterion = { label: "Pitch", description: "", inputType: "blocks", min: 1, max: 5, step: 1, weight: 1 } as const;
const game: GameDraft = {
title: "Karaoke",
description: "",
scoringMode: "judged",
pointsMode: "raw",
multiplier: 1,
placementPoints: [10, 7, 5, 3],
adminNotes: "",
judgeNotes: "",
criteria: [criterion],
};
const message = (result: ReturnType<typeof criterionSchema.safeParse>) =>
result.success ? null : result.error.issues[0].message;
describe("criterionSchema", () => {
it("accepts every preset", () => {
for (const preset of Object.values(criteriaPresets)) {
for (const draft of preset.criteria) {
expect(criterionSchema.safeParse(draft).success).toBe(true);
}
}
});
it("rejects max <= min", () => {
expect(message(criterionSchema.safeParse({ ...criterion, min: 5, max: 5 }))).toMatch(/max must be more/);
});
it("rejects a step that doesn't fit the range, but allows fractional steps", () => {
expect(message(criterionSchema.safeParse({ ...criterion, min: 0, max: 5, step: 2 }))).toMatch(/step/);
expect(criterionSchema.safeParse({ ...criterion, min: 0, max: 5, step: 0.5 }).success).toBe(true);
expect(criterionSchema.safeParse({ ...criterion, min: 0, max: 1, step: 0.1 }).success).toBe(true);
});
it("limits blocks to 11 buttons but not number inputs", () => {
expect(message(criterionSchema.safeParse({ ...criterion, min: 0, max: 20 }))).toMatch(/blocks/);
expect(criterionSchema.safeParse({ ...criterion, inputType: "number", min: 0, max: 20 }).success).toBe(true);
});
it("rejects an empty number field (NaN)", () => {
expect(message(criterionSchema.safeParse({ ...criterion, weight: Number.NaN }))).toMatch(/Weight must be a number/);
});
});
describe("gameSchema", () => {
it("accepts a valid game and trims text", () => {
const parsed = gameSchema.parse({ ...game, title: " Karaoke " });
expect(parsed.title).toBe("Karaoke");
});
it("needs a title, a criterion and whole placement points", () => {
expect(gameSchema.safeParse({ ...game, title: " " }).success).toBe(false);
expect(gameSchema.safeParse({ ...game, criteria: [] }).success).toBe(false);
expect(gameSchema.safeParse({ ...game, placementPoints: [10, 7.5] }).success).toBe(false);
expect(gameSchema.safeParse({ ...game, placementPoints: [] }).success).toBe(false);
});
});
describe("parsePlacementPoints", () => {
it("splits on commas and spaces", () => {
expect(parsePlacementPoints("10, 7,5 3")).toEqual([10, 7, 5, 3]);
expect(parsePlacementPoints("")).toEqual([]);
expect(parsePlacementPoints("10, x")).toEqual([10, Number.NaN]);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { z } from "zod";
import { idSchema } from "@/lib/tokens";
// Limits mirror the CHECK constraints in supabase/migrations/0001_init.sql so that
// the admin sees a friendly message instead of a database error.
const text = (max: number, what: string) =>
z
.string()
.trim()
.min(1, `${what} can't be empty.`)
.max(max, `${what} can be at most ${max} characters.`);
const optionalText = (max: number, what: string) =>
z.string().trim().max(max, `${what} can be at most ${max} characters.`);
const number = (what: string) => z.number(`${what} must be a number.`);
export const eventSchema = z.object({
name: text(120, "Event name"),
eventDate: z.union([z.iso.date("Pick a valid date."), z.literal("")]),
});
export const teamSchema = z.object({
name: text(60, "Team name"),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Pick a colour."),
});
/** Is (max − min) a whole number of steps? Tolerates float error for steps like 0.5. */
const onStep = (min: number, max: number, step: number) => {
const steps = (max - min) / step;
return Math.abs(steps - Math.round(steps)) < 1e-9;
};
export const criterionSchema = z
.object({
id: idSchema.optional(),
label: text(60, "Criterion label"),
description: optionalText(300, "Criterion description"),
inputType: z.enum(["blocks", "number"]),
min: number("Min").min(-999999).max(999999),
max: number("Max").min(-999999).max(999999),
step: number("Step").positive("Step must be more than 0.").max(999999),
weight: number("Weight").min(0, "Weight can't be negative.").max(9999),
})
.superRefine((criterion, context) => {
const name = criterion.label || "A criterion";
if (criterion.max <= criterion.min) {
context.addIssue({ code: "custom", message: `${name}: max must be more than min.` });
return;
}
if (!onStep(criterion.min, criterion.max, criterion.step)) {
context.addIssue({
code: "custom",
message: `${name}: the step must divide evenly into the range from min to max.`,
});
return;
}
if (
criterion.inputType === "blocks" &&
(criterion.max - criterion.min) / criterion.step > 10
) {
context.addIssue({
code: "custom",
message: `${name}: blocks can show at most 11 buttons. Use a number input or a bigger step.`,
});
}
});
export const gameSchema = z.object({
title: text(120, "Game title"),
description: optionalText(2000, "Description"),
scoringMode: z.enum(["judged", "direct"]),
pointsMode: z.enum(["raw", "placement"]),
multiplier: number("Multiplier").min(0, "Multiplier can't be negative.").max(9999),
placementPoints: z
.array(z.number().int("Placement points must be whole numbers.").min(0).max(100000))
.min(1, "Add at least one placement value, like 10, 7, 5, 3.")
.max(30),
adminNotes: optionalText(4000, "Admin notes"),
judgeNotes: optionalText(4000, "Judge notes"),
criteria: z.array(criterionSchema).min(1, "A game needs at least one criterion.").max(20),
});
export type CriterionDraft = z.input<typeof criterionSchema>;
export type GameDraft = z.input<typeof gameSchema>;
/** Parses "10, 7, 5, 3" into numbers; invalid parts become NaN so validation reports them. */
export function parsePlacementPoints(value: string): number[] {
return value
.split(/[,\s]+/)
.filter(Boolean)
.map((part) => Number(part));
}
export function firstError(error: z.ZodError): string {
return error.issues[0]?.message ?? "Something in the form isn't valid.";
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse, type NextRequest } from "next/server";
import { isAdminEmail } from "@/lib/admin-emails";
import { updateSession } from "@/lib/supabase/proxy";
// Next.js 16 renamed middleware.ts to proxy.ts.
export async function proxy(request: NextRequest) {
const { response, claims } = await updateSession(request);
const isAdmin = isAdminEmail(claims?.email, process.env.ADMIN_EMAILS);
const { pathname } = request.nextUrl;
if (pathname.startsWith("/admin") && !isAdmin) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.search = claims ? "?error=not-admin" : "";
return NextResponse.redirect(url);
}
if (pathname === "/login" && isAdmin) {
const url = request.nextUrl.clone();
url.pathname = "/admin";
url.search = "";
return NextResponse.redirect(url);
}
return response;
}
// Only admin routes need a session. Scoreboard and judge pages stay out of the
// proxy so they never wait on an auth check.
export const config = {
matcher: ["/admin/:path*", "/login"],
};