Migrate auth from Clerk to BetterAuth (#354)

* 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>
This commit is contained in:
Chris Parsons 2026-04-29 10:03:50 -07:00 committed by GitHub
parent e201ecd28a
commit 5268e07365
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 5543 additions and 85 deletions

View file

@ -41,7 +41,7 @@ const activityEntries: AuditLogEntry[] = [
id: "log-1",
seasonId: "season-1",
leagueId: "league-abc-123",
actorClerkId: "user-alice",
actorUserId: "user-alice",
actorDisplayName: "Alice Johnson",
action: "draft_order_randomized",
affectedTeamIds: null,
@ -52,7 +52,7 @@ const activityEntries: AuditLogEntry[] = [
id: "log-2",
seasonId: "season-1",
leagueId: "league-abc-123",
actorClerkId: "user-alice",
actorUserId: "user-alice",
actorDisplayName: "Alice Johnson",
action: "draft_settings_changed",
affectedTeamIds: null,
@ -63,7 +63,7 @@ const activityEntries: AuditLogEntry[] = [
id: "log-3",
seasonId: "season-1",
leagueId: "league-abc-123",
actorClerkId: "user-bob",
actorUserId: "user-bob",
actorDisplayName: "Bob Smith",
action: "league_settings_changed",
affectedTeamIds: null,

View file

@ -104,7 +104,7 @@ export function CommissionersPanel({
</span>
<span className="min-w-0">
<span className="font-medium">
{entry.actorDisplayName ?? entry.actorClerkId}
{entry.actorDisplayName ?? entry.actorUserId}
</span>
{" — "}
<span className="text-muted-foreground">

View file

@ -23,16 +23,21 @@ export const auth = betterAuth({
provider: "pg",
usePlural: true,
}),
advanced: {
database: {
generateId: false,
},
},
user: {
fields: {
name: "display_name",
image: "image_url",
name: "displayName",
image: "imageUrl",
},
additionalFields: {
firstName: { type: "string", required: false, fieldName: "first_name" },
lastName: { type: "string", required: false, fieldName: "last_name" },
firstName: { type: "string", required: false, fieldName: "firstName" },
lastName: { type: "string", required: false, fieldName: "lastName" },
username: { type: "string", required: false, fieldName: "username" },
isAdmin: { type: "boolean", defaultValue: false, fieldName: "is_admin" },
isAdmin: { type: "boolean", defaultValue: false, fieldName: "isAdmin" },
},
},
emailAndPassword: {

View file

@ -25,7 +25,7 @@ const SAMPLE_ENTRY = {
id: "entry-1",
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_USER_ID,
actorUserId: ACTOR_USER_ID,
actorDisplayName: "Alice",
action: "draft_rollback" as const,
affectedTeamIds: ["team-1"],
@ -99,7 +99,7 @@ describe("createAuditLogEntry", () => {
const result = await createAuditLogEntry({
seasonId: SEASON_ID,
leagueId: LEAGUE_ID,
actorClerkId: ACTOR_USER_ID,
actorUserId: ACTOR_USER_ID,
actorDisplayName: "Alice",
action: "draft_rollback",
affectedTeamIds: ["team-1"],
@ -209,7 +209,7 @@ describe("logCommissionerAction", () => {
expect(insertValues.actorDisplayName).toBe("alice");
});
it("falls back to actorClerkId if the user is not found", async () => {
it("falls back to actorUserId if the user is not found", async () => {
vi.mocked(findUserById).mockResolvedValue(undefined);
vi.mocked(getUserDisplayName).mockReturnValue(null);

View file

@ -13,7 +13,7 @@ import { isUserAdmin } from "~/models/user";
import { database } from "~/database/context";
const LEAGUE_ID = "league-1";
const USER_ID = "user-clerk-1";
const USER_ID = "user-uuid-1";
function makeMockDb(commissionerRow: object | null) {
return {

View file

@ -78,7 +78,7 @@ export async function logCommissionerAction(params: {
await createAuditLogEntry({
seasonId: params.seasonId,
leagueId: params.leagueId,
actorClerkId: params.actorUserId,
actorUserId: params.actorUserId,
actorDisplayName,
action: params.action,
affectedTeamIds: params.affectedTeamIds ?? [],

View file

@ -52,6 +52,8 @@ export default [
route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
route("login", "routes/login.tsx"),
route("register", "routes/register.tsx"),
route("forgot-password", "routes/forgot-password.tsx"),
route("reset-password", "routes/reset-password.tsx"),
route("user-profile", "routes/user-profile.tsx"),
route("how-to-play", "routes/how-to-play.tsx"),
route("rules", "routes/rules.tsx"),

View file

@ -0,0 +1,80 @@
import { useState } from "react";
import { Link } from "react-router";
import { authClient } from "~/lib/auth-client";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
export function meta() {
return [{ title: "Forgot Password - Brackt" }];
}
export default function ForgotPasswordPage() {
const [error, setError] = useState<string | null>(null);
const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const email = (e.currentTarget.elements.namedItem("email") as HTMLInputElement).value;
const { error: resetError } = await authClient.requestPasswordReset({
email,
redirectTo: "/reset-password",
});
if (resetError) {
setError(resetError.message ?? "Something went wrong. Please try again.");
setLoading(false);
} else {
setSubmitted(true);
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl">Reset your password</CardTitle>
<CardDescription>
Enter your email and we&apos;ll send you a reset link
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{submitted ? (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Check your email for a password reset link. It may take a minute to arrive.
</p>
<p className="text-center text-sm text-muted-foreground">
<Link to="/login" className="underline">
Back to sign in
</Link>
</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" required autoComplete="email" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Sending…" : "Send reset link"}
</Button>
<p className="text-center text-sm text-muted-foreground">
Remember your password?{" "}
<Link to="/login" className="underline">
Sign in
</Link>
</p>
</form>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -10,8 +10,8 @@ import {
claimTeam,
isUserLeagueMember,
findUserById,
getUserDisplayName,
} from "~/models";
import { prependOwnerToTeamName } from "~/utils/team-names";
import { Button } from "~/components/ui/button";
import {
Card,
@ -105,7 +105,8 @@ export async function action(args: Route.ActionArgs) {
// Claim the first available team, setting owner and name atomically
const team = availableTeams[0];
const teamName = `Team ${getUserDisplayName(user) ?? "Member"}`;
const username = user.username ?? user.displayName ?? "Member";
const teamName = prependOwnerToTeamName(team.name, username);
await claimTeam(team.id, userId, teamName);
// Redirect to league page

View file

@ -150,7 +150,7 @@ function AuditLogRow({ entry, teamMap }: AuditLogRowProps) {
{format(new Date(entry.createdAt), "MMM d, yyyy HH:mm")}
</TableCell>
<TableCell className="text-sm font-medium">
{entry.actorDisplayName ?? entry.actorClerkId}
{entry.actorDisplayName ?? entry.actorUserId}
</TableCell>
<TableCell>
<Badge variant={getActionBadgeVariant(entry.action)}>

View file

@ -15,8 +15,9 @@ import {
removeCommissionerByLeagueAndUser,
} from "~/models/commissioner";
import { findCurrentSeasonWithSports, updateSeason, type NewSeason } from "~/models/season";
import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam } from "~/models/team";
import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam, findTeamById, renameTeam } from "~/models/team";
import { findAllUsers, findUsersByIds, findUserById, getUserDisplayName, isUserAdmin } from "~/models/user";
import { prependOwnerToTeamName, stripOwnerFromTeamName, generateUniqueTeamNames } from "~/utils/team-names";
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
import { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season";
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
@ -400,7 +401,22 @@ export async function action(args: Route.ActionArgs) {
}
try {
await removeTeamOwner(teamId);
const team = await findTeamById(teamId);
if (team?.ownerId) {
const owner = await findUserById(team.ownerId);
const username = owner?.username ?? owner?.displayName;
if (username) {
const strippedName = stripOwnerFromTeamName(team.name, username);
if (strippedName !== team.name) {
await renameTeam(teamId, strippedName);
}
await removeTeamOwner(teamId);
} else {
await removeTeamOwner(teamId);
}
} else {
await removeTeamOwner(teamId);
}
return { success: true, message: "Owner removed successfully" };
} catch (error) {
logger.error("Error removing team owner:", error);
@ -438,12 +454,18 @@ export async function action(args: Route.ActionArgs) {
// Check if the target team already has an owner
const targetTeam = teams.find(team => team.id === teamId);
if (targetTeam?.ownerId) {
if (!targetTeam) {
return { error: "Team not found" };
}
if (targetTeam.ownerId) {
return { error: "This team already has an owner. Remove the current owner first." };
}
try {
const teamName = `Team ${getUserDisplayName(assignedUser) ?? "Member"}`;
const teamName = prependOwnerToTeamName(
targetTeam.name,
assignedUser.username ?? assignedUser.displayName ?? "Member"
);
await claimTeam(teamId, assignedUserId, teamName);
return { success: true, message: "Owner assigned successfully" };
@ -718,11 +740,12 @@ export async function action(args: Route.ActionArgs) {
// Add or remove teams as needed
if (newTeamCount > currentTeamCount) {
const newNames = generateUniqueTeamNames(newTeamCount - currentTeamCount);
const teamsToAdd = Array.from(
{ length: newTeamCount - currentTeamCount },
(_, i) => ({
seasonId: season.id,
name: `Team ${currentTeamCount + i + 1}`,
name: newNames[i],
ownerId: null as null,
})
);

View file

@ -88,7 +88,7 @@ The test suite covers:
- ✅ Handle removing owner from team with no owner
- ✅ Handle assigning owner to team that already has owner
- ✅ Handle invalid team ID gracefully
- ✅ Handle invalid user clerk ID gracefully
- ✅ Handle invalid user ID gracefully
- ✅ Proper error messages
### 6. Integration Scenarios (3 tests)
@ -168,7 +168,7 @@ The test suite covers:
The tests use Vitest's mocking capabilities to:
- Mock database operations (`removeTeamOwner`, `assignTeamOwner`)
- Mock user authentication (`isUserAdminByClerkId`)
- Mock user authentication (`isUserAdmin`)
- Mock user data fetching (`findAllUsers`)
- Simulate various success and error scenarios

View file

@ -112,26 +112,26 @@ describe('Team Management - Assign Owner', () => {
it('should successfully assign an owner to a team', async () => {
const teamId = 'team-1';
const userClerkId = 'user-clerk-123';
const mockTeam = createMockTeam({ id: teamId, ownerId: userClerkId, name: 'Team 1' });
const userId = 'user-uuid-123';
const mockTeam = createMockTeam({ id: teamId, ownerId: userId, name: 'Team 1' });
vi.mocked(assignTeamOwner).mockResolvedValue(mockTeam);
const result = await assignTeamOwner(teamId, userClerkId);
const result = await assignTeamOwner(teamId, userId);
expect(assignTeamOwner).toHaveBeenCalledWith(teamId, userClerkId);
expect(result.ownerId).toBe(userClerkId);
expect(assignTeamOwner).toHaveBeenCalledWith(teamId, userId);
expect(result.ownerId).toBe(userId);
expect(result.id).toBe(teamId);
});
it('should handle errors when assigning owner fails', async () => {
const teamId = 'team-1';
const userClerkId = 'user-clerk-123';
const userId = 'user-uuid-123';
const error = new Error('Database error');
vi.mocked(assignTeamOwner).mockRejectedValue(error);
await expect(assignTeamOwner(teamId, userClerkId)).rejects.toThrow('Database error');
await expect(assignTeamOwner(teamId, userId)).rejects.toThrow('Database error');
});
it('should only allow admins to assign owners', async () => {
@ -159,17 +159,17 @@ describe('Team Management - Assign Owner', () => {
it('should replace existing owner when assigning new owner', async () => {
const teamId = 'team-1';
const oldOwnerClerkId = 'user-clerk-old';
const newOwnerClerkId = 'user-clerk-new';
const oldOwnerId = 'user-uuid-old';
const newOwnerId = 'user-uuid-new';
const mockTeam = createMockTeam({ id: teamId, ownerId: newOwnerClerkId, name: 'Team 1' });
const mockTeam = createMockTeam({ id: teamId, ownerId: newOwnerId, name: 'Team 1' });
vi.mocked(assignTeamOwner).mockResolvedValue(mockTeam);
const result = await assignTeamOwner(teamId, newOwnerClerkId);
const result = await assignTeamOwner(teamId, newOwnerId);
expect(result.ownerId).toBe(newOwnerClerkId);
expect(result.ownerId).not.toBe(oldOwnerClerkId);
expect(result.ownerId).toBe(newOwnerId);
expect(result.ownerId).not.toBe(oldOwnerId);
});
});
@ -324,16 +324,16 @@ describe('Team Management - Edge Cases', () => {
});
it('should prevent assigning user who already owns a team in the league', async () => {
const userClerkId = 'user-clerk-1';
const userId = 'user-uuid-1';
// Simulate that user already owns team-1
const existingTeams = [
createMockTeam({ id: 'team-1', ownerId: userClerkId, name: 'Team 1' }),
createMockTeam({ id: 'team-1', ownerId: userId, name: 'Team 1' }),
createMockTeam({ id: 'team-2', ownerId: null, name: 'Team 2' }),
];
// Check if user already has a team
const userAlreadyHasTeam = existingTeams.some(team => team.ownerId === userClerkId);
const userAlreadyHasTeam = existingTeams.some(team => team.ownerId === userId);
expect(userAlreadyHasTeam).toBe(true);
@ -356,14 +356,14 @@ describe('Team Management - Edge Cases', () => {
it('should handle assigning owner to team that already has owner', async () => {
const teamId = 'team-1';
const newOwnerClerkId = 'user-clerk-new';
const mockTeam = createMockTeam({ id: teamId, ownerId: newOwnerClerkId, name: 'Team 1' });
const newOwnerId = 'user-uuid-new';
const mockTeam = createMockTeam({ id: teamId, ownerId: newOwnerId, name: 'Team 1' });
vi.mocked(assignTeamOwner).mockResolvedValue(mockTeam);
const result = await assignTeamOwner(teamId, newOwnerClerkId);
const result = await assignTeamOwner(teamId, newOwnerId);
expect(result.ownerId).toBe(newOwnerClerkId);
expect(result.ownerId).toBe(newOwnerId);
});
it('should handle invalid team ID gracefully', async () => {
@ -375,14 +375,14 @@ describe('Team Management - Edge Cases', () => {
await expect(removeTeamOwner(invalidTeamId)).rejects.toThrow('Team not found');
});
it('should handle invalid user clerk ID gracefully', async () => {
it('should handle invalid user ID gracefully', async () => {
const teamId = 'team-1';
const invalidUserClerkId = 'invalid-clerk-id';
const invalidUserId = 'invalid-user-id';
const error = new Error('User not found');
vi.mocked(assignTeamOwner).mockRejectedValue(error);
await expect(assignTeamOwner(teamId, invalidUserClerkId)).rejects.toThrow('User not found');
await expect(assignTeamOwner(teamId, invalidUserId)).rejects.toThrow('User not found');
});
});
@ -393,14 +393,14 @@ describe('Team Management - Integration Scenarios', () => {
it('should handle complete workflow: assign then remove owner', async () => {
const teamId = 'team-1';
const userClerkId = 'user-clerk-123';
const userId = 'user-uuid-123';
// First, assign owner
const assignedTeam = createMockTeam({ id: teamId, ownerId: userClerkId, name: 'Team 1' });
const assignedTeam = createMockTeam({ id: teamId, ownerId: userId, name: 'Team 1' });
vi.mocked(assignTeamOwner).mockResolvedValue(assignedTeam);
const assignResult = await assignTeamOwner(teamId, userClerkId);
expect(assignResult.ownerId).toBe(userClerkId);
const assignResult = await assignTeamOwner(teamId, userId);
expect(assignResult.ownerId).toBe(userId);
// Then, remove owner
const removedTeam = createMockTeam({ id: teamId, ownerId: null, name: 'Team 1' });
@ -412,23 +412,23 @@ describe('Team Management - Integration Scenarios', () => {
it('should handle reassigning owner from one user to another', async () => {
const teamId = 'team-1';
const firstUserClerkId = 'user-clerk-1';
const secondUserClerkId = 'user-clerk-2';
const firstUserId = 'user-uuid-1';
const secondUserId = 'user-uuid-2';
// Assign first owner
const firstAssignment = createMockTeam({ id: teamId, ownerId: firstUserClerkId, name: 'Team 1' });
const firstAssignment = createMockTeam({ id: teamId, ownerId: firstUserId, name: 'Team 1' });
vi.mocked(assignTeamOwner).mockResolvedValueOnce(firstAssignment);
const firstResult = await assignTeamOwner(teamId, firstUserClerkId);
expect(firstResult.ownerId).toBe(firstUserClerkId);
const firstResult = await assignTeamOwner(teamId, firstUserId);
expect(firstResult.ownerId).toBe(firstUserId);
// Reassign to second owner
const secondAssignment = createMockTeam({ id: teamId, ownerId: secondUserClerkId, name: 'Team 1' });
const secondAssignment = createMockTeam({ id: teamId, ownerId: secondUserId, name: 'Team 1' });
vi.mocked(assignTeamOwner).mockResolvedValueOnce(secondAssignment);
const secondResult = await assignTeamOwner(teamId, secondUserClerkId);
expect(secondResult.ownerId).toBe(secondUserClerkId);
expect(secondResult.ownerId).not.toBe(firstUserClerkId);
const secondResult = await assignTeamOwner(teamId, secondUserId);
expect(secondResult.ownerId).toBe(secondUserId);
expect(secondResult.ownerId).not.toBe(firstUserId);
});
it('should handle multiple teams with different ownership states', async () => {

View file

@ -13,7 +13,7 @@ import { createManyTeams } from "~/models/team";
import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template";
import { findDraftableSportsSeasons } from "~/models/sports-season";
import { findUserById, updateUser } from "~/models/user";
import { generateUniqueTeamNames } from "~/utils/team-names";
import { generateUniqueTeamNames, prependOwnerToTeamName } from "~/utils/team-names";
import { format } from "date-fns";
import { CalendarClock, Check, Star, Swords, Trophy } from "lucide-react";
import { Badge } from "~/components/ui/badge";
@ -247,9 +247,13 @@ export async function action(args: Route.ActionArgs) {
const joinAsPlayer = formData.get("joinAsPlayer") === "on";
const teamNames = generateUniqueTeamNames(teamCountNum);
const creator = await findUserById(userId);
const creatorUsername = creator?.username ?? creator?.displayName;
const teams = teamNames.map((teamName, i) => ({
seasonId: season.id,
name: teamName,
name: joinAsPlayer && i === 0 && creatorUsername
? prependOwnerToTeamName(teamName, creatorUsername)
: teamName,
ownerId: joinAsPlayer && i === 0 ? userId : null,
}));

View file

@ -107,6 +107,12 @@ export default function LoginPage() {
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
<Link to="/forgot-password" className="underline">
Forgot password?
</Link>
</p>
<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link to={`/register?redirectTo=${encodeURIComponent(redirectTo)}`} className="underline">

View file

@ -51,6 +51,9 @@ export default function RegisterPage() {
if (signUpError) {
setError(signUpError.message ?? "Registration failed. Please try again.");
setLoading(false);
} else {
// BetterAuth redirects via callbackURL; fallback in case it doesn't fire
window.location.href = redirectTo;
}
}

View file

@ -0,0 +1,97 @@
import { useState } from "react";
import { Link, redirect, useSearchParams } from "react-router";
import { auth } from "~/lib/auth.server";
import { authClient } from "~/lib/auth-client";
import type { Route } from "./+types/reset-password";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
export function meta(): Route.MetaDescriptors {
return [{ title: "Reset Password - Brackt" }];
}
export async function loader(args: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
if (session) return redirect("/");
const token = new URL(args.request.url).searchParams.get("token");
if (!token) return redirect("/forgot-password");
return {};
}
export default function ResetPasswordPage() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token") ?? ""; // loader redirects to /forgot-password when absent
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
const form = e.currentTarget;
const newPassword = (form.elements.namedItem("password") as HTMLInputElement).value;
const confirm = (form.elements.namedItem("confirm") as HTMLInputElement).value;
if (newPassword !== confirm) {
setError("Passwords do not match.");
return;
}
setLoading(true);
const { error: resetError } = await authClient.resetPassword({ newPassword, token });
if (resetError) {
setError(resetError.message ?? "Reset failed. The link may have expired.");
setLoading(false);
} else {
window.location.href = "/login";
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl">Choose a new password</CardTitle>
<CardDescription>Enter a new password for your account</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">New password</Label>
<Input
id="password"
name="password"
type="password"
required
minLength={8}
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm">Confirm password</Label>
<Input
id="confirm"
name="confirm"
type="password"
required
minLength={8}
autoComplete="new-password"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Saving…" : "Set new password"}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
<Link to="/login" className="underline">
Back to sign in
</Link>
</p>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,6 +1,6 @@
export const mockUser = {
id: 'user-1',
clerkId: 'clerk-user-1',
id: 'user-uuid-1',
clerkId: null,
email: 'user1@example.com',
emailVerified: true,
username: 'user1',
@ -9,13 +9,14 @@ export const mockUser = {
lastName: 'One',
imageUrl: null,
isAdmin: false,
timezone: null,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
};
export const mockAdminUser = {
id: 'admin-1',
clerkId: 'clerk-admin-1',
id: 'admin-uuid-1',
clerkId: null,
email: 'admin@example.com',
emailVerified: true,
username: 'admin',
@ -24,6 +25,7 @@ export const mockAdminUser = {
lastName: 'User',
imageUrl: null,
isAdmin: true,
timezone: null,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
};
@ -31,8 +33,8 @@ export const mockAdminUser = {
export const mockUsers = [
mockUser,
{
id: 'user-2',
clerkId: 'clerk-user-2',
id: 'user-uuid-2',
clerkId: null,
email: 'user2@example.com',
emailVerified: true,
username: 'user2',
@ -41,12 +43,13 @@ export const mockUsers = [
lastName: 'Two',
imageUrl: null,
isAdmin: false,
timezone: null,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
},
{
id: 'user-3',
clerkId: 'clerk-user-3',
id: 'user-uuid-3',
clerkId: null,
email: 'user3@example.com',
emailVerified: true,
username: 'user3',
@ -55,6 +58,7 @@ export const mockUsers = [
lastName: 'Three',
imageUrl: null,
isAdmin: false,
timezone: null,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
},

View file

@ -75,3 +75,39 @@ export function generateUniqueTeamNames(count: number): string[] {
return Array.from(names);
}
function toTitleCase(str: string): string {
if (!str) return str;
return str.charAt(0).toUpperCase() + str.slice(1);
}
function possessiveSuffix(name: string): string {
return name.endsWith("s") ? "'" : "'s";
}
export function prependOwnerToTeamName(
baseName: string,
username: string
): string {
const titleName = toTitleCase(username);
return `${titleName}${possessiveSuffix(titleName)} ${baseName}`;
}
// Relies on the name having been set via prependOwnerToTeamName (same toTitleCase logic).
// If username casing differs from when the name was set, the strip is a safe no-op.
export function stripOwnerFromTeamName(
teamName: string,
username: string
): string {
const titleName = toTitleCase(username);
const prefixWithS = `${titleName}'s `;
const prefixWithoutS = `${titleName}' `;
if (teamName.startsWith(prefixWithS)) {
return teamName.slice(prefixWithS.length);
}
if (teamName.startsWith(prefixWithoutS)) {
return teamName.slice(prefixWithoutS.length);
}
return teamName;
}

View file

@ -3,7 +3,7 @@ import { relations, sql } from "drizzle-orm";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
clerkId: varchar("clerk_id", { length: 255 }).unique(), // nullable: new users won't have one
clerkId: varchar("clerk_id", { length: 255 }).unique(), // legacy: only populated for users migrated from Clerk; new users are null
email: varchar("email", { length: 255 }).notNull(),
emailVerified: boolean("email_verified").notNull().default(false),
username: varchar("username", { length: 255 }),
@ -19,7 +19,7 @@ export const users = pgTable("users", {
// BetterAuth session/account/verification tables
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
@ -30,7 +30,7 @@ export const sessions = pgTable("sessions", {
});
export const accounts = pgTable("accounts", {
id: text("id").primaryKey(),
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
@ -47,7 +47,7 @@ export const accounts = pgTable("accounts", {
});
export const verifications = pgTable("verifications", {
id: text("id").primaryKey(),
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
@ -159,7 +159,7 @@ export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [
export const leagues = pgTable("leagues", {
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 255 }).notNull(),
createdBy: varchar("created_by", { length: 255 }).notNull(), // Clerk user ID
createdBy: varchar("created_by", { length: 255 }).notNull(), // UUID → users.id
currentSeasonId: uuid("current_season_id"), // References the active season
isPublicDraftBoard: boolean("is_public_draft_board").notNull().default(false),
discordWebhookUrl: text("discord_webhook_url"),
@ -220,7 +220,7 @@ export const teams = pgTable("teams", {
.references(() => seasons.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
logoUrl: varchar("logo_url", { length: 512 }),
ownerId: varchar("owner_id", { length: 255 }), // Clerk user ID, nullable
ownerId: varchar("owner_id", { length: 255 }), // UUID → users.id, nullable
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
@ -252,7 +252,7 @@ export const draftPicks = pgTable("draft_picks", {
pickNumber: integer("pick_number").notNull(),
round: integer("round").notNull(),
pickInRound: integer("pick_in_round").notNull(),
pickedByUserId: varchar("picked_by_user_id", { length: 255 }).notNull(), // Clerk user ID
pickedByUserId: varchar("picked_by_user_id", { length: 255 }).notNull(), // UUID → users.id
pickedByType: pickedByTypeEnum("picked_by_type").notNull(),
timeUsed: integer("time_used").notNull().default(0), // team's time bank (seconds) at the moment the pick was made
createdAt: timestamp("created_at").defaultNow().notNull(),
@ -309,7 +309,7 @@ export const commissioners = pgTable("commissioners", {
leagueId: uuid("league_id")
.notNull()
.references(() => leagues.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).notNull(), // Clerk user ID
userId: varchar("user_id", { length: 255 }).notNull(), // UUID → users.id
createdAt: timestamp("created_at").defaultNow().notNull(),
});
@ -1324,7 +1324,7 @@ export const commissionerAuditLog = pgTable("commissioner_audit_log", {
leagueId: uuid("league_id")
.notNull()
.references(() => leagues.id, { onDelete: "cascade" }),
actorClerkId: varchar("actor_clerk_id", { length: 255 }).notNull(),
actorUserId: varchar("actor_user_id", { length: 255 }).notNull(),
actorDisplayName: varchar("actor_display_name", { length: 255 }),
action: auditActionEnum("action").notNull(),
affectedTeamIds: text("affected_team_ids").array(),

View file

@ -1,7 +1,94 @@
# Authentication
- Auth is handled by Clerk (`@clerk/react-router`)
- User records are synced from Clerk via webhook at `routes/api/webhooks/clerk.ts`
- Ownership validation: check `ownerId` field against the Clerk user ID (`clerkId`)
- Admin access: `isAdmin` boolean on the users table; all `/admin` routes enforce this
- League commissioners: stored separately in the `commissioners` table (not via `isAdmin`)
Auth is handled by [BetterAuth](https://better-auth.com) (`better-auth` package).
## Key Files
| File | Purpose |
|---|---|
| `app/lib/auth.server.ts` | BetterAuth instance — providers, DB adapter, field mapping |
| `app/lib/auth-client.ts` | Browser-side auth client (exported as `authClient`) |
| `app/routes/api.auth.$.ts` | Catches all `/api/auth/*` requests and delegates to BetterAuth |
| `app/lib/auth.ts` | Server helper: `requireLeagueAccess()` |
## Reading the Session
In any route loader or action:
```ts
import { auth } from "~/lib/auth.server";
const session = await auth.api.getSession({ headers: request.headers });
const userId = session?.user.id ?? null; // UUID string
```
`session` is `null` when the user is not logged in.
## User Fields
BetterAuth's `user` object on the session exposes these fields:
| Session field | DB column |
|---|---|
| `session.user.id` | `users.id` (UUID) |
| `session.user.name` | `users.display_name` (mapped as `displayName`) |
| `session.user.email` | `users.email` |
| `session.user.image` | `users.image_url` (mapped as `imageUrl`) |
Additional fields (`firstName`, `lastName`, `username`, `isAdmin`) are stored in `users` and accessible via DB lookup but are NOT on the session object by default.
## Admin Access
`isAdmin` is a boolean on the `users` table. All `/admin` routes check it:
```ts
const isAdmin = session ? await isUserAdmin(session.user.id) : false;
```
Do **not** rely on `session.user.isAdmin` — always re-read from the DB to pick up changes without requiring a new login.
## Ownership Validation
Ownership is stored as a user UUID in FK columns (`teams.owner_id`, `leagues.created_by`, etc.). Compare against `session.user.id`:
```ts
if (team.ownerId !== userId) throw new Response("Forbidden", { status: 403 });
```
## League Commissioner Access
Commissioners are stored in the `commissioners` table. Use `requireLeagueAccess()` from `app/lib/auth.ts` to enforce that the current user is either a commissioner or a team owner:
```ts
await requireLeagueAccess(args, { leagueId, seasonId, db });
```
## Auth Routes
| Route | Purpose |
|---|---|
| `/login` | Email/password sign-in + Google/Discord OAuth |
| `/register` | Email/password sign-up + OAuth |
| `/forgot-password` | Request a password reset email |
| `/reset-password` | Enter new password via reset token (link sent by email via Resend) |
Password reset emails are sent via Resend from `noreply@brackt.com`. The reset link points to `/reset-password?token=…` using `BETTER_AUTH_URL` as the base.
## OAuth Providers
Google and Discord are conditionally enabled when env vars are present:
```
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET
```
Account linking is enabled — users who sign in with OAuth get their accounts linked if the email matches an existing email/password account (`trustedProviders: ["google", "discord"]`).
## Environment Variables
```
BETTER_AUTH_SECRET # required — random secret for signing sessions
BETTER_AUTH_URL # required — app base URL, e.g. https://brackt.com
RESEND_API_KEY # required for password reset emails
```

View file

@ -6,7 +6,7 @@
|---|---|
| **League** | Top-level container; has one or more commissioners |
| **Season** | One iteration of a league. Status: `pre_draft``draft``active``completed` |
| **Team** | Belongs to a season; owned by a user (`ownerId` = Clerk user ID) |
| **Team** | Belongs to a season; owned by a user (`ownerId` = UUID → `users.id`) |
| **Draft Slot** | Defines draft order for teams in a season |
| **Draft Pick** | Record of each pick made |
| **Draft Queue** | A team's pre-ordered wish list of participants |

View file

@ -0,0 +1 @@
ALTER TABLE "commissioner_audit_log" RENAME COLUMN "actor_clerk_id" TO "actor_user_id";

File diff suppressed because it is too large Load diff

View file

@ -589,6 +589,13 @@
"when": 1777180952144,
"tag": "0083_fearless_leader",
"breakpoints": true
},
{
"idx": 84,
"version": "7",
"when": 1777477826949,
"tag": "0084_wooden_retro_girl",
"breakpoints": true
}
]
}