Migrate authentication from Clerk to BetterAuth (#324)
* Migrate authentication from Clerk to BetterAuth (#322)
Replaces @clerk/react-router with self-hosted better-auth to eliminate
the external Clerk dependency and keep all user/session data in our own
PostgreSQL database.
**What changed**
- New: auth.server.ts (BetterAuth config w/ Drizzle adapter, bcrypt, Resend), auth-client.ts, api.auth.$.ts handler
- New: /login and /register pages with email+password and Google/Discord OAuth; open-redirect guard on redirectTo param
- New: UserMenu component replacing Clerk's UserButton
- Schema: sessions, accounts, verifications tables; emailVerified column; clerkId made nullable
- Migrations 0081 (BetterAuth tables) and 0082 (accounts extra columns for v1.6.9)
- All ~30 route files: getAuth → auth.api.getSession, isUserAdminByClerkId → isUserAdmin
- root.tsx: isAdmin read directly from session.user.isAdmin (no extra DB query)
- useDraftAuthRecovery: removed Clerk JWT refresh logic; replaced with cookie-session check
- models/user.ts: removed findUserByClerkId, findOrCreateUser, updateUserByClerkId (webhook pattern)
- Deleted: app/routes/api/webhooks/clerk.ts; uninstalled @clerk/react-router, @clerk/themes, svix
- scripts/migrate.mjs: extended with idempotent Clerk → BetterAuth data migration (FK conversion, email_verified, OAuth accounts)
- scripts/migrate-clerk-passwords.mjs: one-time script to import bcrypt hashes from Clerk CSV export
- BETTERAUTH_MIGRATION.md: dev and production runbooks
- All test mocks updated: vi.mock('~/lib/auth.server') instead of @clerk/react-router/server
- Test fixtures: added emailVerified field
**Follow-up (post-stable)**
- Rename actor_clerk_id column → actor_user_id in commissioner_audit_log
- Drop clerk_id column from users once migration confirmed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add .npmrc with legacy-peer-deps for better-auth/drizzle peer dep conflict
better-auth@1.6.9 declares peerOptional deps on drizzle-orm ^0.45.2 and
drizzle-kit >=0.31.4, but we run drizzle-orm ~0.36.3 / drizzle-kit ~0.28.1.
The adapter works correctly at runtime with our versions — the peer dep is
only for stricter type checking. This unblocks npm ci in CI without a risky
drizzle major-version upgrade.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 22:00:49 -07:00
|
|
|
import { auth } from "~/lib/auth.server";
|
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 18:45:39 -04:00
|
|
|
import { format } from "date-fns";
|
|
|
|
|
import { Link, Form, useSearchParams } from "react-router";
|
|
|
|
|
import type { Route } from "./+types/$leagueId.audit-log";
|
|
|
|
|
import { Button } from "~/components/ui/button";
|
|
|
|
|
import { Badge } from "~/components/ui/badge";
|
|
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
CardContent,
|
|
|
|
|
CardDescription,
|
|
|
|
|
CardHeader,
|
|
|
|
|
CardTitle,
|
|
|
|
|
} from "~/components/ui/card";
|
|
|
|
|
import {
|
|
|
|
|
Table,
|
|
|
|
|
TableBody,
|
|
|
|
|
TableCell,
|
|
|
|
|
TableHead,
|
|
|
|
|
TableHeader,
|
|
|
|
|
TableRow,
|
|
|
|
|
} from "~/components/ui/table";
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "~/components/ui/select";
|
|
|
|
|
import { findLeagueById, isCommissioner, isUserLeagueMember } from "~/models";
|
|
|
|
|
import { findCurrentSeasonWithSports } from "~/models/season";
|
|
|
|
|
import { findTeamsBySeasonId } from "~/models/team";
|
|
|
|
|
import { getAuditLogForSeason, type AuditAction, type AuditLogEntry } from "~/models/audit-log";
|
|
|
|
|
import { AUDIT_ACTION_LABELS, formatAuditDetail } from "~/lib/audit-log-display";
|
|
|
|
|
import * as schema from "~/database/schema";
|
|
|
|
|
|
|
|
|
|
const VALID_AUDIT_ACTIONS = new Set<string>(schema.auditActionEnum.enumValues);
|
|
|
|
|
|
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
|
|
|
return [{ title: `Audit Log — ${data?.league?.name ?? "League"} - Brackt` }];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const PAGE_SIZE = 50;
|
|
|
|
|
|
|
|
|
|
export async function loader(args: Route.LoaderArgs) {
|
Migrate authentication from Clerk to BetterAuth (#324)
* Migrate authentication from Clerk to BetterAuth (#322)
Replaces @clerk/react-router with self-hosted better-auth to eliminate
the external Clerk dependency and keep all user/session data in our own
PostgreSQL database.
**What changed**
- New: auth.server.ts (BetterAuth config w/ Drizzle adapter, bcrypt, Resend), auth-client.ts, api.auth.$.ts handler
- New: /login and /register pages with email+password and Google/Discord OAuth; open-redirect guard on redirectTo param
- New: UserMenu component replacing Clerk's UserButton
- Schema: sessions, accounts, verifications tables; emailVerified column; clerkId made nullable
- Migrations 0081 (BetterAuth tables) and 0082 (accounts extra columns for v1.6.9)
- All ~30 route files: getAuth → auth.api.getSession, isUserAdminByClerkId → isUserAdmin
- root.tsx: isAdmin read directly from session.user.isAdmin (no extra DB query)
- useDraftAuthRecovery: removed Clerk JWT refresh logic; replaced with cookie-session check
- models/user.ts: removed findUserByClerkId, findOrCreateUser, updateUserByClerkId (webhook pattern)
- Deleted: app/routes/api/webhooks/clerk.ts; uninstalled @clerk/react-router, @clerk/themes, svix
- scripts/migrate.mjs: extended with idempotent Clerk → BetterAuth data migration (FK conversion, email_verified, OAuth accounts)
- scripts/migrate-clerk-passwords.mjs: one-time script to import bcrypt hashes from Clerk CSV export
- BETTERAUTH_MIGRATION.md: dev and production runbooks
- All test mocks updated: vi.mock('~/lib/auth.server') instead of @clerk/react-router/server
- Test fixtures: added emailVerified field
**Follow-up (post-stable)**
- Rename actor_clerk_id column → actor_user_id in commissioner_audit_log
- Drop clerk_id column from users once migration confirmed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add .npmrc with legacy-peer-deps for better-auth/drizzle peer dep conflict
better-auth@1.6.9 declares peerOptional deps on drizzle-orm ^0.45.2 and
drizzle-kit >=0.31.4, but we run drizzle-orm ~0.36.3 / drizzle-kit ~0.28.1.
The adapter works correctly at runtime with our versions — the peer dep is
only for stricter type checking. This unblocks npm ci in CI without a risky
drizzle major-version upgrade.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 22:00:49 -07:00
|
|
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
|
|
|
|
const userId = session?.user.id ?? null;
|
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 18:45:39 -04:00
|
|
|
const { params, request } = args;
|
|
|
|
|
const { leagueId } = params;
|
|
|
|
|
|
|
|
|
|
if (!userId) {
|
|
|
|
|
throw new Response("You must be logged in to view this page", {
|
|
|
|
|
status: 401,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const league = await findLeagueById(leagueId);
|
|
|
|
|
if (!league) {
|
|
|
|
|
throw new Response("League not found", { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [userIsCommissioner, userIsMember] = await Promise.all([
|
|
|
|
|
isCommissioner(leagueId, userId),
|
|
|
|
|
isUserLeagueMember(leagueId, userId),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if (!userIsCommissioner && !userIsMember) {
|
|
|
|
|
throw new Response("You do not have access to this league", {
|
|
|
|
|
status: 403,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const season = await findCurrentSeasonWithSports(leagueId);
|
|
|
|
|
|
|
|
|
|
if (!season) {
|
|
|
|
|
return {
|
|
|
|
|
league,
|
|
|
|
|
season: null,
|
|
|
|
|
auditLog: { entries: [] as AuditLogEntry[], total: 0, hasMore: false },
|
|
|
|
|
teams: [] as Array<{ id: string; name: string }>,
|
|
|
|
|
page: 0,
|
|
|
|
|
isUserCommissioner: userIsCommissioner,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const searchParams = new URL(request.url).searchParams;
|
|
|
|
|
const page = Math.max(0, parseInt(searchParams.get("page") ?? "0", 10) || 0);
|
|
|
|
|
const rawAction = searchParams.get("action");
|
|
|
|
|
const actionFilter: AuditAction | null =
|
|
|
|
|
rawAction && VALID_AUDIT_ACTIONS.has(rawAction) ? (rawAction as AuditAction) : null;
|
|
|
|
|
|
|
|
|
|
const [auditLog, teams] = await Promise.all([
|
|
|
|
|
getAuditLogForSeason(season.id, {
|
|
|
|
|
limit: PAGE_SIZE,
|
|
|
|
|
offset: page * PAGE_SIZE,
|
|
|
|
|
actions: actionFilter ? [actionFilter] : undefined,
|
|
|
|
|
}),
|
|
|
|
|
findTeamsBySeasonId(season.id),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
league,
|
|
|
|
|
season,
|
|
|
|
|
auditLog,
|
|
|
|
|
teams: teams.map((t) => ({ id: t.id, name: t.name })),
|
|
|
|
|
page,
|
|
|
|
|
isUserCommissioner: userIsCommissioner,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getActionBadgeVariant(
|
|
|
|
|
action: string
|
|
|
|
|
): "default" | "secondary" | "destructive" | "outline" {
|
|
|
|
|
if (action === "draft_reset" || action === "draft_rollback") {
|
|
|
|
|
return "destructive";
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
action === "draft_started" ||
|
|
|
|
|
action === "draft_paused" ||
|
|
|
|
|
action === "draft_resumed"
|
|
|
|
|
) {
|
|
|
|
|
return "secondary";
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
action === "league_settings_changed" ||
|
|
|
|
|
action === "draft_settings_changed" ||
|
2026-04-14 21:41:05 -07:00
|
|
|
action === "scoring_rules_changed" ||
|
|
|
|
|
action === "sports_changed"
|
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 18:45:39 -04:00
|
|
|
) {
|
|
|
|
|
return "outline";
|
|
|
|
|
}
|
|
|
|
|
return "default";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AuditLogRowProps {
|
|
|
|
|
entry: AuditLogEntry;
|
|
|
|
|
teamMap: Map<string, string>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function AuditLogRow({ entry, teamMap }: AuditLogRowProps) {
|
|
|
|
|
const affectedTeamNames =
|
|
|
|
|
entry.affectedTeamIds && entry.affectedTeamIds.length > 0
|
|
|
|
|
? entry.affectedTeamIds
|
|
|
|
|
.map((id) => teamMap.get(id) ?? id)
|
|
|
|
|
.join(", ")
|
|
|
|
|
: "—";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
|
|
|
|
{format(new Date(entry.createdAt), "MMM d, yyyy HH:mm")}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="text-sm font-medium">
|
|
|
|
|
{entry.actorDisplayName ?? entry.actorClerkId}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
<Badge variant={getActionBadgeVariant(entry.action)}>
|
|
|
|
|
{AUDIT_ACTION_LABELS[entry.action] ?? entry.action}
|
|
|
|
|
</Badge>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
|
|
|
{affectedTeamNames}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="text-sm text-muted-foreground max-w-xs">
|
|
|
|
|
{formatAuditDetail(entry)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function AuditLogPage({ loaderData }: Route.ComponentProps) {
|
|
|
|
|
const { league, season, auditLog, teams, page, isUserCommissioner } =
|
|
|
|
|
loaderData;
|
|
|
|
|
const [searchParams] = useSearchParams();
|
|
|
|
|
const actionFilter = searchParams.get("action") ?? "";
|
|
|
|
|
|
|
|
|
|
const teamMap = new Map(teams.map((t) => [t.id, t.name]));
|
|
|
|
|
const totalPages = Math.ceil(auditLog.total / PAGE_SIZE);
|
|
|
|
|
|
|
|
|
|
function buildPageUrl(newPage: number) {
|
|
|
|
|
const p = new URLSearchParams(searchParams);
|
|
|
|
|
p.set("page", String(newPage));
|
|
|
|
|
return `?${p.toString()}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="container max-w-5xl mx-auto py-8 px-4">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="flex items-center justify-between mb-6">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-bold">Audit Log</h1>
|
|
|
|
|
<p className="text-muted-foreground">{league.name}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
{isUserCommissioner && (
|
|
|
|
|
<Button variant="outline" asChild>
|
|
|
|
|
<Link to={`/leagues/${league.id}/settings`}>Settings</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button variant="outline" asChild>
|
|
|
|
|
<Link to={`/leagues/${league.id}`}>Back to League</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!season ? (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardContent className="py-8 text-center text-muted-foreground">
|
|
|
|
|
No season found for this league.
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{/* Filter bar */}
|
|
|
|
|
<Card className="mb-4">
|
|
|
|
|
<CardContent className="py-4">
|
|
|
|
|
<Form method="get" className="flex gap-3 items-center">
|
|
|
|
|
<Select name="action" defaultValue={actionFilter || ""}>
|
|
|
|
|
<SelectTrigger className="w-[220px]">
|
|
|
|
|
<SelectValue placeholder="All actions" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="">All actions</SelectItem>
|
|
|
|
|
{Object.entries(AUDIT_ACTION_LABELS).map(
|
|
|
|
|
([value, label]) => (
|
|
|
|
|
<SelectItem key={value} value={value}>
|
|
|
|
|
{label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<Button type="submit" size="sm">
|
|
|
|
|
Filter
|
|
|
|
|
</Button>
|
|
|
|
|
{actionFilter && (
|
|
|
|
|
<Button variant="outline" size="sm" asChild>
|
|
|
|
|
<Link to={`/leagues/${league.id}/audit-log`}>
|
|
|
|
|
Clear filter
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</Form>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Results table */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle>Commissioner Actions</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
{auditLog.total === 0
|
|
|
|
|
? "No entries found"
|
|
|
|
|
: `${auditLog.total} ${auditLog.total === 1 ? "entry" : "entries"}`}
|
|
|
|
|
{totalPages > 1 &&
|
|
|
|
|
` — page ${page + 1} of ${totalPages}`}
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{auditLog.entries.length === 0 ? (
|
|
|
|
|
<p className="text-center text-muted-foreground py-8">
|
|
|
|
|
No activity recorded yet.
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead>Time</TableHead>
|
|
|
|
|
<TableHead>Commissioner</TableHead>
|
|
|
|
|
<TableHead>Action</TableHead>
|
|
|
|
|
<TableHead>Teams Affected</TableHead>
|
|
|
|
|
<TableHead>Details</TableHead>
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{auditLog.entries.map((entry) => (
|
|
|
|
|
<AuditLogRow
|
|
|
|
|
key={entry.id}
|
|
|
|
|
entry={entry}
|
|
|
|
|
teamMap={teamMap}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Pagination */}
|
|
|
|
|
{totalPages > 1 && (
|
|
|
|
|
<div className="flex gap-2 mt-4 justify-center">
|
|
|
|
|
{page > 0 && (
|
|
|
|
|
<Button variant="outline" size="sm" asChild>
|
|
|
|
|
<Link to={buildPageUrl(page - 1)}>Previous</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<span className="flex items-center text-sm text-muted-foreground px-2">
|
|
|
|
|
Page {page + 1} of {totalPages}
|
|
|
|
|
</span>
|
|
|
|
|
{auditLog.hasMore && (
|
|
|
|
|
<Button variant="outline" size="sm" asChild>
|
|
|
|
|
<Link to={buildPageUrl(page + 1)}>Next</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|