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:
parent
e201ecd28a
commit
5268e07365
25 changed files with 5543 additions and 85 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
80
app/routes/forgot-password.tsx
Normal file
80
app/routes/forgot-password.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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't have an account?{" "}
|
||||
<Link to={`/register?redirectTo=${encodeURIComponent(redirectTo)}`} className="underline">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
97
app/routes/reset-password.tsx
Normal file
97
app/routes/reset-password.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
app/test/fixtures/user.ts
vendored
20
app/test/fixtures/user.ts
vendored
|
|
@ -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'),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
1
drizzle/0084_wooden_retro_girl.sql
Normal file
1
drizzle/0084_wooden_retro_girl.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "commissioner_audit_log" RENAME COLUMN "actor_clerk_id" TO "actor_user_id";
|
||||
5102
drizzle/meta/0084_snapshot.json
Normal file
5102
drizzle/meta/0084_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue