* Fix BetterAuth field mapping and add owner-prefixed team names - Fix auth.server.ts: use camelCase Drizzle field names for BetterAuth adapter (was snake_case, causing user inserts to fail); add generateId: "uuid" so PostgreSQL UUID columns accept generated IDs - Add prependOwnerToTeamName / stripOwnerFromTeamName utilities - Invite join, league creation, and settings assign/remove all now manage the owner-prefix on team names atomically Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Complete BetterAuth migration: auth flows, rename, and cleanup - Add /forgot-password and /reset-password pages (full password reset flow) - Add 'Forgot password?' link on login page - Fix register.tsx: add explicit window.location fallback after signUp - Rename commissionerAuditLog.actorClerkId → actorUserId (migration 0084) and update all references in model, routes, components, and tests - Rewrite docs/agents/auth.md to document BetterAuth (removes all Clerk refs) - Update test fixtures and schema comments to remove Clerk ID references; use UUID-format IDs throughout test data - Update docs/agents/domain-models.md ownerId description Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix BetterAuth ID generation, clean up review issues - Set generateId: false so BetterAuth omits id from inserts; add $defaultFn(crypto.randomUUID) to accounts/sessions/verifications so Drizzle fills it in (fixes null id constraint violation on signup) - Move renameTeam to static import; rename before removeTeamOwner so a failed rename leaves owner intact rather than leaving a stale prefix - Clarify stripOwnerFromTeamName toTitleCase assumption in comment - Annotate reset-password token fallback to explain loader guarantee Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
310 lines
9.7 KiB
TypeScript
310 lines
9.7 KiB
TypeScript
import { auth } from "~/lib/auth.server";
|
|
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) {
|
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
|
const userId = session?.user.id ?? null;
|
|
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" ||
|
|
action === "scoring_rules_changed" ||
|
|
action === "sports_changed"
|
|
) {
|
|
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.actorUserId}
|
|
</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>
|
|
);
|
|
}
|