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>
This commit is contained in:
parent
d2282a263e
commit
7e8c5afde5
20 changed files with 5457 additions and 68 deletions
|
|
@ -41,7 +41,7 @@ const activityEntries: AuditLogEntry[] = [
|
||||||
id: "log-1",
|
id: "log-1",
|
||||||
seasonId: "season-1",
|
seasonId: "season-1",
|
||||||
leagueId: "league-abc-123",
|
leagueId: "league-abc-123",
|
||||||
actorClerkId: "user-alice",
|
actorUserId: "user-alice",
|
||||||
actorDisplayName: "Alice Johnson",
|
actorDisplayName: "Alice Johnson",
|
||||||
action: "draft_order_randomized",
|
action: "draft_order_randomized",
|
||||||
affectedTeamIds: null,
|
affectedTeamIds: null,
|
||||||
|
|
@ -52,7 +52,7 @@ const activityEntries: AuditLogEntry[] = [
|
||||||
id: "log-2",
|
id: "log-2",
|
||||||
seasonId: "season-1",
|
seasonId: "season-1",
|
||||||
leagueId: "league-abc-123",
|
leagueId: "league-abc-123",
|
||||||
actorClerkId: "user-alice",
|
actorUserId: "user-alice",
|
||||||
actorDisplayName: "Alice Johnson",
|
actorDisplayName: "Alice Johnson",
|
||||||
action: "draft_settings_changed",
|
action: "draft_settings_changed",
|
||||||
affectedTeamIds: null,
|
affectedTeamIds: null,
|
||||||
|
|
@ -63,7 +63,7 @@ const activityEntries: AuditLogEntry[] = [
|
||||||
id: "log-3",
|
id: "log-3",
|
||||||
seasonId: "season-1",
|
seasonId: "season-1",
|
||||||
leagueId: "league-abc-123",
|
leagueId: "league-abc-123",
|
||||||
actorClerkId: "user-bob",
|
actorUserId: "user-bob",
|
||||||
actorDisplayName: "Bob Smith",
|
actorDisplayName: "Bob Smith",
|
||||||
action: "league_settings_changed",
|
action: "league_settings_changed",
|
||||||
affectedTeamIds: null,
|
affectedTeamIds: null,
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ export function CommissionersPanel({
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{entry.actorDisplayName ?? entry.actorClerkId}
|
{entry.actorDisplayName ?? entry.actorUserId}
|
||||||
</span>
|
</span>
|
||||||
{" — "}
|
{" — "}
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ const SAMPLE_ENTRY = {
|
||||||
id: "entry-1",
|
id: "entry-1",
|
||||||
seasonId: SEASON_ID,
|
seasonId: SEASON_ID,
|
||||||
leagueId: LEAGUE_ID,
|
leagueId: LEAGUE_ID,
|
||||||
actorClerkId: ACTOR_USER_ID,
|
actorUserId: ACTOR_USER_ID,
|
||||||
actorDisplayName: "Alice",
|
actorDisplayName: "Alice",
|
||||||
action: "draft_rollback" as const,
|
action: "draft_rollback" as const,
|
||||||
affectedTeamIds: ["team-1"],
|
affectedTeamIds: ["team-1"],
|
||||||
|
|
@ -99,7 +99,7 @@ describe("createAuditLogEntry", () => {
|
||||||
const result = await createAuditLogEntry({
|
const result = await createAuditLogEntry({
|
||||||
seasonId: SEASON_ID,
|
seasonId: SEASON_ID,
|
||||||
leagueId: LEAGUE_ID,
|
leagueId: LEAGUE_ID,
|
||||||
actorClerkId: ACTOR_USER_ID,
|
actorUserId: ACTOR_USER_ID,
|
||||||
actorDisplayName: "Alice",
|
actorDisplayName: "Alice",
|
||||||
action: "draft_rollback",
|
action: "draft_rollback",
|
||||||
affectedTeamIds: ["team-1"],
|
affectedTeamIds: ["team-1"],
|
||||||
|
|
@ -209,7 +209,7 @@ describe("logCommissionerAction", () => {
|
||||||
expect(insertValues.actorDisplayName).toBe("alice");
|
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(findUserById).mockResolvedValue(undefined);
|
||||||
vi.mocked(getUserDisplayName).mockReturnValue(null);
|
vi.mocked(getUserDisplayName).mockReturnValue(null);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { isUserAdmin } from "~/models/user";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
|
|
||||||
const LEAGUE_ID = "league-1";
|
const LEAGUE_ID = "league-1";
|
||||||
const USER_ID = "user-clerk-1";
|
const USER_ID = "user-uuid-1";
|
||||||
|
|
||||||
function makeMockDb(commissionerRow: object | null) {
|
function makeMockDb(commissionerRow: object | null) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ export async function logCommissionerAction(params: {
|
||||||
await createAuditLogEntry({
|
await createAuditLogEntry({
|
||||||
seasonId: params.seasonId,
|
seasonId: params.seasonId,
|
||||||
leagueId: params.leagueId,
|
leagueId: params.leagueId,
|
||||||
actorClerkId: params.actorUserId,
|
actorUserId: params.actorUserId,
|
||||||
actorDisplayName,
|
actorDisplayName,
|
||||||
action: params.action,
|
action: params.action,
|
||||||
affectedTeamIds: params.affectedTeamIds ?? [],
|
affectedTeamIds: params.affectedTeamIds ?? [],
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ export default [
|
||||||
route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
|
route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
|
||||||
route("login", "routes/login.tsx"),
|
route("login", "routes/login.tsx"),
|
||||||
route("register", "routes/register.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("user-profile", "routes/user-profile.tsx"),
|
||||||
route("how-to-play", "routes/how-to-play.tsx"),
|
route("how-to-play", "routes/how-to-play.tsx"),
|
||||||
route("rules", "routes/rules.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -150,7 +150,7 @@ function AuditLogRow({ entry, teamMap }: AuditLogRowProps) {
|
||||||
{format(new Date(entry.createdAt), "MMM d, yyyy HH:mm")}
|
{format(new Date(entry.createdAt), "MMM d, yyyy HH:mm")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm font-medium">
|
<TableCell className="text-sm font-medium">
|
||||||
{entry.actorDisplayName ?? entry.actorClerkId}
|
{entry.actorDisplayName ?? entry.actorUserId}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={getActionBadgeVariant(entry.action)}>
|
<Badge variant={getActionBadgeVariant(entry.action)}>
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ The test suite covers:
|
||||||
- ✅ Handle removing owner from team with no owner
|
- ✅ Handle removing owner from team with no owner
|
||||||
- ✅ Handle assigning owner to team that already has owner
|
- ✅ Handle assigning owner to team that already has owner
|
||||||
- ✅ Handle invalid team ID gracefully
|
- ✅ Handle invalid team ID gracefully
|
||||||
- ✅ Handle invalid user clerk ID gracefully
|
- ✅ Handle invalid user ID gracefully
|
||||||
- ✅ Proper error messages
|
- ✅ Proper error messages
|
||||||
|
|
||||||
### 6. Integration Scenarios (3 tests)
|
### 6. Integration Scenarios (3 tests)
|
||||||
|
|
@ -168,7 +168,7 @@ The test suite covers:
|
||||||
|
|
||||||
The tests use Vitest's mocking capabilities to:
|
The tests use Vitest's mocking capabilities to:
|
||||||
- Mock database operations (`removeTeamOwner`, `assignTeamOwner`)
|
- Mock database operations (`removeTeamOwner`, `assignTeamOwner`)
|
||||||
- Mock user authentication (`isUserAdminByClerkId`)
|
- Mock user authentication (`isUserAdmin`)
|
||||||
- Mock user data fetching (`findAllUsers`)
|
- Mock user data fetching (`findAllUsers`)
|
||||||
- Simulate various success and error scenarios
|
- 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 () => {
|
it('should successfully assign an owner to a team', async () => {
|
||||||
const teamId = 'team-1';
|
const teamId = 'team-1';
|
||||||
const userClerkId = 'user-clerk-123';
|
const userId = 'user-uuid-123';
|
||||||
const mockTeam = createMockTeam({ id: teamId, ownerId: userClerkId, name: 'Team 1' });
|
const mockTeam = createMockTeam({ id: teamId, ownerId: userId, name: 'Team 1' });
|
||||||
|
|
||||||
vi.mocked(assignTeamOwner).mockResolvedValue(mockTeam);
|
vi.mocked(assignTeamOwner).mockResolvedValue(mockTeam);
|
||||||
|
|
||||||
const result = await assignTeamOwner(teamId, userClerkId);
|
const result = await assignTeamOwner(teamId, userId);
|
||||||
|
|
||||||
expect(assignTeamOwner).toHaveBeenCalledWith(teamId, userClerkId);
|
expect(assignTeamOwner).toHaveBeenCalledWith(teamId, userId);
|
||||||
expect(result.ownerId).toBe(userClerkId);
|
expect(result.ownerId).toBe(userId);
|
||||||
expect(result.id).toBe(teamId);
|
expect(result.id).toBe(teamId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle errors when assigning owner fails', async () => {
|
it('should handle errors when assigning owner fails', async () => {
|
||||||
const teamId = 'team-1';
|
const teamId = 'team-1';
|
||||||
const userClerkId = 'user-clerk-123';
|
const userId = 'user-uuid-123';
|
||||||
const error = new Error('Database error');
|
const error = new Error('Database error');
|
||||||
|
|
||||||
vi.mocked(assignTeamOwner).mockRejectedValue(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 () => {
|
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 () => {
|
it('should replace existing owner when assigning new owner', async () => {
|
||||||
const teamId = 'team-1';
|
const teamId = 'team-1';
|
||||||
const oldOwnerClerkId = 'user-clerk-old';
|
const oldOwnerId = 'user-uuid-old';
|
||||||
const newOwnerClerkId = 'user-clerk-new';
|
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);
|
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);
|
||||||
expect(result.ownerId).not.toBe(oldOwnerClerkId);
|
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 () => {
|
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
|
// Simulate that user already owns team-1
|
||||||
const existingTeams = [
|
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' }),
|
createMockTeam({ id: 'team-2', ownerId: null, name: 'Team 2' }),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Check if user already has a team
|
// 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);
|
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 () => {
|
it('should handle assigning owner to team that already has owner', async () => {
|
||||||
const teamId = 'team-1';
|
const teamId = 'team-1';
|
||||||
const newOwnerClerkId = 'user-clerk-new';
|
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);
|
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 () => {
|
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');
|
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 teamId = 'team-1';
|
||||||
const invalidUserClerkId = 'invalid-clerk-id';
|
const invalidUserId = 'invalid-user-id';
|
||||||
const error = new Error('User not found');
|
const error = new Error('User not found');
|
||||||
|
|
||||||
vi.mocked(assignTeamOwner).mockRejectedValue(error);
|
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 () => {
|
it('should handle complete workflow: assign then remove owner', async () => {
|
||||||
const teamId = 'team-1';
|
const teamId = 'team-1';
|
||||||
const userClerkId = 'user-clerk-123';
|
const userId = 'user-uuid-123';
|
||||||
|
|
||||||
// First, assign owner
|
// 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);
|
vi.mocked(assignTeamOwner).mockResolvedValue(assignedTeam);
|
||||||
const assignResult = await assignTeamOwner(teamId, userClerkId);
|
const assignResult = await assignTeamOwner(teamId, userId);
|
||||||
expect(assignResult.ownerId).toBe(userClerkId);
|
expect(assignResult.ownerId).toBe(userId);
|
||||||
|
|
||||||
// Then, remove owner
|
// Then, remove owner
|
||||||
const removedTeam = createMockTeam({ id: teamId, ownerId: null, name: 'Team 1' });
|
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 () => {
|
it('should handle reassigning owner from one user to another', async () => {
|
||||||
const teamId = 'team-1';
|
const teamId = 'team-1';
|
||||||
const firstUserClerkId = 'user-clerk-1';
|
const firstUserId = 'user-uuid-1';
|
||||||
const secondUserClerkId = 'user-clerk-2';
|
const secondUserId = 'user-uuid-2';
|
||||||
|
|
||||||
// Assign first owner
|
// 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);
|
vi.mocked(assignTeamOwner).mockResolvedValueOnce(firstAssignment);
|
||||||
const firstResult = await assignTeamOwner(teamId, firstUserClerkId);
|
const firstResult = await assignTeamOwner(teamId, firstUserId);
|
||||||
expect(firstResult.ownerId).toBe(firstUserClerkId);
|
expect(firstResult.ownerId).toBe(firstUserId);
|
||||||
|
|
||||||
// Reassign to second owner
|
// 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);
|
vi.mocked(assignTeamOwner).mockResolvedValueOnce(secondAssignment);
|
||||||
const secondResult = await assignTeamOwner(teamId, secondUserClerkId);
|
const secondResult = await assignTeamOwner(teamId, secondUserId);
|
||||||
expect(secondResult.ownerId).toBe(secondUserClerkId);
|
expect(secondResult.ownerId).toBe(secondUserId);
|
||||||
expect(secondResult.ownerId).not.toBe(firstUserClerkId);
|
expect(secondResult.ownerId).not.toBe(firstUserId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple teams with different ownership states', async () => {
|
it('should handle multiple teams with different ownership states', async () => {
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,12 @@ export default function LoginPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Link to={`/register?redirectTo=${encodeURIComponent(redirectTo)}`} className="underline">
|
<Link to={`/register?redirectTo=${encodeURIComponent(redirectTo)}`} className="underline">
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,9 @@ export default function RegisterPage() {
|
||||||
if (signUpError) {
|
if (signUpError) {
|
||||||
setError(signUpError.message ?? "Registration failed. Please try again.");
|
setError(signUpError.message ?? "Registration failed. Please try again.");
|
||||||
setLoading(false);
|
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") ?? "";
|
||||||
|
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 = {
|
export const mockUser = {
|
||||||
id: 'user-1',
|
id: 'user-uuid-1',
|
||||||
clerkId: 'clerk-user-1',
|
clerkId: null,
|
||||||
email: 'user1@example.com',
|
email: 'user1@example.com',
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
username: 'user1',
|
username: 'user1',
|
||||||
|
|
@ -9,13 +9,14 @@ export const mockUser = {
|
||||||
lastName: 'One',
|
lastName: 'One',
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
timezone: null,
|
||||||
createdAt: new Date('2025-01-01'),
|
createdAt: new Date('2025-01-01'),
|
||||||
updatedAt: new Date('2025-01-01'),
|
updatedAt: new Date('2025-01-01'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mockAdminUser = {
|
export const mockAdminUser = {
|
||||||
id: 'admin-1',
|
id: 'admin-uuid-1',
|
||||||
clerkId: 'clerk-admin-1',
|
clerkId: null,
|
||||||
email: 'admin@example.com',
|
email: 'admin@example.com',
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
|
|
@ -24,6 +25,7 @@ export const mockAdminUser = {
|
||||||
lastName: 'User',
|
lastName: 'User',
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
|
timezone: null,
|
||||||
createdAt: new Date('2025-01-01'),
|
createdAt: new Date('2025-01-01'),
|
||||||
updatedAt: new Date('2025-01-01'),
|
updatedAt: new Date('2025-01-01'),
|
||||||
};
|
};
|
||||||
|
|
@ -31,8 +33,8 @@ export const mockAdminUser = {
|
||||||
export const mockUsers = [
|
export const mockUsers = [
|
||||||
mockUser,
|
mockUser,
|
||||||
{
|
{
|
||||||
id: 'user-2',
|
id: 'user-uuid-2',
|
||||||
clerkId: 'clerk-user-2',
|
clerkId: null,
|
||||||
email: 'user2@example.com',
|
email: 'user2@example.com',
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
username: 'user2',
|
username: 'user2',
|
||||||
|
|
@ -41,12 +43,13 @@ export const mockUsers = [
|
||||||
lastName: 'Two',
|
lastName: 'Two',
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
timezone: null,
|
||||||
createdAt: new Date('2025-01-01'),
|
createdAt: new Date('2025-01-01'),
|
||||||
updatedAt: new Date('2025-01-01'),
|
updatedAt: new Date('2025-01-01'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'user-3',
|
id: 'user-uuid-3',
|
||||||
clerkId: 'clerk-user-3',
|
clerkId: null,
|
||||||
email: 'user3@example.com',
|
email: 'user3@example.com',
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
username: 'user3',
|
username: 'user3',
|
||||||
|
|
@ -55,6 +58,7 @@ export const mockUsers = [
|
||||||
lastName: 'Three',
|
lastName: 'Three',
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
timezone: null,
|
||||||
createdAt: new Date('2025-01-01'),
|
createdAt: new Date('2025-01-01'),
|
||||||
updatedAt: new Date('2025-01-01'),
|
updatedAt: new Date('2025-01-01'),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { relations, sql } from "drizzle-orm";
|
||||||
|
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable("users", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
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(),
|
email: varchar("email", { length: 255 }).notNull(),
|
||||||
emailVerified: boolean("email_verified").notNull().default(false),
|
emailVerified: boolean("email_verified").notNull().default(false),
|
||||||
username: varchar("username", { length: 255 }),
|
username: varchar("username", { length: 255 }),
|
||||||
|
|
@ -159,7 +159,7 @@ export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [
|
||||||
export const leagues = pgTable("leagues", {
|
export const leagues = pgTable("leagues", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
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
|
currentSeasonId: uuid("current_season_id"), // References the active season
|
||||||
isPublicDraftBoard: boolean("is_public_draft_board").notNull().default(false),
|
isPublicDraftBoard: boolean("is_public_draft_board").notNull().default(false),
|
||||||
discordWebhookUrl: text("discord_webhook_url"),
|
discordWebhookUrl: text("discord_webhook_url"),
|
||||||
|
|
@ -220,7 +220,7 @@ export const teams = pgTable("teams", {
|
||||||
.references(() => seasons.id, { onDelete: "cascade" }),
|
.references(() => seasons.id, { onDelete: "cascade" }),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
logoUrl: varchar("logo_url", { length: 512 }),
|
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(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
@ -252,7 +252,7 @@ export const draftPicks = pgTable("draft_picks", {
|
||||||
pickNumber: integer("pick_number").notNull(),
|
pickNumber: integer("pick_number").notNull(),
|
||||||
round: integer("round").notNull(),
|
round: integer("round").notNull(),
|
||||||
pickInRound: integer("pick_in_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(),
|
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
|
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(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
|
@ -309,7 +309,7 @@ export const commissioners = pgTable("commissioners", {
|
||||||
leagueId: uuid("league_id")
|
leagueId: uuid("league_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => leagues.id, { onDelete: "cascade" }),
|
.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(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1324,7 +1324,7 @@ export const commissionerAuditLog = pgTable("commissioner_audit_log", {
|
||||||
leagueId: uuid("league_id")
|
leagueId: uuid("league_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => leagues.id, { onDelete: "cascade" }),
|
.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 }),
|
actorDisplayName: varchar("actor_display_name", { length: 255 }),
|
||||||
action: auditActionEnum("action").notNull(),
|
action: auditActionEnum("action").notNull(),
|
||||||
affectedTeamIds: text("affected_team_ids").array(),
|
affectedTeamIds: text("affected_team_ids").array(),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,94 @@
|
||||||
# Authentication
|
# Authentication
|
||||||
|
|
||||||
- Auth is handled by Clerk (`@clerk/react-router`)
|
Auth is handled by [BetterAuth](https://better-auth.com) (`better-auth` package).
|
||||||
- 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`)
|
## Key Files
|
||||||
- Admin access: `isAdmin` boolean on the users table; all `/admin` routes enforce this
|
|
||||||
- League commissioners: stored separately in the `commissioners` table (not via `isAdmin`)
|
| 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 |
|
| **League** | Top-level container; has one or more commissioners |
|
||||||
| **Season** | One iteration of a league. Status: `pre_draft` → `draft` → `active` → `completed` |
|
| **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 Slot** | Defines draft order for teams in a season |
|
||||||
| **Draft Pick** | Record of each pick made |
|
| **Draft Pick** | Record of each pick made |
|
||||||
| **Draft Queue** | A team's pre-ordered wish list of participants |
|
| **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,
|
"when": 1777180952144,
|
||||||
"tag": "0083_fearless_leader",
|
"tag": "0083_fearless_leader",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 84,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777477826949,
|
||||||
|
"tag": "0084_wooden_retro_girl",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue