brackt/app/routes/leagues/$leagueId.settings.tsx
Chris Parsons 442b392461
Add audit logging for commissioner actions (#293)
Closes #144

* feat: add commissioner audit log for league transparency (issue #144)

Adds a complete audit log system so league members can verify that
settings, draft order, picks, and time banks have not been quietly
changed without their awareness.

Changes:
- database/schema.ts: new `audit_action` enum + `commissioner_audit_log`
  table (seasonId, leagueId, actorClerkId, actorDisplayName, action,
  affectedTeamIds[], details jsonb, createdAt)
- drizzle/0075: generated migration for the new table
- app/models/audit-log.ts: createAuditLogEntry, getAuditLogForSeason
  (paginated), logCommissionerAction (resolves display name automatically)
- app/lib/audit-log-display.ts: shared formatAuditDetail() helper used by
  both the league home widget and the full audit log page
- app/routes/leagues/$leagueId.audit-log.tsx: new read-only route at
  /leagues/:id/audit-log, accessible to all league members, with
  action-type filter and pagination
- app/routes.ts: registers the new route
- League home page ($leagueId.server.ts / $leagueId.tsx): "Recent Activity"
  summary card showing the last 5 entries with "View all" link
- Settings page ($leagueId.settings.tsx): "View Full Audit Log" link card;
  audit log calls added for league/draft settings changes, draft order
  set/randomized, and draft reset
- API routes: audit log calls added to draft.start, draft.pause,
  draft.resume, draft.rollback, draft.adjust-time-bank, draft.force-autopick,
  draft.force-manual-pick, draft.replace-pick
- Tests: 11 new unit tests for the audit-log model; mocks added to 3
  existing route test files to account for the new logCommissionerAction call

https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm

* fix: validate action filter URL param against known enum values

The action filter on the audit log route was cast directly from the URL
search param to AuditAction without validation. An invalid value would
be passed into the Drizzle inArray() call, potentially throwing a
PostgreSQL enum type error. Now validates against the actual enum values
before using the filter.

https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm

* Fix lint errors: use !== instead of != and toSorted instead of sort

https://claude.ai/code/session_01NdiwK2fbtKhAD3XuD58fTm

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 15:45:39 -07:00

1653 lines
64 KiB
TypeScript

import { useState, useEffect } from "react";
import { Form, Link, redirect, useNavigation } from "react-router";
import { getAuth } from "@clerk/react-router/server";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { logger } from "~/lib/logger";
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
import { sendStandingsUpdateNotification } from "~/services/discord";
import {
isCommissioner,
hasCommissionerRecord,
findCommissionersByLeagueId,
createCommissioner,
countCommissionersByLeagueId,
removeCommissionerByLeagueAndUser,
} from "~/models/commissioner";
import { findCurrentSeasonWithSports, updateSeason, type NewSeason } from "~/models/season";
import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam } from "~/models/team";
import { findAllUsers, findUserByClerkId, findUsersByClerkIds, getUserDisplayName, isUserAdminByClerkId } from "~/models/user";
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
import { findDraftableSportsSeasons } from "~/models/sports-season";
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { deleteAllDraftPicks } from "~/models/draft-pick";
import { clearAllQueuesForSeason } from "~/models/draft-queue";
import { deleteSeasonTimers } from "~/models/draft-timer";
import { logCommissionerAction } from "~/models/audit-log";
import { parseDraftSpeed } from "~/lib/draft-timer";
import type { Route } from "./+types/$leagueId.settings";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Calendar } from "~/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { Checkbox } from "~/components/ui/checkbox";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor";
import { toast } from "sonner";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }];
}
export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args);
const { params } = args;
const { leagueId } = params;
if (!userId) {
throw new Response("Unauthorized", { status: 401 });
}
// Fetch league
const league = await findLeagueById(leagueId);
if (!league) {
throw new Response("League not found", { status: 404 });
}
// Check if user is authorized (creator or commissioner)
const userIsCommissioner = await isCommissioner(leagueId, userId);
const isCreator = league.createdBy === userId;
if (!isCreator && !userIsCommissioner) {
throw new Response("Forbidden - You must be a commissioner to access settings", {
status: 403,
});
}
// Get current season with sports and draftable seasons in parallel
const [season, draftableSportsSeasons] = await Promise.all([
findCurrentSeasonWithSports(leagueId),
findDraftableSportsSeasons(),
]);
const teams = season ? await findTeamsBySeasonId(season.id) : [];
// Merge draftable seasons with any already-linked non-draftable seasons so that
// previously-added seasons remain visible and checked in the UI even after being disabled.
const today = new Date().toISOString().slice(0, 10);
const draftableIds = new Set(draftableSportsSeasons.map((s) => s.id));
const linkedButNotDraftable = (season?.seasonSports ?? [])
.map((s) => s.sportsSeason)
.filter((ss) => !draftableIds.has(ss.id) && (ss.draftOff < today || ss.draftOn > today));
const allSportsSeasons = [...draftableSportsSeasons, ...linkedButNotDraftable];
const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : [];
// Count teams with owners
const teamsWithOwners = teams.filter(team => team.ownerId !== null).length;
// Check if user is admin
const isAdmin = await isUserAdminByClerkId(userId);
// Get all users (only for admins - used in team assignment dropdown)
const allUsers = isAdmin ? await findAllUsers() : [];
// Get commissioners for this league with user info
const commissioners = await findCommissionersByLeagueId(leagueId);
// Batch-fetch all users needed for commissioner and owner lookups in one query
const uniqueOwnerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
const commissionerUserIds = commissioners.map((c) => c.userId);
const allUserIds = [...new Set([...commissionerUserIds, ...uniqueOwnerIds])];
const userRows = await findUsersByClerkIds(allUserIds);
const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u]));
const commissionerUserData = commissioners.map((c) => {
const user = userByClerkId.get(c.userId);
return {
...c,
userName: user ? (getUserDisplayName(user) ?? "Unknown User") : "Unknown User",
};
});
const validOwners = uniqueOwnerIds
.map((ownerId) => {
const user = userByClerkId.get(ownerId);
return user ? { clerkId: ownerId, name: getUserDisplayName(user), id: user.id } : null;
})
.filter((o): o is NonNullable<typeof o> => o !== null);
const ownerMap = new Map(validOwners.map((o) => [o.clerkId, o.name]));
// League members (team owners) - available to all commissioners for adding co-commissioners
const leagueMembers = validOwners.map((o) => ({
id: o.id,
clerkId: o.clerkId,
name: o.name,
}));
return {
league,
season,
teams,
teamCount: teams.length,
teamsWithOwners,
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>,
draftSlots,
isAdmin,
allUsers,
leagueMembers,
ownerMap: Object.fromEntries(ownerMap),
commissioners: commissionerUserData,
currentUserId: userId,
};
}
export async function action(args: Route.ActionArgs) {
const { userId } = await getAuth(args);
const { params, request } = args;
const { leagueId } = params;
if (!userId) {
throw new Response("Unauthorized", { status: 401 });
}
// Verify authorization
const league = await findLeagueById(leagueId);
if (!league) {
throw new Response("League not found", { status: 404 });
}
const userIsCommissioner = await isCommissioner(leagueId, userId);
const isCreator = league.createdBy === userId;
if (!isCreator && !userIsCommissioner) {
throw new Response("Forbidden", { status: 403 });
}
const formData = await request.formData();
const intent = formData.get("intent");
// For league-level settings (name, public draft board), save immediately
// before checking season, so they work even if no season exists
if (intent === "update") {
const name = formData.get("name");
const isPublicDraftBoard = formData.get("isPublicDraftBoard") === "on";
const discordWebhookUrl = formData.get("discordWebhookUrl");
if (typeof name !== "string" || !name.trim()) {
return { error: "League name is required" };
}
if (name.trim().length < 3 || name.trim().length > 50) {
return { error: "League name must be between 3 and 50 characters" };
}
const webhookUrl =
typeof discordWebhookUrl === "string" ? discordWebhookUrl.trim() : "";
if (webhookUrl && !webhookUrl.startsWith("https://discord.com/api/webhooks/")) {
return { error: "Discord webhook URL must start with https://discord.com/api/webhooks/" };
}
try {
const changedFields: string[] = [];
if (name.trim() !== league.name) changedFields.push("name");
if (isPublicDraftBoard !== league.isPublicDraftBoard) changedFields.push("isPublicDraftBoard");
if ((webhookUrl || null) !== league.discordWebhookUrl) changedFields.push("discordWebhookUrl");
await updateLeague(leagueId, {
name: name.trim(),
isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null,
});
if (changedFields.length > 0) {
const currentSeason = await findCurrentSeasonWithSports(leagueId);
if (currentSeason) {
await logCommissionerAction({
seasonId: currentSeason.id,
leagueId,
actorClerkId: userId,
action: "league_settings_changed",
details: {
changedFields,
previousValues: {
name: league.name,
isPublicDraftBoard: league.isPublicDraftBoard,
discordWebhookUrl: league.discordWebhookUrl,
},
newValues: {
name: name.trim(),
isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null,
},
},
});
}
}
} catch (error) {
logger.error("Error updating league:", error);
return { error: "Failed to update league. Please try again." };
}
}
if (intent === "test-discord-webhook") {
const webhookUrl = formData.get("webhookUrl");
if (typeof webhookUrl !== "string" || !webhookUrl.startsWith("https://discord.com/api/webhooks/")) {
return { error: "Invalid Discord webhook URL" };
}
try {
const testSeason = await findCurrentSeasonWithSports(leagueId);
const seasonName = testSeason ? `${league.name} ${testSeason.year}` : league.name;
await sendStandingsUpdateNotification({
webhookUrl,
seasonName,
standings: [
{ teamId: "1", teamName: "Team Alpha", totalPoints: 150, rank: 1 },
{ teamId: "2", teamName: "Team Beta", totalPoints: 125, rank: 2 },
{ teamId: "3", teamName: "Team Gamma", totalPoints: 100, rank: 3 },
],
previousStandings: new Map([
["1", 125],
["2", 125],
["3", 100],
]),
previousRanks: new Map([
["1", 2],
["2", 1],
["3", 3],
]),
eventName: "Test Notification",
scoredMatches: [
{ winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" },
],
});
return { testSuccess: true };
} catch (err) {
logger.error("Discord test webhook failed:", err);
return { error: "Failed to send test notification. Check your webhook URL." };
}
}
// Get current season to check status
const season = await findCurrentSeasonWithSports(leagueId);
if (!season) {
if (intent === "update") {
return redirect(`/leagues/${leagueId}?updated=true`);
}
return { error: "No active season found" };
}
if (intent === "update-sports") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify sports after draft has started" };
}
const selectedSports = formData.getAll("sportsSeasons");
const currentSportIds = new Set(
season.seasonSports?.map((s) => s.sportsSeason.id) || []
);
const newSportIds = new Set(selectedSports as string[]);
// Remove sports that are no longer selected
for (const sportId of currentSportIds) {
if (!newSportIds.has(sportId)) {
await unlinkSportFromSeason(season.id, sportId);
}
}
// Add newly selected sports
const sportsToAdd = [];
for (const sportId of newSportIds) {
if (!currentSportIds.has(sportId)) {
sportsToAdd.push({
seasonId: season.id,
sportsSeasonId: sportId as string,
});
}
}
if (sportsToAdd.length > 0) {
await linkMultipleSportsToSeason(sportsToAdd);
}
return { success: true };
}
if (intent === "set-draft-order") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify draft order after draft has started" };
}
const teams = await findTeamsBySeasonId(season.id);
const teamIds = formData.getAll("teamOrder") as string[];
// Validate that all teams are included
if (teamIds.length !== teams.length) {
return { error: "All teams must be included in the draft order" };
}
// Validate that all team IDs are valid
const validTeamIds = new Set(teams.map(t => t.id));
for (const teamId of teamIds) {
if (!validTeamIds.has(teamId)) {
return { error: "Invalid team ID in draft order" };
}
}
await setDraftOrder(season.id, teamIds);
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: userId,
action: "draft_order_set",
affectedTeamIds: teamIds,
details: {
order: teamIds.map((id, i) => ({
teamId: id,
teamName: teams.find((t) => t.id === id)?.name ?? id,
position: i + 1,
})),
},
});
return { success: true, message: "Draft order updated successfully" };
}
if (intent === "randomize-draft-order") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify draft order after draft has started" };
}
const teams = await findTeamsBySeasonId(season.id);
const teamIds = teams.map(t => t.id);
await randomizeDraftOrder(season.id, teamIds);
// Fetch the new order that was just written so we can log it accurately
const newSlots = await findDraftSlotsBySeasonId(season.id);
const sortedSlots = newSlots.toSorted((a, b) => a.draftOrder - b.draftOrder);
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: userId,
action: "draft_order_randomized",
affectedTeamIds: sortedSlots.map((s) => s.teamId),
details: {
order: sortedSlots.map((s) => ({
teamId: s.teamId,
teamName: teams.find((t) => t.id === s.teamId)?.name ?? s.teamId,
position: s.draftOrder,
})),
},
});
return { success: true, message: "Draft order randomized successfully" };
}
if (intent === "remove-team-owner") {
const teamId = formData.get("teamId") as string;
if (!teamId) {
return { error: "Team ID is required" };
}
try {
await removeTeamOwner(teamId);
return { success: true, message: "Owner removed successfully" };
} catch (error) {
logger.error("Error removing team owner:", error);
return { error: "Failed to remove owner. Please try again." };
}
}
if (intent === "assign-team-owner") {
const teamId = formData.get("teamId") as string;
const userClerkId = formData.get("userClerkId") as string;
if (!teamId || !userClerkId) {
return { error: "Team ID and User ID are required" };
}
// Check if user is admin (only admins can assign owners)
const isAdmin = await isUserAdminByClerkId(userId);
if (!isAdmin) {
return { error: "Only admins can assign team owners" };
}
// Look up user before assigning to fail fast and get their name
const assignedUser = await findUserByClerkId(userClerkId);
if (!assignedUser) {
return { error: "User not found. Please try again." };
}
// Check if user is already assigned to a team in this season
const teams = await findTeamsBySeasonId(season.id);
const userAlreadyHasTeam = teams.some(team => team.ownerId === userClerkId);
if (userAlreadyHasTeam) {
return { error: "This user is already assigned to a team in this league" };
}
// Check if the target team already has an owner
const targetTeam = teams.find(team => team.id === teamId);
if (targetTeam?.ownerId) {
return { error: "This team already has an owner. Remove the current owner first." };
}
try {
const teamName = `Team ${getUserDisplayName(assignedUser) ?? "Member"}`;
await claimTeam(teamId, userClerkId, teamName);
return { success: true, message: "Owner assigned successfully" };
} catch (error) {
logger.error("Error assigning team owner:", error);
return { error: "Failed to assign owner. Please try again." };
}
}
if (intent === "reset-draft") {
// Check if user is admin (only admins can reset draft)
const isAdmin = await isUserAdminByClerkId(userId);
if (!isAdmin) {
return { error: "Only admins can reset the draft" };
}
try {
// Delete all draft picks
await deleteAllDraftPicks(season.id);
// Clear all draft queues
await clearAllQueuesForSeason(season.id);
// Delete all draft timers
await deleteSeasonTimers(season.id);
const previousPickNumber = season.currentPickNumber;
// Set season status back to pre_draft and reset draft state (keeps draft order intact)
await updateSeason(season.id, {
status: "pre_draft",
currentPickNumber: null,
draftStartedAt: null,
draftPaused: false,
});
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: userId,
action: "draft_reset",
details: { previousPickNumber },
});
return { success: true, message: "Draft has been reset successfully. Draft order preserved." };
} catch (error) {
logger.error("Error resetting draft:", error);
return { error: "Failed to reset draft. Please try again." };
}
}
if (intent === "delete") {
// Delete the league
await deleteLeague(leagueId);
// Redirect to home with success message
return redirect("/?deleted=true");
}
if (intent === "update") {
const teamCount = formData.get("teamCount");
const draftDateTime = formData.get("draftDateTime");
const draftRounds = formData.get("draftRounds");
const draftSpeed = formData.get("draftSpeed");
const draftTimerMode = formData.get("draftTimerMode") as "chess_clock" | "standard" | null;
// Map draft speed to time values
const { draftInitialTime, draftIncrementTime } = parseDraftSpeed(
draftSpeed as string | null,
draftTimerMode ?? "chess_clock"
);
// League-level fields (name, isPublicDraftBoard) are already saved above.
// Now handle season-specific updates.
try {
// Update season settings
const seasonUpdates: Partial<NewSeason> = {};
// Handle draft rounds
if (typeof draftRounds === "string") {
const draftRoundsNum = parseInt(draftRounds, 10);
if (!isNaN(draftRoundsNum)) {
const sportsCount = season.seasonSports?.length || 0;
if (draftRoundsNum < sportsCount) {
return { error: `Draft rounds must be at least ${sportsCount} (number of sports selected)` };
}
if (draftRoundsNum < 1 || draftRoundsNum > 50) {
return { error: "Draft rounds must be between 1 and 50" };
}
seasonUpdates.draftRounds = draftRoundsNum;
}
}
// Handle draft date/time
if (typeof draftDateTime === "string") {
seasonUpdates.draftDateTime = draftDateTime ? new Date(draftDateTime) : null;
}
// Set draft times from speed preset (only if draftSpeed was submitted;
// the select is disabled during an active draft so it won't be present)
if (draftSpeed !== null) {
seasonUpdates.draftInitialTime = draftInitialTime;
seasonUpdates.draftIncrementTime = draftIncrementTime;
}
// Timer mode (only if submitted; disabled during active draft)
if (draftTimerMode !== null) {
seasonUpdates.draftTimerMode = draftTimerMode;
}
// Handle scoring rules (only if in pre_draft status)
if (season.status === "pre_draft") {
const scoringFields = [
"pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"
];
for (const field of scoringFields) {
const value = formData.get(field);
if (typeof value === "string") {
const points = parseInt(value, 10);
if (!isNaN(points)) {
if (points < 0 || points > 1000) {
return { error: `${field} must be between 0 and 1000 points` };
}
(seasonUpdates as Record<string, unknown>)[field] = points;
}
}
}
}
// Update season if there are changes
if (Object.keys(seasonUpdates).length > 0) {
await updateSeason(season.id, seasonUpdates);
const scoringFields = ["pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"];
const draftFields = Object.keys(seasonUpdates).filter((k) => !scoringFields.includes(k));
const scoringChangedFields = Object.keys(seasonUpdates).filter((k) => scoringFields.includes(k));
if (draftFields.length > 0) {
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: userId,
action: "draft_settings_changed",
details: {
changedFields: draftFields,
newValues: Object.fromEntries(draftFields.map((k) => [k, (seasonUpdates as Record<string, unknown>)[k]])),
},
});
}
if (scoringChangedFields.length > 0) {
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorClerkId: userId,
action: "scoring_rules_changed",
details: {
changedFields: scoringChangedFields,
newValues: Object.fromEntries(scoringChangedFields.map((k) => [k, (seasonUpdates as Record<string, unknown>)[k]])),
},
});
}
}
// Handle team count changes if provided
if (typeof teamCount === "string") {
const newTeamCount = parseInt(teamCount, 10);
if (!isNaN(newTeamCount)) {
const teams = await findTeamsBySeasonId(season.id);
const currentTeamCount = teams.length;
const teamsWithOwners = teams.filter(t => t.ownerId !== null).length;
// Validate team count
if (newTeamCount < 6 || newTeamCount > 16) {
return { error: "Number of teams must be between 6 and 16" };
}
if (newTeamCount < teamsWithOwners) {
return { error: `Cannot reduce team count below ${teamsWithOwners} (number of teams with owners)` };
}
// Add or remove teams as needed
if (newTeamCount > currentTeamCount) {
const teamsToAdd = Array.from(
{ length: newTeamCount - currentTeamCount },
(_, i) => ({
seasonId: season.id,
name: `Team ${currentTeamCount + i + 1}`,
ownerId: null as null,
})
);
// Fetch existing slots before the transaction to determine append position
const existingSlots = await findDraftSlotsBySeasonId(season.id);
const maxOrder = existingSlots.reduce((max, s) => Math.max(max, s.draftOrder), 0);
// Create teams and their draft slots atomically
const db = database();
await db.transaction(async (tx) => {
const newTeams = await tx
.insert(schema.teams)
.values(teamsToAdd)
.returning();
await tx.insert(schema.draftSlots).values(
newTeams.map((team, i) => ({
seasonId: season.id,
teamId: team.id,
draftOrder: maxOrder + i + 1,
}))
);
});
} else if (newTeamCount < currentTeamCount) {
// Remove teams without owners (from the end)
const teamsToRemove = teams
.filter(t => t.ownerId === null)
.slice(-(currentTeamCount - newTeamCount));
for (const team of teamsToRemove) {
await deleteTeam(team.id);
}
}
}
}
return redirect(`/leagues/${leagueId}?updated=true`);
} catch (error) {
logger.error("Error updating season settings:", error);
return { error: "Failed to update season settings. Please try again." };
}
}
if (intent === "add-commissioner") {
const userClerkId = formData.get("userClerkId") as string;
if (!userClerkId) {
return { error: "User is required" };
}
// Check if user already has a commissioner record (don't use isCommissioner — it
// returns true for site admins, causing a false "already a commissioner" error)
const alreadyCommissioner = await hasCommissionerRecord(leagueId, userClerkId);
if (alreadyCommissioner) {
return { error: "This user is already a commissioner" };
}
try {
await createCommissioner({ leagueId, userId: userClerkId });
return { success: true, message: "Commissioner added successfully" };
} catch (error) {
logger.error("Error adding commissioner:", error);
return { error: "Failed to add commissioner. Please try again." };
}
}
if (intent === "remove-commissioner") {
const commissionerUserId = formData.get("commissionerUserId") as string;
if (!commissionerUserId) {
return { error: "User is required" };
}
// Prevent self-removal
if (commissionerUserId === userId) {
return { error: "You cannot remove yourself as a commissioner" };
}
// Prevent removing the last commissioner
const count = await countCommissionersByLeagueId(leagueId);
if (count <= 1) {
return { error: "Cannot remove the last commissioner" };
}
try {
await removeCommissionerByLeagueAndUser(leagueId, commissionerUserId);
return { success: true, message: "Commissioner removed successfully" };
} catch (error) {
logger.error("Error removing commissioner:", error);
return { error: "Failed to remove commissioner. Please try again." };
}
}
return { error: "Invalid action" };
}
export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) {
const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, leagueMembers, ownerMap, commissioners, currentUserId } = loaderData;
const navigation = useNavigation();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock");
const [selectedSports, setSelectedSports] = useState<Set<string>>(
new Set(season?.seasonSports?.map((s) => s.sportsSeason.id) || [])
);
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
draftSlots.length > 0
? draftSlots.map((slot) => slot.teamId)
: teams.map((team) => team.id)
);
const [draftRounds, setDraftRounds] = useState<number>(season?.draftRounds || 20);
const [draftDate, setDraftDate] = useState<Date | undefined>(
season?.draftDateTime ? new Date(season.draftDateTime) : undefined
);
const [draftTime, setDraftTime] = useState<string>(
season?.draftDateTime
? format(new Date(season.draftDateTime), "HH:mm")
: ""
);
// Update draft order when loader data changes (after randomization)
useEffect(() => {
const newOrder = draftSlots.length > 0
? draftSlots.map((slot) => slot.teamId)
: teams.map((team) => team.id);
setDraftOrderTeams(newOrder);
}, [draftSlots, teams]);
const canEditSports = season && season.status === "pre_draft";
const canEditTeamCount = season && season.status === "pre_draft";
const canEditDraftOrder = season && season.status === "pre_draft";
const canEditDraftRounds = season && season.status === "pre_draft";
const minTeamCount = Math.max(6, teamsWithOwners);
// Calculate flex spots and minimum rounds
const sportsCount = selectedSports.size;
const minRounds = sportsCount;
const recommendedRounds = Math.ceil(sportsCount * 1.25);
const flexSpots = Math.max(0, draftRounds - sportsCount);
const handleSportToggle = (sportId: string) => {
const newSelected = new Set(selectedSports);
if (newSelected.has(sportId)) {
newSelected.delete(sportId);
} else {
newSelected.add(sportId);
}
setSelectedSports(newSelected);
};
const handleDelete = () => {
setIsDeleteDialogOpen(false);
// The form submission will handle the actual deletion
};
const moveDraftSlot = (fromIndex: number, toIndex: number) => {
const newOrder = [...draftOrderTeams];
const [movedTeam] = newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, movedTeam);
setDraftOrderTeams(newOrder);
};
const getTeamById = (teamId: string) => {
return teams.find((t) => t.id === teamId);
};
const [copied, setCopied] = useState(false);
const handleCopyInviteLink = async () => {
if (!season?.inviteCode) return;
const inviteUrl = `${window.location.origin}/i/${season.inviteCode}`;
try {
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
toast.success("Invite link copied to clipboard!");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy invite link");
}
};
return (
<div className="container max-w-2xl mx-auto py-8 px-4">
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-4xl font-bold">League Settings</h1>
<Button variant="outline" asChild>
<Link to={`/leagues/${league.id}`}>Back to League</Link>
</Button>
</div>
<p className="text-muted-foreground">{league.name}</p>
</div>
{season?.inviteCode && (
<Card className="mb-6">
<CardHeader>
<CardTitle>Invite Link</CardTitle>
<CardDescription>
Share this link to invite people to join your league
{teamCount - teamsWithOwners > 0 && ` (${teamCount - teamsWithOwners} spot${teamCount - teamsWithOwners !== 1 ? "s" : ""} available)`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={`${typeof window !== "undefined" ? window.location.origin : ""}/i/${season.inviteCode}`}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted"
onClick={(e) => e.currentTarget.select()}
/>
<Button
onClick={handleCopyInviteLink}
variant={copied ? "default" : "outline"}
size="sm"
>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Anyone with this link can join your league
</p>
</CardContent>
</Card>
)}
<Form method="post" className="space-y-6">
<input type="hidden" name="intent" value="update" />
{/* General Settings */}
<Card>
<CardHeader>
<CardTitle>General Settings</CardTitle>
<CardDescription>
Update your league's basic information
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">League Name</Label>
<Input
id="name"
name="name"
type="text"
defaultValue={league.name}
placeholder="Enter league name"
required
minLength={3}
maxLength={50}
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="isPublicDraftBoard"
name="isPublicDraftBoard"
defaultChecked={league.isPublicDraftBoard}
className="h-4 w-4 rounded border-border"
/>
<Label htmlFor="isPublicDraftBoard" className="font-normal cursor-pointer">
Make draft board publicly accessible (no login required)
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="teamCount">Number of Teams</Label>
<Select
name="teamCount"
defaultValue={teamCount.toString()}
disabled={!canEditTeamCount}
>
<SelectTrigger id="teamCount">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 11 }, (_, i) => i + 6).map((num) => (
<SelectItem
key={num}
value={num.toString()}
disabled={num < minTeamCount}
>
{num} Teams
{num < minTeamCount && ` (${teamsWithOwners} teams have owners)`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{canEditTeamCount
? `Can be changed before draft starts (minimum ${minTeamCount} based on registered teams)`
: "Team count cannot be changed after draft has started"}
</p>
</div>
</CardContent>
</Card>
{/* Notifications */}
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>
Get Discord alerts when standings change after scoring events
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="discordWebhookUrl">Discord Webhook URL</Label>
<Input
id="discordWebhookUrl"
name="discordWebhookUrl"
type="url"
defaultValue={league.discordWebhookUrl ?? ""}
placeholder="https://discord.com/api/webhooks/..."
/>
<p className="text-sm text-muted-foreground">
Create a webhook in your Discord server under Server Settings → Integrations → Webhooks.
</p>
</div>
{league.discordWebhookUrl && (
<Form method="post" className="pt-2">
<input type="hidden" name="intent" value="test-discord-webhook" />
<input type="hidden" name="webhookUrl" value={league.discordWebhookUrl} />
<Button type="submit" variant="outline" size="sm">
Send Test Notification
</Button>
</Form>
)}
{actionData && "testSuccess" in actionData && actionData.testSuccess && (
<p className="text-sm text-emerald-600">Test notification sent to Discord!</p>
)}
</CardContent>
</Card>
{/* Draft Rounds */}
<Card>
<CardHeader>
<CardTitle>Draft Rounds</CardTitle>
<CardDescription>
{canEditDraftRounds
? "Set the number of draft rounds for your league"
: "Draft rounds cannot be modified after the draft has started"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="draftRounds">Number of Draft Rounds</Label>
<Select
name="draftRounds"
value={draftRounds.toString()}
onValueChange={(value) => setDraftRounds(parseInt(value))}
disabled={!canEditDraftRounds}
required
>
<SelectTrigger id="draftRounds">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 50 }, (_, i) => i + 1)
.filter(num => num >= minRounds)
.map((num) => (
<SelectItem
key={num}
value={num.toString()}
>
{num} Round{num !== 1 ? 's' : ''}
{num === recommendedRounds && ' (recommended)'}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
Minimum: {minRounds} (number of sports selected)
{recommendedRounds > minRounds && ` • Recommended: ${recommendedRounds}`}
</p>
{sportsCount > 0 && draftRounds < sportsCount && (
<p className="text-sm font-medium text-destructive">
⚠️ You need at least {sportsCount} rounds for the {sportsCount} sports selected
</p>
)}
{sportsCount > 0 && draftRounds >= sportsCount && (
<p className="text-sm font-medium text-primary">
Flex Spots: {flexSpots}
</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="draftDate">Draft Date & Time</Label>
<div className="grid gap-2 sm:grid-cols-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"justify-start text-left font-normal",
!draftDate && "text-muted-foreground"
)}
disabled={!canEditDraftRounds}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{draftDate ? format(draftDate, "PPP") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={draftDate}
onSelect={setDraftDate}
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
/>
</PopoverContent>
</Popover>
<Input
type="time"
value={draftTime}
onChange={(e) => setDraftTime(e.target.value)}
placeholder="Select time"
disabled={!canEditDraftRounds}
/>
</div>
{draftDate && draftTime && (
<input
type="hidden"
name="draftDateTime"
value={new Date(`${format(draftDate, "yyyy-MM-dd")}T${draftTime}`).toISOString()}
/>
)}
{!draftDate && !draftTime && (
<input type="hidden" name="draftDateTime" value="" />
)}
<p className="text-sm text-muted-foreground">
{canEditDraftRounds
? "You must set a draft date and time before starting the draft"
: "Draft date cannot be changed after draft has started"}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="draftTimerMode">Timer Mode</Label>
<select
id="draftTimerMode"
name="draftTimerMode"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={timerMode}
onChange={(e) => setTimerMode(e.target.value as "chess_clock" | "standard")}
disabled={!canEditDraftRounds}
required
>
<option value="chess_clock">Chess Clock (accumulating bank)</option>
<option value="standard">Standard (per-pick countdown)</option>
</select>
<p className="text-sm text-muted-foreground">
Chess Clock: unused time carries over between picks. Standard: timer resets each pick.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="draftSpeed">Draft Speed</Label>
<select
id="draftSpeed"
name="draftSpeed"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
defaultValue={
timerMode === "standard"
? (season?.draftIncrementTime?.toString() ?? "90")
: season?.draftInitialTime === 60 && season?.draftIncrementTime === 10
? "fast"
: season?.draftInitialTime === 120 && season?.draftIncrementTime === 15
? "standard"
: season?.draftInitialTime === 28800 && season?.draftIncrementTime === 3600
? "slow"
: season?.draftInitialTime === 43200 && season?.draftIncrementTime === 3600
? "very-slow"
: "standard"
}
key={timerMode}
disabled={!canEditDraftRounds}
required
>
{timerMode === "standard" ? (
<>
<option value="15">15 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
<option value="90">90 seconds (default)</option>
<option value="120">2 minutes</option>
<option value="900">15 minutes</option>
<option value="3600">1 hour</option>
<option value="7200">2 hours</option>
<option value="14400">4 hours</option>
<option value="28800">8 hours</option>
<option value="43200">12 hours</option>
</>
) : (
<>
<option value="fast">Fast (1 min + 10 sec)</option>
<option value="standard">Standard (2 min + 15 sec)</option>
<option value="slow">Slow (8 hours + 1 hour)</option>
<option value="very-slow">Very Slow (12 hours + 1 hour)</option>
</>
)}
</select>
<p className="text-sm text-muted-foreground">
{timerMode === "standard"
? "Time per pick — resets to this value after each selection"
: "Initial time bank + increment added after each pick"}
</p>
</div>
</CardContent>
</Card>
{/* Scoring Rules */}
<ScoringRulesEditor
scoringRules={season ? {
pointsFor1st: season.pointsFor1st,
pointsFor2nd: season.pointsFor2nd,
pointsFor3rd: season.pointsFor3rd,
pointsFor4th: season.pointsFor4th,
pointsFor5th: season.pointsFor5th,
pointsFor6th: season.pointsFor6th,
pointsFor7th: season.pointsFor7th,
pointsFor8th: season.pointsFor8th,
} : undefined}
disabled={!season || season.status !== "pre_draft"}
/>
{/* Sports Seasons */}
<Card>
<CardHeader>
<CardTitle>Sports Seasons</CardTitle>
<CardDescription>
{canEditSports
? "Select the sports seasons that teams can draft from"
: "Sports cannot be modified after the draft has started"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
<p className="text-sm text-muted-foreground">
<strong>Recommendation:</strong> Select 16-20 sports seasons for optimal league balance and variety.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
Sports Seasons ({selectedSports.size} selected)
</Label>
<div className="max-h-96 overflow-y-auto border rounded-md p-3 space-y-2">
{allSportsSeasons.length > 0 ? (
allSportsSeasons.map((ss) => (
<div key={ss.id} className="flex items-center space-x-2">
<Checkbox
id={ss.id}
name="sportsSeasons"
value={ss.id}
checked={selectedSports.has(ss.id)}
onCheckedChange={() => handleSportToggle(ss.id)}
disabled={!canEditSports}
/>
<Label
htmlFor={ss.id}
className={`font-normal flex-1 ${canEditSports ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}
>
<span className="font-medium">{ss.sport.name}</span> - {ss.name} ({ss.year})
<Badge variant="outline" className="ml-2 text-xs">
{ss.scoringType.replace("_", " ")}
</Badge>
</Label>
</div>
))
) : (
<p className="text-sm text-muted-foreground">
No sports seasons available
</p>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Save Button */}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
{actionData?.success && (
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
Settings updated successfully!
</div>
)}
<Button type="submit" size="lg" className="w-full">
Save All Settings
</Button>
</Form>
<div className="space-y-6">
{/* Draft Order Management */}
<Card id="draft-order">
<CardHeader>
<CardTitle>Draft Order</CardTitle>
<CardDescription>
{canEditDraftOrder
? "Set the draft order for your league. Drag teams to reorder or randomize."
: "Draft order cannot be changed after the draft has started"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{draftSlots.length === 0 && canEditDraftOrder && (
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
<p className="text-sm text-muted-foreground">
<strong>Note:</strong> Draft order has not been set yet. Use the form below to set the order manually or click "Randomize" to generate a random order.
</p>
</div>
)}
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="set-draft-order" />
<div className="space-y-2">
<div className="border rounded-md divide-y">
{draftOrderTeams.map((teamId, index) => {
const team = getTeamById(teamId);
if (!team) return null;
return (
<div key={teamId} className="flex items-center gap-3 p-3">
<input type="hidden" name="teamOrder" value={teamId} />
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary text-primary-foreground font-bold text-sm">
{index + 1}
</div>
<div className="flex-1">
<p className="font-medium">{team.name}</p>
{team.ownerId && (
<p className="text-xs text-muted-foreground">{ownerMap[team.ownerId] ?? "Unknown"}</p>
)}
</div>
{canEditDraftOrder && (
<div className="flex gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => moveDraftSlot(index, Math.max(0, index - 1))}
disabled={index === 0}
>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => moveDraftSlot(index, Math.min(draftOrderTeams.length - 1, index + 1))}
disabled={index === draftOrderTeams.length - 1}
>
</Button>
</div>
)}
</div>
);
})}
</div>
</div>
{canEditDraftOrder && (
<div className="flex gap-2">
<Button type="submit" className="flex-1">
Save Draft Order
</Button>
</div>
)}
{actionData?.success && actionData?.message && (
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
{actionData.message}
</div>
)}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
</Form>
{canEditDraftOrder && (
<Form method="post">
<input type="hidden" name="intent" value="randomize-draft-order" />
<Button
type="submit"
variant="outline"
className="w-full"
disabled={navigation.state === "submitting"}
>
{navigation.state === "submitting" && navigation.formData?.get("intent") === "randomize-draft-order"
? "Randomizing..."
: "🎲 Randomize Draft Order"}
</Button>
</Form>
)}
</div>
</CardContent>
</Card>
{/* Team Management */}
<Card>
<CardHeader>
<CardTitle>Team Management</CardTitle>
<CardDescription>
Manage team ownership for this league
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{teams.map((team) => {
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
return (
<div
key={team.id}
className="flex items-center justify-between p-3 border rounded-md"
>
<div className="flex-1">
<p className="font-medium">{team.name}</p>
<p className="text-sm text-muted-foreground">
{team.ownerId ? (
<span>Owner: {ownerName || "Unknown"}</span>
) : (
<span>No owner</span>
)}
</p>
</div>
<div className="flex gap-2">
{team.ownerId && (
<Form method="post">
<input type="hidden" name="intent" value="remove-team-owner" />
<input type="hidden" name="teamId" value={team.id} />
<Button
type="submit"
variant="outline"
size="sm"
disabled={navigation.state === "submitting"}
>
Remove Owner
</Button>
</Form>
)}
{isAdmin && (
<Form method="post" className="flex gap-2">
<input type="hidden" name="intent" value="assign-team-owner" />
<input type="hidden" name="teamId" value={team.id} />
<Select name="userClerkId" required>
<SelectTrigger className="w-[180px] h-9">
<SelectValue placeholder="Select user" />
</SelectTrigger>
<SelectContent>
{allUsers.map((user) => {
const userOwnsTeam = teams.some((t) => t.ownerId === user.clerkId);
return (
<SelectItem
key={user.id}
value={user.clerkId}
disabled={userOwnsTeam}
>
{user.username || user.email}
{userOwnsTeam && " (already in league)"}
</SelectItem>
);
})}
</SelectContent>
</Select>
<Button
type="submit"
variant="outline"
size="sm"
disabled={navigation.state === "submitting"}
>
Assign
</Button>
</Form>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Commissioner Management */}
<Card>
<CardHeader>
<CardTitle>Commissioner Management</CardTitle>
<CardDescription>
Commissioners can manage league settings and oversee the draft. They do not need to own a team.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{actionData?.error && !actionData?.success && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
{actionData?.success && actionData?.message && (
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
{actionData.message}
</div>
)}
<div className="space-y-3">
{commissioners.map((commissioner) => (
<div
key={commissioner.id}
className="flex items-center justify-between p-3 border rounded-md"
>
<div className="flex-1">
<p className="font-medium">
{commissioner.userName}
{commissioner.userId === currentUserId && (
<span className="text-xs text-muted-foreground ml-2">(you)</span>
)}
</p>
{!teams.some((t) => t.ownerId === commissioner.userId) && (
<p className="text-xs text-muted-foreground">No team in current season</p>
)}
</div>
{commissioners.length > 1 && commissioner.userId !== currentUserId && (
<Form method="post">
<input type="hidden" name="intent" value="remove-commissioner" />
<input type="hidden" name="commissionerUserId" value={commissioner.userId} />
<Button
type="submit"
variant="outline"
size="sm"
disabled={navigation.state === "submitting"}
>
Remove
</Button>
</Form>
)}
</div>
))}
</div>
<div className="border-t pt-4">
<p className="text-sm font-medium mb-3">Add Commissioner</p>
<Form method="post" className="flex gap-2">
<input type="hidden" name="intent" value="add-commissioner" />
<Select name="userClerkId" required>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select a user" />
</SelectTrigger>
<SelectContent>
{leagueMembers.map((member) => {
const alreadyCommissioner = commissioners.some(
(c) => c.userId === member.clerkId
);
return (
<SelectItem
key={member.id}
value={member.clerkId}
disabled={alreadyCommissioner}
>
{member.name || "Unknown"}
{alreadyCommissioner && " (already commissioner)"}
</SelectItem>
);
})}
</SelectContent>
</Select>
<Button
type="submit"
variant="outline"
size="sm"
disabled={navigation.state === "submitting"}
>
Add
</Button>
</Form>
</div>
</CardContent>
</Card>
{/* Audit Log */}
<Card>
<CardHeader>
<CardTitle>Audit Log</CardTitle>
<CardDescription>
View the full history of commissioner actions for this season. All league members can see this log.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" asChild>
<Link to={`/leagues/${league.id}/audit-log`}>
View Full Audit Log
</Link>
</Button>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Irreversible and destructive actions
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Reset Draft - Admin Only */}
{isAdmin && season && (
<div className="space-y-2">
<h3 className="font-medium">Reset Draft</h3>
<p className="text-sm text-muted-foreground">
Delete all draft picks, queues, and timers, and reset the season to pre-draft status. The draft order will be preserved.
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
Reset Draft
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset the draft?</AlertDialogTitle>
<AlertDialogDescription>
This will delete all draft picks, draft queues, and draft timers, and set the season back to pre-draft status.
The draft order will remain intact. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Form method="post">
<input type="hidden" name="intent" value="reset-draft" />
<AlertDialogAction type="submit" className="bg-destructive hover:bg-destructive/90">
Reset Draft
</AlertDialogAction>
</Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
{/* Delete League */}
<div className="space-y-2">
<h3 className="font-medium">Delete League</h3>
<p className="text-sm text-muted-foreground">
Permanently delete this league and all associated data.
</p>
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
Delete League
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete <strong>{league.name}</strong> and all
associated data including seasons, teams, and commissioners. This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Form method="post" onSubmit={handleDelete}>
<input type="hidden" name="intent" value="delete" />
<AlertDialogAction type="submit" className="bg-destructive hover:bg-destructive/90">
Delete League
</AlertDialogAction>
</Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
</div>
</div>
);
}