Migrate authentication from Clerk to BetterAuth (#324)

* Migrate authentication from Clerk to BetterAuth (#322)

Replaces @clerk/react-router with self-hosted better-auth to eliminate
the external Clerk dependency and keep all user/session data in our own
PostgreSQL database.

**What changed**
- New: auth.server.ts (BetterAuth config w/ Drizzle adapter, bcrypt, Resend), auth-client.ts, api.auth.$.ts handler
- New: /login and /register pages with email+password and Google/Discord OAuth; open-redirect guard on redirectTo param
- New: UserMenu component replacing Clerk's UserButton
- Schema: sessions, accounts, verifications tables; emailVerified column; clerkId made nullable
- Migrations 0081 (BetterAuth tables) and 0082 (accounts extra columns for v1.6.9)
- All ~30 route files: getAuth → auth.api.getSession, isUserAdminByClerkId → isUserAdmin
- root.tsx: isAdmin read directly from session.user.isAdmin (no extra DB query)
- useDraftAuthRecovery: removed Clerk JWT refresh logic; replaced with cookie-session check
- models/user.ts: removed findUserByClerkId, findOrCreateUser, updateUserByClerkId (webhook pattern)
- Deleted: app/routes/api/webhooks/clerk.ts; uninstalled @clerk/react-router, @clerk/themes, svix
- scripts/migrate.mjs: extended with idempotent Clerk → BetterAuth data migration (FK conversion, email_verified, OAuth accounts)
- scripts/migrate-clerk-passwords.mjs: one-time script to import bcrypt hashes from Clerk CSV export
- BETTERAUTH_MIGRATION.md: dev and production runbooks
- All test mocks updated: vi.mock('~/lib/auth.server') instead of @clerk/react-router/server
- Test fixtures: added emailVerified field

**Follow-up (post-stable)**
- Rename actor_clerk_id column → actor_user_id in commissioner_audit_log
- Drop clerk_id column from users once migration confirmed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add .npmrc with legacy-peer-deps for better-auth/drizzle peer dep conflict

better-auth@1.6.9 declares peerOptional deps on drizzle-orm ^0.45.2 and
drizzle-kit >=0.31.4, but we run drizzle-orm ~0.36.3 / drizzle-kit ~0.28.1.
The adapter works correctly at runtime with our versions — the peer dep is
only for stricter type checking. This unblocks npm ci in CI without a risky
drizzle major-version upgrade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-24 22:00:49 -07:00 committed by GitHub
parent e8e4981465
commit ba9bf64e37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 11970 additions and 752 deletions

View file

@ -7,11 +7,13 @@ DATABASE_URL=""
# Leave both unset for local development with plain PostgreSQL. # Leave both unset for local development with plain PostgreSQL.
PGBOUNCER= PGBOUNCER=
DATABASE_DIRECT_URL= DATABASE_DIRECT_URL=
CLERK_SECRET_KEY="" BETTER_AUTH_SECRET=""
CLERK_PUBLISHABLE_KEY="" BETTER_AUTH_URL=""
CLERK_WEBHOOK_SECRET="" GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
DISCORD_CLIENT_ID=""
DISCORD_CLIENT_SECRET=""
CONTAINER_REGISTRY="" CONTAINER_REGISTRY=""
DEV_ADMIN_CLERK_ID=""
SENTRY_AUTH_TOKEN="" SENTRY_AUTH_TOKEN=""
SQUIGGLE_CONTACT_EMAIL="" SQUIGGLE_CONTACT_EMAIL=""
RESEND_API_KEY="" RESEND_API_KEY=""

3
.gitignore vendored
View file

@ -21,3 +21,6 @@
storybook-static storybook-static
.grepai/ .grepai/
.worktrees/ .worktrees/
# Clerk migration export — contains password hashes, never commit
exported_users.csv

1
.npmrc Normal file
View file

@ -0,0 +1 @@
legacy-peer-deps=true

183
BETTERAUTH_MIGRATION.md Normal file
View file

@ -0,0 +1,183 @@
# BetterAuth Migration Checklist
This document covers the manual steps required to complete the Clerk → BetterAuth migration in both development and production.
**What's automated** (runs via `scripts/migrate.mjs` on every deploy, idempotent):
- Schema migrations (sessions, accounts, verifications tables; nullable clerk_id; email_verified column)
- Convert `teams.owner_id` and `commissioners.user_id` from Clerk IDs → `users.id` UUIDs
- Mark all Clerk users as `email_verified = true`
- Create `accounts` records for Google and Discord OAuth users (via Clerk API)
**What's manual** (one-time steps you do by hand):
- Export user CSV from Clerk (for password hash migration)
- Run the password migration script
- Update OAuth redirect URIs in Google/Discord consoles
- Set env vars in production
---
## Development
### Step 1 — Add environment variables
Add these to your `.env` file:
```
BETTER_AUTH_SECRET="<random string run: openssl rand -base64 32>"
BETTER_AUTH_URL="http://localhost:5173"
GOOGLE_CLIENT_ID="<from Clerk dashboard Social Connections Google>"
GOOGLE_CLIENT_SECRET="<from Clerk dashboard Social Connections Google>"
DISCORD_CLIENT_ID="<from Clerk dashboard Social Connections Discord>"
DISCORD_CLIENT_SECRET="<from Clerk dashboard Social Connections Discord>"
# Keep temporarily for the automated OAuth migration (remove after confirmed working)
CLERK_SECRET_KEY="<your existing value>"
```
Remove once confirmed working: `VITE_CLERK_PUBLISHABLE_KEY`, `CLERK_WEBHOOK_SECRET`, `DEV_ADMIN_CLERK_ID`
### Step 2 — Apply the database schema migration
```bash
npm run db:migrate
```
Adds the `sessions`, `accounts`, and `verifications` tables; makes `clerk_id` nullable; adds `email_verified`.
### Step 3 — Add OAuth redirect URIs for localhost
In **Google Cloud Console** (APIs & Services → Credentials → your OAuth client):
- Add `http://localhost:5173/api/auth/callback/google` to Authorized redirect URIs
In **Discord Developer Portal** (your app → OAuth2):
- Add `http://localhost:5173/api/auth/callback/discord` to Redirects
### Step 4 — Run the automated data migration
```bash
node scripts/migrate.mjs
```
This converts owner IDs, creates Google/Discord OAuth accounts, and marks users as email-verified. It reads from the Clerk API using your `CLERK_SECRET_KEY`.
### Step 5 — Migrate email/password users
This requires exporting password hashes from Clerk (they're only in the CSV export, not the API).
1. Go to **Clerk dashboard → Users → Export** and download the CSV
2. Save it as `exported_users.csv` in the project root (it's gitignored)
3. Run:
```bash
node scripts/migrate-clerk-passwords.mjs
```
4. **Delete `exported_users.csv` immediately after** — it contains bcrypt password hashes
### Step 6 — Set yourself as admin
`DEV_ADMIN_CLERK_ID` no longer works. Set admin access directly in the database:
```sql
UPDATE users SET is_admin = true WHERE email = 'your@email.com';
```
### Step 7 — Verify sign-in works
- **Google / Discord**: sign in immediately — OAuth accounts were migrated automatically
- **Email/password**: sign in with the same password as before — bcrypt hashes were migrated
- Check that league ownership and commissioner access still works
After verifying, remove `CLERK_SECRET_KEY` from `.env`.
---
## Production
> Do Steps 13 before merging to main. The data migration (Step 4) and OAuth account creation run automatically on deploy.
### Step 1 — Add production environment variables
Add to your production environment (server docker-compose / secrets manager):
```
BETTER_AUTH_SECRET="<strong random string openssl rand -base64 32>"
BETTER_AUTH_URL="https://brackt.com"
GOOGLE_CLIENT_ID="<same as Clerk dashboard>"
GOOGLE_CLIENT_SECRET="<same as Clerk dashboard>"
DISCORD_CLIENT_ID="<same as Clerk dashboard>"
DISCORD_CLIENT_SECRET="<same as Clerk dashboard>"
# Keep temporarily — needed for the automated OAuth migration on first deploy
CLERK_SECRET_KEY="<your existing production value>"
```
### Step 2 — Add OAuth redirect URIs for production
In **Google Cloud Console**:
- Add `https://brackt.com/api/auth/callback/google` to Authorized redirect URIs
In **Discord Developer Portal**:
- Add `https://brackt.com/api/auth/callback/discord` to Redirects
### Step 3 — Migrate email/password users (before deploying)
The password migration runs against the production database directly — it does NOT go through Docker.
1. Go to **Clerk dashboard → Users → Export** and download the CSV for production users
2. Save as `exported_users.csv` in the project root (gitignored)
3. Run against the production database:
```bash
DATABASE_DIRECT_URL="<prod connection string>" CLERK_SECRET_KEY="<prod key>" node scripts/migrate-clerk-passwords.mjs
```
4. **Delete `exported_users.csv` immediately**
### Step 4 — Deploy
Merge to `main`. The GitHub Actions deploy will:
1. Build and push the Docker image
2. SSH to the server and run `docker compose up --no-deps migrate`
3. `scripts/migrate.mjs` runs — applies schema migrations, then (because `CLERK_SECRET_KEY` is set):
- Converts `teams.owner_id` and `commissioners.user_id` to `users.id` UUIDs
- Marks users as `email_verified`
- Creates `accounts` records for Google and Discord OAuth users
4. The app container starts with the new BetterAuth code
### Step 5 — Verify production sign-in
- Google and Discord users: sign in immediately
- Email/password users: sign in with the same password as before
- Spot-check that league ownership and commissioner access are intact
### Step 6 — Remove Clerk credentials
Once everything is confirmed, remove from the production environment:
```
CLERK_SECRET_KEY
VITE_CLERK_PUBLISHABLE_KEY
CLERK_WEBHOOK_SECRET
DEV_ADMIN_CLERK_ID
```
Delete the Clerk webhook endpoint from the Clerk dashboard (it no longer exists in the codebase).
On the next deploy, `scripts/migrate.mjs` will log `"CLERK_SECRET_KEY not set — skipping Clerk account migration"` and skip gracefully.
---
## Notes
### Why email/password migration is a separate manual step
Clerk's REST API does not expose password hashes — they're only available in the CSV dashboard export. The automated deploy script (`scripts/migrate.mjs`) handles OAuth accounts via the API, but password hashes must be exported manually and migrated with `scripts/migrate-clerk-passwords.mjs`.
The bcrypt hashes from Clerk are directly compatible with BetterAuth — no re-hashing is needed. Users sign in with the same password as before.
### What if a user signed up with both Google and email/password?
Both an OAuth account and a credential account will be created for them. BetterAuth handles this correctly — the user can sign in with either method.
### Future cleanup
After the migration is stable (a week or two after production deploy), open a follow-up PR to:
1. Remove the `clerk_id` column from `database/schema.ts` and run `npm run db:generate`
2. Remove `CLERK_SECRET_KEY` handling from `scripts/migrate.mjs`
3. Delete `scripts/migrate-clerk-passwords.mjs`

View file

@ -0,0 +1,59 @@
import { Link, useNavigate } from "react-router";
import { authClient } from "~/lib/auth-client";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
import { Button } from "~/components/ui/button";
interface UserMenuProps {
name: string | null;
email: string;
imageUrl: string | null;
}
export function UserMenu({ name, email, imageUrl }: UserMenuProps) {
const navigate = useNavigate();
const initials = name
? name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2)
: email.slice(0, 2).toUpperCase();
async function handleSignOut() {
await authClient.signOut();
navigate("/");
}
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" className="relative h-9 w-9 rounded-full p-0 overflow-hidden">
{imageUrl ? (
<img src={imageUrl} alt={name ?? email} className="h-9 w-9 rounded-full object-cover" />
) : (
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-muted text-sm font-medium">
{initials}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-48 p-1">
<div className="px-2 py-1.5 text-sm">
<p className="font-medium truncate">{name ?? email}</p>
<p className="text-muted-foreground truncate text-xs">{email}</p>
</div>
<div className="border-t border-border my-1" />
<Link
to="/user-profile"
className="flex w-full items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
Profile
</Link>
<div className="border-t border-border my-1" />
<button
onClick={handleSignOut}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-sm text-destructive hover:bg-accent transition-colors"
>
Sign Out
</button>
</PopoverContent>
</Popover>
);
}

View file

@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Link, NavLink } from "react-router"; import { Link, NavLink, useLocation } from "react-router";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -8,7 +8,8 @@ import {
SheetTrigger, SheetTrigger,
} from "~/components/ui/sheet"; } from "~/components/ui/sheet";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/react-router"; import { authClient } from "~/lib/auth-client";
import { UserMenu } from "~/components/UserMenu";
import { HelpCircle, MenuIcon, Settings } from "lucide-react"; import { HelpCircle, MenuIcon, Settings } from "lucide-react";
const NAV_LINK_CLASS = const NAV_LINK_CLASS =
@ -30,6 +31,22 @@ interface NavbarProps {
export function Navbar({ isAdmin }: NavbarProps) { export function Navbar({ isAdmin }: NavbarProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const location = useLocation();
const { data: session } = authClient.useSession();
const signInHref = `/login?redirectTo=${encodeURIComponent(location.pathname)}`;
const authSection = session ? (
<UserMenu
name={session.user.name ?? null}
email={session.user.email}
imageUrl={session.user.image ?? null}
/>
) : (
<Link to={signInHref}>
<Button>Sign In</Button>
</Link>
);
return ( return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@ -40,12 +57,10 @@ export function Navbar({ isAdmin }: NavbarProps) {
<img src="/logo.svg" alt="Brackt" className="h-14" /> <img src="/logo.svg" alt="Brackt" className="h-14" />
</Link> </Link>
<nav aria-label="Main navigation" className="hidden md:flex items-center gap-1"> <nav aria-label="Main navigation" className="hidden md:flex items-center gap-1">
<SignedOut> {session
<NavLink to="/" end className={navLinkClass}>Home</NavLink> ? <NavLink to="/" end className={navLinkClass}>Dashboard</NavLink>
</SignedOut> : <NavLink to="/" end className={navLinkClass}>Home</NavLink>
<SignedIn> }
<NavLink to="/" end className={navLinkClass}>Dashboard</NavLink>
</SignedIn>
<NavLink to="/how-to-play" className={navLinkClass}>How To Play</NavLink> <NavLink to="/how-to-play" className={navLinkClass}>How To Play</NavLink>
<NavLink to="/rules" className={navLinkClass}>Rules</NavLink> <NavLink to="/rules" className={navLinkClass}>Rules</NavLink>
</nav> </nav>
@ -69,14 +84,7 @@ export function Navbar({ isAdmin }: NavbarProps) {
<Settings className="h-5 w-5" /> <Settings className="h-5 w-5" />
</Link> </Link>
)} )}
<SignedOut> {authSection}
<SignInButton mode="modal">
<Button>Sign In</Button>
</SignInButton>
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</div> </div>
{/* Mobile: icons + Auth + Hamburger */} {/* Mobile: icons + Auth + Hamburger */}
@ -97,14 +105,7 @@ export function Navbar({ isAdmin }: NavbarProps) {
<Settings className="h-5 w-5" /> <Settings className="h-5 w-5" />
</Link> </Link>
)} )}
<SignedOut> {authSection}
<SignInButton mode="modal">
<Button>Sign In</Button>
</SignInButton>
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
@ -120,26 +121,14 @@ export function Navbar({ isAdmin }: NavbarProps) {
<SheetTitle>Menu</SheetTitle> <SheetTitle>Menu</SheetTitle>
</SheetHeader> </SheetHeader>
<nav aria-label="Mobile navigation" className="flex flex-col gap-4 mt-8"> <nav aria-label="Mobile navigation" className="flex flex-col gap-4 mt-8">
<SignedOut>
<NavLink <NavLink
to="/" to="/"
end end
className={mobileNavLinkClass} className={mobileNavLinkClass}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
Home {session ? "Dashboard" : "Home"}
</NavLink> </NavLink>
</SignedOut>
<SignedIn>
<NavLink
to="/"
end
className={mobileNavLinkClass}
onClick={() => setOpen(false)}
>
Dashboard
</NavLink>
</SignedIn>
<NavLink <NavLink
to="/how-to-play" to="/how-to-play"
className={mobileNavLinkClass} className={mobileNavLinkClass}

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { authClient } from "~/lib/auth-client";
import type { getDraftPicksForSeason } from "~/models/draft-pick"; import type { getDraftPicksForSeason } from "~/models/draft-pick";
import type { findSeasonWithTeamsAndLeague } from "~/models/season"; import type { findSeasonWithTeamsAndLeague } from "~/models/season";
import type { getSeasonTimers } from "~/models/draft-timer"; import type { getSeasonTimers } from "~/models/draft-timer";
@ -14,9 +15,7 @@ interface UseDraftAuthRecoveryParams {
reconnectCount: number; reconnectCount: number;
revalidate: () => void; revalidate: () => void;
revalidatorState: "idle" | "loading" | "submitting"; revalidatorState: "idle" | "loading" | "submitting";
clerkUserId: string | null | undefined;
currentUserId: string | null | undefined; currentUserId: string | null | undefined;
getToken: (opts?: { skipCache?: boolean }) => Promise<string | null>;
userAutodraftSettings: { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean } | null; userAutodraftSettings: { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean } | null;
// Loader data for revalidation sync // Loader data for revalidation sync
draftPicks: DraftPicks; draftPicks: DraftPicks;
@ -34,15 +33,12 @@ interface UseDraftAuthRecoveryParams {
setAutodraftStatus: (fn: () => Record<string, AutodraftStatusEntry>) => void; setAutodraftStatus: (fn: () => Record<string, AutodraftStatusEntry>) => void;
} }
const MAX_AUTH_RECOVERY_ATTEMPTS = 3;
export function useDraftAuthRecovery({ export function useDraftAuthRecovery({
reconnectCount, reconnectCount,
revalidate, revalidate,
revalidatorState, revalidatorState,
clerkUserId,
currentUserId, currentUserId,
getToken,
userAutodraftSettings, userAutodraftSettings,
draftPicks, draftPicks,
season, season,
@ -89,27 +85,8 @@ export function useDraftAuthRecovery({
}; };
}, [reconnectCount, revalidate]); }, [reconnectCount, revalidate]);
// Guard against Clerk JWT expiry race. // Re-check session when the tab becomes visible — cookie-based sessions don't
const authRecoveryTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); // need manual token refresh, but a long-idle tab may have an expired session.
const authRecoveryAttemptsRef = useRef(0);
useEffect(() => {
if (clerkUserId && !currentUserId) {
if (authRecoveryAttemptsRef.current >= MAX_AUTH_RECOVERY_ATTEMPTS) {
setAuthDegraded(true);
return;
}
clearTimeout(authRecoveryTimerRef.current);
authRecoveryTimerRef.current = setTimeout(() => {
authRecoveryAttemptsRef.current += 1;
revalidate();
}, 100);
} else if (currentUserId) {
authRecoveryAttemptsRef.current = 0;
}
return () => clearTimeout(authRecoveryTimerRef.current);
}, [clerkUserId, currentUserId, revalidate]);
// Proactively refresh the Clerk JWT when the tab becomes visible again.
useEffect(() => { useEffect(() => {
if (authDegraded) return; if (authDegraded) return;
@ -117,50 +94,29 @@ export function useDraftAuthRecovery({
let inFlight = false; let inFlight = false;
const handleVisibilityChange = async () => { const handleVisibilityChange = async () => {
if (document.visibilityState !== "visible" || !clerkUserId || inFlight) return; if (document.visibilityState !== "visible" || inFlight) return;
inFlight = true; inFlight = true;
try { try {
const token = await getToken({ skipCache: true }); const { data: session } = await authClient.getSession();
if (aborted) return; if (aborted) return;
if (token !== null) { if (session) {
if (!currentUserId) { if (!currentUserId) revalidate();
revalidate();
}
} else {
await new Promise((r) => setTimeout(r, 2500));
if (aborted) return;
const retryToken = await getToken({ skipCache: true });
if (aborted) return;
if (retryToken !== null) {
revalidate();
} else {
setAuthDegraded(true);
}
}
} catch {
try {
await new Promise((r) => setTimeout(r, 2500));
if (aborted) return;
const retryToken = await getToken({ skipCache: true });
if (aborted) return;
if (retryToken !== null) {
revalidate();
} else { } else {
setAuthDegraded(true); setAuthDegraded(true);
} }
} catch { } catch {
if (!aborted) setAuthDegraded(true); if (!aborted) setAuthDegraded(true);
}
} finally { } finally {
inFlight = false; inFlight = false;
} }
}; };
document.addEventListener("visibilitychange", handleVisibilityChange); document.addEventListener("visibilitychange", handleVisibilityChange);
return () => { return () => {
aborted = true; aborted = true;
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}, [clerkUserId, currentUserId, getToken, authDegraded, revalidate]); }, [currentUserId, authDegraded, revalidate]);
// Track revalidation lifecycle to sync local state from fresh loader data. // Track revalidation lifecycle to sync local state from fresh loader data.
const revalidatorStateRef = useRef(revalidatorState); const revalidatorStateRef = useRef(revalidatorState);

3
app/lib/auth-client.ts Normal file
View file

@ -0,0 +1,3 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();

64
app/lib/auth.server.ts Normal file
View file

@ -0,0 +1,64 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import bcrypt from "bcrypt";
import { Resend } from "resend";
import { db } from "~/server/db";
if (!process.env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET is required");
}
const resend = new Resend(process.env.RESEND_API_KEY);
const googleProvider = process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET } }
: {};
const discordProvider = process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET
? { discord: { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET } }
: {};
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
}),
user: {
fields: {
name: "display_name",
image: "image_url",
},
additionalFields: {
firstName: { type: "string", required: false, fieldName: "first_name" },
lastName: { type: "string", required: false, fieldName: "last_name" },
username: { type: "string", required: false, fieldName: "username" },
isAdmin: { type: "boolean", defaultValue: false, fieldName: "is_admin" },
},
},
emailAndPassword: {
enabled: true,
password: {
hash: (password: string) => bcrypt.hash(password, 10),
verify: ({ hash, password }: { hash: string; password: string }) =>
bcrypt.compare(password, hash),
},
sendResetPassword: async ({ user, url }) => {
await resend.emails.send({
from: "Brackt <noreply@brackt.com>",
to: user.email,
subject: "Reset your Brackt password",
html: `<p>Click the link below to reset your password. This link expires in 1 hour.</p><p><a href="${url}">${url}</a></p>`,
});
},
},
socialProviders: {
...googleProvider,
...discordProvider,
},
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "discord"],
},
},
});

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import type { database } from "~/database/context"; import type { database } from "~/database/context";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
@ -9,9 +9,6 @@ type Db = ReturnType<typeof database>;
/** /**
* Verifies the request has an authenticated user who is either a commissioner * Verifies the request has an authenticated user who is either a commissioner
* or a team owner in the given league/season. Throws Response on failure. * or a team owner in the given league/season. Throws Response on failure.
*
* @param unauthMessage - 401 message when not logged in
* @param unauthorizedMessage - 403 message when logged in but not a member
*/ */
export async function requireLeagueAccess( export async function requireLeagueAccess(
args: LoaderFunctionArgs, args: LoaderFunctionArgs,
@ -29,7 +26,8 @@ export async function requireLeagueAccess(
unauthorizedMessage?: string; unauthorizedMessage?: string;
} }
): Promise<void> { ): Promise<void> {
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
throw new Response(unauthMessage, { status: 401 }); throw new Response(unauthMessage, { status: 401 });

View file

@ -1,4 +1,4 @@
import { findUsersByClerkIds, getUserDisplayName } from "~/models/user"; import { findUsersByIds, getUserDisplayName } from "~/models/user";
/** /**
* Builds a map of teamId -> manager username (or displayName fallback). * Builds a map of teamId -> manager username (or displayName fallback).
@ -11,16 +11,16 @@ export async function buildOwnerMap(
slots.map((s) => s.team.ownerId).filter((id): id is string => id !== null) slots.map((s) => s.team.ownerId).filter((id): id is string => id !== null)
)]; )];
const userRows = await findUsersByClerkIds(uniqueOwnerIds); const userRows = await findUsersByIds(uniqueOwnerIds);
const userByClerkId = new Map( const userById = new Map(
userRows userRows
.map((u) => [u.clerkId, getUserDisplayName(u)] as const) .map((u) => [u.id, getUserDisplayName(u)] as const)
.filter((entry): entry is [string, string] => entry[1] !== null && entry[1] !== undefined) .filter((entry): entry is [string, string] => entry[1] !== null && entry[1] !== undefined)
); );
const ownerMap: Record<string, string> = {}; const ownerMap: Record<string, string> = {};
for (const slot of slots) { for (const slot of slots) {
const name = slot.team.ownerId ? userByClerkId.get(slot.team.ownerId) : undefined; const name = slot.team.ownerId ? userById.get(slot.team.ownerId) : undefined;
if (name) { if (name) {
ownerMap[slot.team.id] = name; ownerMap[slot.team.id] = name;
} }

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/models/user", () => ({ vi.mock("~/models/user", () => ({
findUserByClerkId: vi.fn(), findUserById: vi.fn(),
getUserDisplayName: vi.fn(), getUserDisplayName: vi.fn(),
})); }));
@ -14,18 +14,18 @@ import {
getAuditLogForSeason, getAuditLogForSeason,
logCommissionerAction, logCommissionerAction,
} from "../audit-log"; } from "../audit-log";
import { findUserByClerkId, getUserDisplayName } from "~/models/user"; import { findUserById, getUserDisplayName } from "~/models/user";
import { database } from "~/database/context"; import { database } from "~/database/context";
const SEASON_ID = "season-1"; const SEASON_ID = "season-1";
const LEAGUE_ID = "league-1"; const LEAGUE_ID = "league-1";
const ACTOR_CLERK_ID = "user_clerk_1"; const ACTOR_USER_ID = "user-uuid-1";
const SAMPLE_ENTRY = { const SAMPLE_ENTRY = {
id: "entry-1", id: "entry-1",
seasonId: SEASON_ID, seasonId: SEASON_ID,
leagueId: LEAGUE_ID, leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID, actorClerkId: 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_CLERK_ID, actorClerkId: ACTOR_USER_ID,
actorDisplayName: "Alice", actorDisplayName: "Alice",
action: "draft_rollback", action: "draft_rollback",
affectedTeamIds: ["team-1"], affectedTeamIds: ["team-1"],
@ -192,7 +192,7 @@ describe("getAuditLogForSeason", () => {
describe("logCommissionerAction", () => { describe("logCommissionerAction", () => {
it("resolves the actor display name from the users table", async () => { it("resolves the actor display name from the users table", async () => {
const mockUser = { username: "alice", displayName: "Alice Smith" }; const mockUser = { username: "alice", displayName: "Alice Smith" };
vi.mocked(findUserByClerkId).mockResolvedValue(mockUser as never); vi.mocked(findUserById).mockResolvedValue(mockUser as never);
vi.mocked(getUserDisplayName).mockReturnValue("alice"); vi.mocked(getUserDisplayName).mockReturnValue("alice");
const insertDb = makeInsertDb(SAMPLE_ENTRY); const insertDb = makeInsertDb(SAMPLE_ENTRY);
@ -201,7 +201,7 @@ describe("logCommissionerAction", () => {
await logCommissionerAction({ await logCommissionerAction({
seasonId: SEASON_ID, seasonId: SEASON_ID,
leagueId: LEAGUE_ID, leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID, actorUserId: ACTOR_USER_ID,
action: "draft_rollback", action: "draft_rollback",
}); });
@ -210,7 +210,7 @@ describe("logCommissionerAction", () => {
}); });
it("falls back to actorClerkId if the user is not found", async () => { it("falls back to actorClerkId if the user is not found", async () => {
vi.mocked(findUserByClerkId).mockResolvedValue(undefined); vi.mocked(findUserById).mockResolvedValue(undefined);
vi.mocked(getUserDisplayName).mockReturnValue(null); vi.mocked(getUserDisplayName).mockReturnValue(null);
const insertDb = makeInsertDb(SAMPLE_ENTRY); const insertDb = makeInsertDb(SAMPLE_ENTRY);
@ -219,16 +219,16 @@ describe("logCommissionerAction", () => {
await logCommissionerAction({ await logCommissionerAction({
seasonId: SEASON_ID, seasonId: SEASON_ID,
leagueId: LEAGUE_ID, leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID, actorUserId: ACTOR_USER_ID,
action: "draft_rollback", action: "draft_rollback",
}); });
const insertValues = (insertDb.insert as ReturnType<typeof vi.fn>).mock.results[0].value.values.mock.calls[0][0]; const insertValues = (insertDb.insert as ReturnType<typeof vi.fn>).mock.results[0].value.values.mock.calls[0][0];
expect(insertValues.actorDisplayName).toBe(ACTOR_CLERK_ID); expect(insertValues.actorDisplayName).toBe(ACTOR_USER_ID);
}); });
it("defaults affectedTeamIds to [] when not provided", async () => { it("defaults affectedTeamIds to [] when not provided", async () => {
vi.mocked(findUserByClerkId).mockResolvedValue({ username: "alice", displayName: "Alice" } as never); vi.mocked(findUserById).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
vi.mocked(getUserDisplayName).mockReturnValue("alice"); vi.mocked(getUserDisplayName).mockReturnValue("alice");
const insertDb = makeInsertDb(SAMPLE_ENTRY); const insertDb = makeInsertDb(SAMPLE_ENTRY);
@ -237,7 +237,7 @@ describe("logCommissionerAction", () => {
await logCommissionerAction({ await logCommissionerAction({
seasonId: SEASON_ID, seasonId: SEASON_ID,
leagueId: LEAGUE_ID, leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID, actorUserId: ACTOR_USER_ID,
action: "draft_started", action: "draft_started",
}); });
@ -246,7 +246,7 @@ describe("logCommissionerAction", () => {
}); });
it("defaults details to {} when not provided", async () => { it("defaults details to {} when not provided", async () => {
vi.mocked(findUserByClerkId).mockResolvedValue({ username: "alice", displayName: "Alice" } as never); vi.mocked(findUserById).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
vi.mocked(getUserDisplayName).mockReturnValue("alice"); vi.mocked(getUserDisplayName).mockReturnValue("alice");
const insertDb = makeInsertDb(SAMPLE_ENTRY); const insertDb = makeInsertDb(SAMPLE_ENTRY);
@ -255,7 +255,7 @@ describe("logCommissionerAction", () => {
await logCommissionerAction({ await logCommissionerAction({
seasonId: SEASON_ID, seasonId: SEASON_ID,
leagueId: LEAGUE_ID, leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID, actorUserId: ACTOR_USER_ID,
action: "draft_started", action: "draft_started",
}); });
@ -264,7 +264,7 @@ describe("logCommissionerAction", () => {
}); });
it("passes through provided affectedTeamIds and details", async () => { it("passes through provided affectedTeamIds and details", async () => {
vi.mocked(findUserByClerkId).mockResolvedValue({ username: "alice", displayName: "Alice" } as never); vi.mocked(findUserById).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
vi.mocked(getUserDisplayName).mockReturnValue("alice"); vi.mocked(getUserDisplayName).mockReturnValue("alice");
const insertDb = makeInsertDb(SAMPLE_ENTRY); const insertDb = makeInsertDb(SAMPLE_ENTRY);
@ -274,7 +274,7 @@ describe("logCommissionerAction", () => {
await logCommissionerAction({ await logCommissionerAction({
seasonId: SEASON_ID, seasonId: SEASON_ID,
leagueId: LEAGUE_ID, leagueId: LEAGUE_ID,
actorClerkId: ACTOR_CLERK_ID, actorUserId: ACTOR_USER_ID,
action: "force_manual_pick", action: "force_manual_pick",
affectedTeamIds: ["team-7"], affectedTeamIds: ["team-7"],
details, details,

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("~/models/user", () => ({ vi.mock("~/models/user", () => ({
isUserAdminByClerkId: vi.fn(), isUserAdmin: vi.fn(),
})); }));
vi.mock("~/database/context", () => ({ vi.mock("~/database/context", () => ({
@ -9,7 +9,7 @@ vi.mock("~/database/context", () => ({
})); }));
import { isCommissioner, hasCommissionerRecord } from "../commissioner"; import { isCommissioner, hasCommissionerRecord } from "../commissioner";
import { isUserAdminByClerkId } from "~/models/user"; 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";
@ -31,7 +31,7 @@ beforeEach(() => {
describe("isCommissioner", () => { describe("isCommissioner", () => {
it("returns true for a site admin without a commissioner record", async () => { it("returns true for a site admin without a commissioner record", async () => {
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true); vi.mocked(isUserAdmin).mockResolvedValue(true);
vi.mocked(database).mockReturnValue(makeMockDb(null) as never); vi.mocked(database).mockReturnValue(makeMockDb(null) as never);
const result = await isCommissioner(LEAGUE_ID, USER_ID); const result = await isCommissioner(LEAGUE_ID, USER_ID);
@ -39,7 +39,7 @@ describe("isCommissioner", () => {
}); });
it("returns true for a user with a commissioner record", async () => { it("returns true for a user with a commissioner record", async () => {
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false); vi.mocked(isUserAdmin).mockResolvedValue(false);
vi.mocked(database).mockReturnValue( vi.mocked(database).mockReturnValue(
makeMockDb({ id: "comm-1", leagueId: LEAGUE_ID, userId: USER_ID }) as never makeMockDb({ id: "comm-1", leagueId: LEAGUE_ID, userId: USER_ID }) as never
); );
@ -49,7 +49,7 @@ describe("isCommissioner", () => {
}); });
it("returns true for a site admin who also has a commissioner record", async () => { it("returns true for a site admin who also has a commissioner record", async () => {
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true); vi.mocked(isUserAdmin).mockResolvedValue(true);
vi.mocked(database).mockReturnValue( vi.mocked(database).mockReturnValue(
makeMockDb({ id: "comm-1", leagueId: LEAGUE_ID, userId: USER_ID }) as never makeMockDb({ id: "comm-1", leagueId: LEAGUE_ID, userId: USER_ID }) as never
); );
@ -59,7 +59,7 @@ describe("isCommissioner", () => {
}); });
it("returns false for a non-admin user without a commissioner record", async () => { it("returns false for a non-admin user without a commissioner record", async () => {
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false); vi.mocked(isUserAdmin).mockResolvedValue(false);
vi.mocked(database).mockReturnValue(makeMockDb(null) as never); vi.mocked(database).mockReturnValue(makeMockDb(null) as never);
const result = await isCommissioner(LEAGUE_ID, USER_ID); const result = await isCommissioner(LEAGUE_ID, USER_ID);
@ -68,7 +68,7 @@ describe("isCommissioner", () => {
it("runs the admin check and DB query in parallel", async () => { it("runs the admin check and DB query in parallel", async () => {
const order: string[] = []; const order: string[] = [];
vi.mocked(isUserAdminByClerkId).mockImplementation(async () => { vi.mocked(isUserAdmin).mockImplementation(async () => {
order.push("admin"); order.push("admin");
return false; return false;
}); });
@ -110,6 +110,6 @@ describe("hasCommissionerRecord", () => {
const result = await hasCommissionerRecord(LEAGUE_ID, USER_ID); const result = await hasCommissionerRecord(LEAGUE_ID, USER_ID);
expect(result).toBe(false); expect(result).toBe(false);
expect(isUserAdminByClerkId).not.toHaveBeenCalled(); expect(isUserAdmin).not.toHaveBeenCalled();
}); });
}); });

View file

@ -1,7 +1,7 @@
import { eq, desc, and, inArray, sql } from "drizzle-orm"; import { eq, desc, and, inArray, sql } from "drizzle-orm";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { findUserByClerkId, getUserDisplayName } from "~/models/user"; import { findUserById, getUserDisplayName } from "~/models/user";
export type AuditLogEntry = typeof schema.commissionerAuditLog.$inferSelect; export type AuditLogEntry = typeof schema.commissionerAuditLog.$inferSelect;
export type NewAuditLogEntry = typeof schema.commissionerAuditLog.$inferInsert; export type NewAuditLogEntry = typeof schema.commissionerAuditLog.$inferInsert;
@ -65,20 +65,20 @@ export async function getAuditLogForSeason(
export async function logCommissionerAction(params: { export async function logCommissionerAction(params: {
seasonId: string; seasonId: string;
leagueId: string; leagueId: string;
actorClerkId: string; actorUserId: string;
action: AuditAction; action: AuditAction;
affectedTeamIds?: string[]; affectedTeamIds?: string[];
details?: Record<string, unknown>; details?: Record<string, unknown>;
}): Promise<void> { }): Promise<void> {
const user = await findUserByClerkId(params.actorClerkId); const user = await findUserById(params.actorUserId);
const actorDisplayName = user const actorDisplayName = user
? (getUserDisplayName(user) ?? params.actorClerkId) ? (getUserDisplayName(user) ?? params.actorUserId)
: params.actorClerkId; : params.actorUserId;
await createAuditLogEntry({ await createAuditLogEntry({
seasonId: params.seasonId, seasonId: params.seasonId,
leagueId: params.leagueId, leagueId: params.leagueId,
actorClerkId: params.actorClerkId, actorClerkId: params.actorUserId,
actorDisplayName, actorDisplayName,
action: params.action, action: params.action,
affectedTeamIds: params.affectedTeamIds ?? [], affectedTeamIds: params.affectedTeamIds ?? [],

View file

@ -1,7 +1,7 @@
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { isUserAdminByClerkId } from "~/models/user"; import { isUserAdmin } from "~/models/user";
export type Commissioner = typeof schema.commissioners.$inferSelect; export type Commissioner = typeof schema.commissioners.$inferSelect;
export type NewCommissioner = typeof schema.commissioners.$inferInsert; export type NewCommissioner = typeof schema.commissioners.$inferInsert;
@ -57,7 +57,7 @@ export async function isCommissioner(
): Promise<boolean> { ): Promise<boolean> {
const db = database(); const db = database();
const [isAdmin, commissioner] = await Promise.all([ const [isAdmin, commissioner] = await Promise.all([
isUserAdminByClerkId(userId), isUserAdmin(userId),
db.query.commissioners.findFirst({ db.query.commissioners.findFirst({
where: and( where: and(
eq(schema.commissioners.leagueId, leagueId), eq(schema.commissioners.leagueId, leagueId),

View file

@ -1446,18 +1446,18 @@ export async function recalculateAffectedLeagues(
const webhookUrl = season?.league?.discordWebhookUrl; const webhookUrl = season?.league?.discordWebhookUrl;
if (!webhookUrl || options?.skipDiscord) continue; if (!webhookUrl || options?.skipDiscord) continue;
// Build clerkId → username map for all team owners in this season // Build userId → username map for all team owners in this season
const ownerClerkIds = afterStandings const ownerUserIds = afterStandings
.map((s) => s.team.ownerId) .map((s) => s.team.ownerId)
.filter((id): id is string => id !== null && id !== undefined); .filter((id): id is string => id !== null && id !== undefined);
const teamOwnerUsers = ownerClerkIds.length const teamOwnerUsers = ownerUserIds.length
? await db.query.users.findMany({ ? await db.query.users.findMany({
where: inArray(schema.users.clerkId, ownerClerkIds), where: inArray(schema.users.id, ownerUserIds),
}) })
: []; : [];
const usernameByClerkId = new Map( const usernameByUserId = new Map(
teamOwnerUsers teamOwnerUsers
.map((u) => [u.clerkId, getUserDisplayName(u)]) .map((u) => [u.id, getUserDisplayName(u)])
.filter((entry): entry is [string, string] => entry[1] !== null) .filter((entry): entry is [string, string] => entry[1] !== null)
); );
@ -1497,7 +1497,7 @@ export async function recalculateAffectedLeagues(
if (!teamId) return undefined; if (!teamId) return undefined;
const ownerId = ownerIdByTeamId.get(teamId); const ownerId = ownerIdByTeamId.get(teamId);
if (!ownerId) return undefined; if (!ownerId) return undefined;
return usernameByClerkId.get(ownerId); return usernameByUserId.get(ownerId);
}; };
scoredMatches = relevant scoredMatches = relevant
@ -1527,7 +1527,7 @@ export async function recalculateAffectedLeagues(
const standings = afterStandings.map((s) => ({ const standings = afterStandings.map((s) => ({
teamId: s.teamId, teamId: s.teamId,
teamName: s.team.name, teamName: s.team.name,
username: usernameByClerkId.get(s.team.ownerId ?? ""), username: usernameByUserId.get(s.team.ownerId ?? ""),
totalPoints: parseFloat(s.totalPoints), totalPoints: parseFloat(s.totalPoints),
rank: s.currentRank, rank: s.currentRank,
})); }));

View file

@ -5,11 +5,6 @@ import * as schema from "~/database/schema";
export type User = typeof schema.users.$inferSelect; export type User = typeof schema.users.$inferSelect;
export type NewUser = typeof schema.users.$inferInsert; export type NewUser = typeof schema.users.$inferInsert;
/**
* Returns the best available display identifier for a user.
* Prefers Clerk username; falls back to displayName (always set from
* firstName+lastName or email prefix). Returns null only if both are absent.
*/
export function getUserDisplayName( export function getUserDisplayName(
user: Pick<User, "username" | "displayName"> user: Pick<User, "username" | "displayName">
): string | null { ): string | null {
@ -29,22 +24,13 @@ export async function findUserById(id: string): Promise<User | undefined> {
}); });
} }
export async function findUserByClerkId( export async function findUsersByIds(ids: string[]): Promise<User[]> {
clerkId: string if (ids.length === 0) return [];
): Promise<User | undefined> {
const db = database();
return await db.query.users.findFirst({
where: eq(schema.users.clerkId, clerkId),
});
}
export async function findUsersByClerkIds(clerkIds: string[]): Promise<User[]> {
if (clerkIds.length === 0) return [];
const db = database(); const db = database();
return await db return await db
.select() .select()
.from(schema.users) .from(schema.users)
.where(inArray(schema.users.clerkId, clerkIds)); .where(inArray(schema.users.id, ids));
} }
export async function updateUser( export async function updateUser(
@ -60,71 +46,6 @@ export async function updateUser(
return user; return user;
} }
export async function updateUserByClerkId(
clerkId: string,
data: Partial<NewUser>
): Promise<User> {
const db = database();
const [user] = await db
.update(schema.users)
.set({ ...data, updatedAt: new Date() })
.where(eq(schema.users.clerkId, clerkId))
.returning();
return user;
}
export async function findOrCreateUser(clerkUser: {
id: string;
emailAddresses?: Array<{ emailAddress: string }>;
username?: string | null;
firstName?: string | null;
lastName?: string | null;
imageUrl?: string;
}): Promise<User> {
// Try to find existing user
const existingUser = await findUserByClerkId(clerkUser.id);
const email = clerkUser.emailAddresses?.[0]?.emailAddress;
if (!email) {
throw new Error("User must have an email address");
}
// Generate display name: firstName lastName, or email username
let displayName = "";
if (clerkUser.firstName || clerkUser.lastName) {
displayName = [clerkUser.firstName, clerkUser.lastName]
.filter(Boolean)
.join(" ")
.trim();
} else {
// Use email username as fallback
displayName = email.split("@")[0];
}
if (existingUser) {
// Update user info in case it changed in Clerk
return await updateUserByClerkId(clerkUser.id, {
email,
username: clerkUser.username || undefined,
displayName,
firstName: clerkUser.firstName || undefined,
lastName: clerkUser.lastName || undefined,
imageUrl: clerkUser.imageUrl,
});
}
// Create new user
return await createUser({
clerkId: clerkUser.id,
email,
username: clerkUser.username || undefined,
displayName,
firstName: clerkUser.firstName || undefined,
lastName: clerkUser.lastName || undefined,
imageUrl: clerkUser.imageUrl,
});
}
export async function deleteUser(id: string): Promise<void> { export async function deleteUser(id: string): Promise<void> {
const db = database(); const db = database();
await db.delete(schema.users).where(eq(schema.users.id, id)); await db.delete(schema.users).where(eq(schema.users.id, id));
@ -143,14 +64,6 @@ export async function isUserAdmin(userId: string): Promise<boolean> {
return user?.isAdmin ?? false; return user?.isAdmin ?? false;
} }
export async function isUserAdminByClerkId(clerkId: string): Promise<boolean> {
if (process.env.NODE_ENV === "development" && process.env.DEV_ADMIN_CLERK_ID === clerkId) {
return true;
}
const user = await findUserByClerkId(clerkId);
return user?.isAdmin ?? false;
}
export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<User> { export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<User> {
return await updateUser(userId, { isAdmin }); return await updateUser(userId, { isAdmin });
} }

View file

@ -10,31 +10,19 @@ import {
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
import "./app.css"; import "./app.css";
import { clerkMiddleware, rootAuthLoader, getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { Navbar } from "~/components/navbar"; import { Navbar } from "~/components/navbar";
import { NavigationProgress } from "~/components/NavigationProgress"; import { NavigationProgress } from "~/components/NavigationProgress";
import { ClerkProvider } from "@clerk/react-router";
import { dark } from "@clerk/themes";
import { Toaster } from "~/components/ui/sonner"; import { Toaster } from "~/components/ui/sonner";
import { BracktGradients } from "~/components/ui/BracktGradients"; import { BracktGradients } from "~/components/ui/BracktGradients";
import { Footer } from "~/components/marketing/Footer"; import { Footer } from "~/components/marketing/Footer";
import { isUserAdminByClerkId } from "~/models/user";
import { AlertCircle, FileQuestion, Lock, ShieldOff, ServerCrash } from "lucide-react"; import { AlertCircle, FileQuestion, Lock, ShieldOff, ServerCrash } from "lucide-react";
import logoUrl from "../public/logo.svg?url"; import logoUrl from "../public/logo.svg?url";
export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; export async function loader({ request }: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: request.headers });
export async function loader(args: Route.LoaderArgs) { const isAdmin = session?.user.isAdmin ?? false;
return rootAuthLoader(args, async () => {
const { userId } = await getAuth(args);
let isAdmin = false;
if (userId) {
isAdmin = await isUserAdminByClerkId(userId);
}
return { isAdmin }; return { isAdmin };
});
} }
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
@ -79,7 +67,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
const isDraftRoute = location.pathname.includes('/draft'); const isDraftRoute = location.pathname.includes('/draft');
return ( return (
<ClerkProvider loaderData={loaderData} appearance={{ baseTheme: dark }}> <>
<NavigationProgress /> <NavigationProgress />
{!isDraftRoute && <Navbar isAdmin={loaderData?.isAdmin ?? false} />} {!isDraftRoute && <Navbar isAdmin={loaderData?.isAdmin ?? false} />}
{isDraftRoute ? ( {isDraftRoute ? (
@ -93,7 +81,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
</> </>
)} )}
<Toaster /> <Toaster />
</ClerkProvider> </>
); );
} }

View file

@ -32,7 +32,7 @@ export default [
"routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.tsx" "routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.tsx"
), ),
route("teams/:teamId/settings", "routes/teams/$teamId.settings.tsx"), route("teams/:teamId/settings", "routes/teams/$teamId.settings.tsx"),
route("api/webhooks/clerk", "routes/api/webhooks/clerk.ts"), route("api/auth/*", "routes/api.auth.$.ts"),
route("api/queue/add", "routes/api/queue.add.ts"), route("api/queue/add", "routes/api/queue.add.ts"),
route("api/queue/remove", "routes/api/queue.remove.ts"), route("api/queue/remove", "routes/api/queue.remove.ts"),
route("api/queue/clear", "routes/api/queue.clear.ts"), route("api/queue/clear", "routes/api/queue.clear.ts"),
@ -48,6 +48,8 @@ export default [
route("api/draft/adjust-time-bank", "routes/api/draft.adjust-time-bank.ts"), route("api/draft/adjust-time-bank", "routes/api/draft.adjust-time-bank.ts"),
route("api/autodraft/update", "routes/api/autodraft.update.ts"), route("api/autodraft/update", "routes/api/autodraft.update.ts"),
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("register", "routes/register.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"),

View file

@ -1,10 +1,10 @@
import { Form, Link, redirect } from "react-router"; import { Form, Link, redirect } from "react-router";
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import type { Route } from "./+types/admin.sports-seasons.$id.clone"; import type { Route } from "./+types/admin.sports-seasons.$id.clone";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { findSportsSeasonById, cloneSportsSeason, shiftDateByYears, type NewSportsSeason } from "~/models/sports-season"; import { findSportsSeasonById, cloneSportsSeason, shiftDateByYears, type NewSportsSeason } from "~/models/sports-season";
import { isUserAdminByClerkId } from "~/models/user"; import { isUserAdmin } from "~/models/user";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
@ -57,8 +57,9 @@ export async function loader({ params }: Route.LoaderArgs) {
export async function action(args: Route.ActionArgs) { export async function action(args: Route.ActionArgs) {
const { request, params } = args; const { request, params } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const isAdmin = userId ? await isUserAdminByClerkId(userId) : false; const userId = session?.user.id ?? null;
const isAdmin = userId ? await isUserAdmin(userId) : false;
if (!isAdmin) { if (!isAdmin) {
throw new Response("Forbidden", { status: 403 }); throw new Response("Forbidden", { status: 403 });
} }

View file

@ -1,6 +1,6 @@
import { Form, Link, redirect, useNavigate, useNavigation } from "react-router"; import { Form, Link, redirect, useNavigate, useNavigation } from "react-router";
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { isUserAdminByClerkId } from "~/models/user"; import { isUserAdmin } from "~/models/user";
import type { Route } from "./+types/admin.sports-seasons.$id"; import type { Route } from "./+types/admin.sports-seasons.$id";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
@ -100,8 +100,9 @@ export async function loader({ params }: Route.LoaderArgs) {
export async function action(args: Route.ActionArgs) { export async function action(args: Route.ActionArgs) {
const { request, params } = args; const { request, params } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const isAdmin = userId ? await isUserAdminByClerkId(userId) : false; const userId = session?.user.id ?? null;
const isAdmin = userId ? await isUserAdmin(userId) : false;
if (!isAdmin) { if (!isAdmin) {
throw new Response("Forbidden", { status: 403 }); throw new Response("Forbidden", { status: 403 });
} }

View file

@ -1,8 +1,8 @@
import { Link, Outlet, redirect } from "react-router"; import { Link, Outlet, redirect } from "react-router";
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import type { Route } from "./+types/admin"; import type { Route } from "./+types/admin";
import { isUserAdminByClerkId } from "~/models/user"; import { isUserAdmin } from "~/models/user";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
LayoutDashboard, LayoutDashboard,
@ -17,13 +17,14 @@ export function meta(): Route.MetaDescriptors {
} }
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
throw redirect("/"); throw redirect("/");
} }
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
if (!isAdmin) { if (!isAdmin) {
throw redirect("/"); throw redirect("/");
} }

View file

@ -1,16 +1,17 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import type { Route } from "./+types/api.admin.export-sports-data"; import type { Route } from "./+types/api.admin.export-sports-data";
import { isUserAdminByClerkId } from "~/models/user"; import { isUserAdmin } from "~/models/user";
import { exportSportsDataToJSON } from "~/utils/sports-data-sync.server"; import { exportSportsDataToJSON } from "~/utils/sports-data-sync.server";
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
throw new Response("Unauthorized", { status: 401 }); throw new Response("Unauthorized", { status: 401 });
} }
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
if (!isAdmin) { if (!isAdmin) {
throw new Response("Forbidden", { status: 403 }); throw new Response("Forbidden", { status: 403 });
} }

10
app/routes/api.auth.$.ts Normal file
View file

@ -0,0 +1,10 @@
import { auth } from "~/lib/auth.server";
import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router";
export async function loader({ request }: LoaderFunctionArgs) {
return auth.handler(request);
}
export async function action({ request }: ActionFunctionArgs) {
return auth.handler(request);
}

View file

@ -8,8 +8,8 @@ vi.mock("~/database/context");
vi.mock("~/server/socket", () => ({ vi.mock("~/server/socket", () => ({
getSocketIO: vi.fn(), getSocketIO: vi.fn(),
})); }));
vi.mock("@clerk/react-router/server", () => ({ vi.mock("~/lib/auth.server", () => ({
getAuth: vi.fn(), auth: { api: { getSession: vi.fn() } },
})); }));
vi.mock("~/models/draft-pick", () => ({ vi.mock("~/models/draft-pick", () => ({
getDraftPicksWithSports: vi.fn(), getDraftPicksWithSports: vi.fn(),
@ -29,7 +29,7 @@ vi.mock("~/models/draft-utils", () => ({
calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }), calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }),
})); }));
vi.mock("~/models/user", () => ({ vi.mock("~/models/user", () => ({
isUserAdminByClerkId: vi.fn(), isUserAdmin: vi.fn(),
})); }));
vi.mock("~/models/audit-log", () => ({ vi.mock("~/models/audit-log", () => ({
logCommissionerAction: vi.fn().mockResolvedValue(undefined), logCommissionerAction: vi.fn().mockResolvedValue(undefined),
@ -119,12 +119,12 @@ describe("draft.force-manual-pick action", () => {
vi.clearAllMocks(); vi.clearAllMocks();
// Auth: default to authenticated commissioner // Auth: default to authenticated commissioner
const { getAuth } = await import("@clerk/react-router/server"); const { auth } = await import("~/lib/auth.server");
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any); vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
// User model: default to non-admin // User model: default to non-admin
const { isUserAdminByClerkId } = await import("~/models/user"); const { isUserAdmin } = await import("~/models/user");
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false); vi.mocked(isUserAdmin).mockResolvedValue(false);
// Socket // Socket
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() }; mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
@ -195,8 +195,8 @@ describe("draft.force-manual-pick action", () => {
describe("authorization", () => { describe("authorization", () => {
it("returns 401 when user is not authenticated", async () => { it("returns 401 when user is not authenticated", async () => {
const { getAuth } = await import("@clerk/react-router/server"); const { auth } = await import("~/lib/auth.server");
vi.mocked(getAuth).mockResolvedValue({ userId: null } as any); vi.mocked(auth.api.getSession).mockResolvedValue(null as any);
const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); const response = await action({ request: defaultPickRequest(), params: {}, context: ctx });

View file

@ -14,8 +14,8 @@ vi.mock("~/database/context");
vi.mock("~/server/socket", () => ({ vi.mock("~/server/socket", () => ({
getSocketIO: vi.fn(), getSocketIO: vi.fn(),
})); }));
vi.mock("@clerk/react-router/server", () => ({ vi.mock("~/lib/auth.server", () => ({
getAuth: vi.fn(), auth: { api: { getSession: vi.fn() } },
})); }));
vi.mock("~/models/draft-pick", () => ({ vi.mock("~/models/draft-pick", () => ({
getDraftPicksWithSports: vi.fn(), getDraftPicksWithSports: vi.fn(),
@ -35,7 +35,7 @@ vi.mock("~/models/draft-utils", () => ({
calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }), calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }),
})); }));
vi.mock("~/models/user", () => ({ vi.mock("~/models/user", () => ({
isUserAdminByClerkId: vi.fn(), isUserAdmin: vi.fn(),
})); }));
vi.mock("~/models/audit-log", () => ({ vi.mock("~/models/audit-log", () => ({
logCommissionerAction: vi.fn().mockResolvedValue(undefined), logCommissionerAction: vi.fn().mockResolvedValue(undefined),
@ -126,11 +126,11 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
vi.clearAllMocks(); vi.clearAllMocks();
// Default: authenticated as commissioner // Default: authenticated as commissioner
const { getAuth } = await import("@clerk/react-router/server"); const { auth } = await import("~/lib/auth.server");
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any); vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
const { isUserAdminByClerkId } = await import("~/models/user"); const { isUserAdmin } = await import("~/models/user");
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false); vi.mocked(isUserAdmin).mockResolvedValue(false);
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() }; mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
const socketModule = await import("~/server/socket"); const socketModule = await import("~/server/socket");
@ -236,11 +236,11 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
describe("admin force pick", () => { describe("admin force pick", () => {
beforeEach(async () => { beforeEach(async () => {
const { getAuth } = await import("@clerk/react-router/server"); const { auth } = await import("~/lib/auth.server");
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any); vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: ADMIN_ID } } as any);
const { isUserAdminByClerkId } = await import("~/models/user"); const { isUserAdmin } = await import("~/models/user");
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true); vi.mocked(isUserAdmin).mockResolvedValue(true);
// Admin is not a commissioner // Admin is not a commissioner
mockDb.query.commissioners.findFirst.mockResolvedValue(null); mockDb.query.commissioners.findFirst.mockResolvedValue(null);
@ -316,11 +316,11 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
describe("admin force pick", () => { describe("admin force pick", () => {
beforeEach(async () => { beforeEach(async () => {
const { getAuth } = await import("@clerk/react-router/server"); const { auth } = await import("~/lib/auth.server");
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any); vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: ADMIN_ID } } as any);
const { isUserAdminByClerkId } = await import("~/models/user"); const { isUserAdmin } = await import("~/models/user");
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true); vi.mocked(isUserAdmin).mockResolvedValue(true);
mockDb.query.commissioners.findFirst.mockResolvedValue(null); mockDb.query.commissioners.findFirst.mockResolvedValue(null);
}); });

View file

@ -14,8 +14,8 @@ vi.mock("~/database/context");
vi.mock("~/server/socket", () => ({ vi.mock("~/server/socket", () => ({
getSocketIO: vi.fn(), getSocketIO: vi.fn(),
})); }));
vi.mock("@clerk/react-router/server", () => ({ vi.mock("~/lib/auth.server", () => ({
getAuth: vi.fn(), auth: { api: { getSession: vi.fn() } },
})); }));
vi.mock("~/models/draft-pick", () => ({ vi.mock("~/models/draft-pick", () => ({
getDraftPicksWithSports: vi.fn(), getDraftPicksWithSports: vi.fn(),
@ -36,7 +36,7 @@ vi.mock("~/models/draft-utils", () => ({
pruneIneligibleQueueItems: vi.fn().mockResolvedValue([]), pruneIneligibleQueueItems: vi.fn().mockResolvedValue([]),
})); }));
vi.mock("~/models/user", () => ({ vi.mock("~/models/user", () => ({
isUserAdminByClerkId: vi.fn(), isUserAdmin: vi.fn(),
})); }));
// ── Fixtures ────────────────────────────────────────────────────────────────── // ── Fixtures ──────────────────────────────────────────────────────────────────
@ -123,11 +123,11 @@ describe("draft.make-pick action — timer mode behavior", () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
const { getAuth } = await import("@clerk/react-router/server"); const { auth } = await import("~/lib/auth.server");
vi.mocked(getAuth).mockResolvedValue({ userId: OWNER_ID } as any); vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: OWNER_ID } } as any);
const { isUserAdminByClerkId } = await import("~/models/user"); const { isUserAdmin } = await import("~/models/user");
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false); vi.mocked(isUserAdmin).mockResolvedValue(false);
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() }; mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
const socketModule = await import("~/server/socket"); const socketModule = await import("~/server/socket");
@ -231,8 +231,8 @@ describe("draft.make-pick action — timer mode behavior", () => {
describe("commissioner pick", () => { describe("commissioner pick", () => {
beforeEach(async () => { beforeEach(async () => {
const { getAuth } = await import("@clerk/react-router/server"); const { auth } = await import("~/lib/auth.server");
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any); vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID }); mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID });
// Commissioner does not own the team on the clock // Commissioner does not own the team on the clock
@ -273,11 +273,11 @@ describe("draft.make-pick action — timer mode behavior", () => {
describe("admin pick", () => { describe("admin pick", () => {
beforeEach(async () => { beforeEach(async () => {
const { getAuth } = await import("@clerk/react-router/server"); const { auth } = await import("~/lib/auth.server");
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any); vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: ADMIN_ID } } as any);
const { isUserAdminByClerkId } = await import("~/models/user"); const { isUserAdmin } = await import("~/models/user");
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true); vi.mocked(isUserAdmin).mockResolvedValue(true);
// Admin does not own the team on the clock // Admin does not own the team on the clock
mockDb.query.draftSlots.findMany.mockResolvedValue([ mockDb.query.draftSlots.findMany.mockResolvedValue([
@ -354,8 +354,8 @@ describe("draft.make-pick action — timer mode behavior", () => {
}); });
it("commissioner pick — resets bank to exactly draftIncrementTime", async () => { it("commissioner pick — resets bank to exactly draftIncrementTime", async () => {
const { getAuth } = await import("@clerk/react-router/server"); const { auth } = await import("~/lib/auth.server");
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any); vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID }); mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID });
mockDb.query.draftSlots.findMany.mockResolvedValue([ mockDb.query.draftSlots.findMany.mockResolvedValue([
{ ...mockDraftSlots[0], team: { ...mockDraftSlots[0].team, ownerId: "someone-else" } }, { ...mockDraftSlots[0], team: { ...mockDraftSlots[0].team, ownerId: "someone-else" } },
@ -375,10 +375,10 @@ describe("draft.make-pick action — timer mode behavior", () => {
}); });
it("admin pick — resets bank to exactly draftIncrementTime", async () => { it("admin pick — resets bank to exactly draftIncrementTime", async () => {
const { getAuth } = await import("@clerk/react-router/server"); const { auth } = await import("~/lib/auth.server");
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any); vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: ADMIN_ID } } as any);
const { isUserAdminByClerkId } = await import("~/models/user"); const { isUserAdmin } = await import("~/models/user");
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true); vi.mocked(isUserAdmin).mockResolvedValue(true);
mockDb.query.draftSlots.findMany.mockResolvedValue([ mockDb.query.draftSlots.findMany.mockResolvedValue([
{ ...mockDraftSlots[0], team: { ...mockDraftSlots[0].team, ownerId: "someone-else" } }, { ...mockDraftSlots[0], team: { ...mockDraftSlots[0].team, ownerId: "someone-else" } },
mockDraftSlots[1], mockDraftSlots[1],

View file

@ -8,8 +8,8 @@ vi.mock("~/database/context");
vi.mock("~/server/socket", () => ({ vi.mock("~/server/socket", () => ({
getSocketIO: vi.fn(), getSocketIO: vi.fn(),
})); }));
vi.mock("@clerk/react-router/server", () => ({ vi.mock("~/lib/auth.server", () => ({
getAuth: vi.fn(), auth: { api: { getSession: vi.fn() } },
})); }));
vi.mock("~/models/commissioner", () => ({ vi.mock("~/models/commissioner", () => ({
isCommissioner: vi.fn(), isCommissioner: vi.fn(),
@ -62,8 +62,8 @@ describe("draft.start action — timer initialization", () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
const { getAuth } = await import("@clerk/react-router/server"); const { auth } = await import("~/lib/auth.server");
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any); vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
const { isCommissioner } = await import("~/models/commissioner"); const { isCommissioner } = await import("~/models/commissioner");
vi.mocked(isCommissioner).mockResolvedValue(true); vi.mocked(isCommissioner).mockResolvedValue(true);

View file

@ -1,9 +1,9 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, asc } from "drizzle-orm"; import { eq, and, asc } from "drizzle-orm";
import { getSocketIO } from "~/server/socket"; import { getSocketIO } from "~/server/socket";
import { isUserAdminByClerkId } from "~/models/user"; import { isUserAdmin } from "~/models/user";
import { getTeamForPick } from "~/lib/draft-order"; import { getTeamForPick } from "~/lib/draft-order";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { executeAutoPick } from "~/models/draft-utils"; import { executeAutoPick } from "~/models/draft-utils";
@ -11,7 +11,8 @@ import { executeAutoPick } from "~/models/draft-utils";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const { request } = args; const { request } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });
@ -51,7 +52,7 @@ export async function action(args: ActionFunctionArgs) {
eq(schema.commissioners.userId, userId) eq(schema.commissioners.userId, userId)
), ),
}), }),
isUserAdminByClerkId(userId), isUserAdmin(userId),
]); ]);
if (!commissioner && !userIsAdmin) { if (!commissioner && !userIsAdmin) {

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
@ -10,7 +10,8 @@ import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const { request } = args; const { request } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });
@ -84,7 +85,7 @@ export async function action(args: ActionFunctionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId, seasonId,
leagueId: season.leagueId, leagueId: season.leagueId,
actorClerkId: userId, actorUserId: userId,
action: "time_bank_edited", action: "time_bank_edited",
affectedTeamIds: [teamId], affectedTeamIds: [teamId],
details: { details: {

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@ -9,7 +9,8 @@ import { logCommissionerAction } from "~/models/audit-log";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const { request } = args; const { request } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });
@ -61,7 +62,7 @@ export async function action(args: ActionFunctionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId, seasonId,
leagueId: season.leagueId, leagueId: season.leagueId,
actorClerkId: userId, actorUserId: userId,
action: "force_autopick", action: "force_autopick",
affectedTeamIds: [teamId], affectedTeamIds: [teamId],
details: { details: {

View file

@ -1,8 +1,8 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, sql } from "drizzle-orm"; import { eq, and, sql } from "drizzle-orm";
import { isUserAdminByClerkId } from "~/models/user"; import { isUserAdmin } from "~/models/user";
import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick"; import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant"; import { getParticipantsForSeasonWithSports } from "~/models/participant";
@ -15,7 +15,8 @@ import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const { request } = args; const { request } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });
@ -44,7 +45,7 @@ export async function action(args: ActionFunctionArgs) {
// Check if user is commissioner or site admin; capture both to set pickedByType accurately // Check if user is commissioner or site admin; capture both to set pickedByType accurately
const [isAdmin, commissionerRecord] = await Promise.all([ const [isAdmin, commissionerRecord] = await Promise.all([
isUserAdminByClerkId(userId), isUserAdmin(userId),
db.query.commissioners.findFirst({ db.query.commissioners.findFirst({
where: and( where: and(
eq(schema.commissioners.leagueId, season.leagueId), eq(schema.commissioners.leagueId, season.leagueId),
@ -263,7 +264,7 @@ export async function action(args: ActionFunctionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId, seasonId,
leagueId: season.leagueId, leagueId: season.leagueId,
actorClerkId: userId, actorUserId: userId,
action: "force_manual_pick", action: "force_manual_pick",
affectedTeamIds: [teamId], affectedTeamIds: [teamId],
details: { details: {

View file

@ -1,8 +1,8 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, sql } from "drizzle-orm"; import { eq, and, sql } from "drizzle-orm";
import { isUserAdminByClerkId } from "~/models/user"; import { isUserAdmin } from "~/models/user";
import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick"; import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant"; import { getParticipantsForSeasonWithSports } from "~/models/participant";
@ -14,7 +14,8 @@ import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const { request } = args; const { request } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });
@ -69,7 +70,7 @@ export async function action(args: ActionFunctionArgs) {
// Capture both results to set pickedByType accurately in the audit record // Capture both results to set pickedByType accurately in the audit record
const isTeamOwner = currentDraftSlot.team.ownerId === userId; const isTeamOwner = currentDraftSlot.team.ownerId === userId;
const [isAdmin, commissionerRecord] = await Promise.all([ const [isAdmin, commissionerRecord] = await Promise.all([
isUserAdminByClerkId(userId), isUserAdmin(userId),
db.query.commissioners.findFirst({ db.query.commissioners.findFirst({
where: and( where: and(
eq(schema.commissioners.leagueId, season.leagueId), eq(schema.commissioners.leagueId, season.leagueId),

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
@ -10,7 +10,8 @@ import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const { request } = args; const { request } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });
@ -59,7 +60,7 @@ export async function action(args: ActionFunctionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId, seasonId,
leagueId: season.leagueId, leagueId: season.leagueId,
actorClerkId: userId, actorUserId: userId,
action: "draft_paused", action: "draft_paused",
details: { pickNumber: season.currentPickNumber }, details: { pickNumber: season.currentPickNumber },
}); });

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
@ -14,7 +14,8 @@ import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const { request } = args; const { request } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });
@ -152,7 +153,7 @@ export async function action(args: ActionFunctionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId, seasonId,
leagueId: season.leagueId, leagueId: season.leagueId,
actorClerkId: userId, actorUserId: userId,
action: "draft_pick_changed", action: "draft_pick_changed",
affectedTeamIds: [teamId], affectedTeamIds: [teamId],
details: { details: {

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
@ -10,7 +10,8 @@ import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const { request } = args; const { request } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });
@ -59,7 +60,7 @@ export async function action(args: ActionFunctionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId, seasonId,
leagueId: season.leagueId, leagueId: season.leagueId,
actorClerkId: userId, actorUserId: userId,
action: "draft_resumed", action: "draft_resumed",
details: { pickNumber: season.currentPickNumber }, details: { pickNumber: season.currentPickNumber },
}); });

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, gte } from "drizzle-orm"; import { eq, and, gte } from "drizzle-orm";
@ -10,7 +10,8 @@ import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const { request } = args; const { request } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });
@ -87,7 +88,7 @@ export async function action(args: ActionFunctionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId, seasonId,
leagueId: season.leagueId, leagueId: season.leagueId,
actorClerkId: userId, actorUserId: userId,
action: "draft_rollback", action: "draft_rollback",
affectedTeamIds: rollbackSlot?.teamId ? [rollbackSlot.teamId] : [], affectedTeamIds: rollbackSlot?.teamId ? [rollbackSlot.teamId] : [],
details: { details: {

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@ -11,7 +11,8 @@ import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const { request } = args; const { request } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });
@ -81,7 +82,7 @@ export async function action(args: ActionFunctionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId, seasonId,
leagueId: season.leagueId, leagueId: season.leagueId,
actorClerkId: userId, actorUserId: userId,
action: "draft_started", action: "draft_started",
details: { pickNumber: 1 }, details: { pickNumber: 1 },
}); });

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@ -8,7 +8,8 @@ import { getSocketIO } from "../../../server/socket";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const { request } = args; const { request } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@ -8,7 +8,8 @@ import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const { request } = args; const { request } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@ -8,7 +8,8 @@ import { getSocketIO } from "../../../server/socket";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const { request } = args; const { request } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@ -8,7 +8,8 @@ import { getSocketIO } from "../../../server/socket";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const { request } = args; const { request } = args;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -67,24 +67,24 @@ export async function loader({ params }: LoaderFunctionArgs) {
const clockOwnerId = onTheClockSlot?.team.ownerId ?? null; const clockOwnerId = onTheClockSlot?.team.ownerId ?? null;
const allOwnerIds = [...new Set([...picksOwnerIds, ...(clockOwnerId ? [clockOwnerId] : [])])]; const allOwnerIds = [...new Set([...picksOwnerIds, ...(clockOwnerId ? [clockOwnerId] : [])])];
const usernameByClerkId = new Map<string, string | null>(); const usernameByUserId = new Map<string, string | null>();
if (allOwnerIds.length > 0) { if (allOwnerIds.length > 0) {
const owners = await db const owners = await db
.select({ .select({
clerkId: schema.users.clerkId, id: schema.users.id,
username: schema.users.username, username: schema.users.username,
displayName: schema.users.displayName, displayName: schema.users.displayName,
}) })
.from(schema.users) .from(schema.users)
.where(inArray(schema.users.clerkId, allOwnerIds)); .where(inArray(schema.users.id, allOwnerIds));
for (const owner of owners) { for (const owner of owners) {
usernameByClerkId.set(owner.clerkId, getUserDisplayName(owner)); usernameByUserId.set(owner.id, getUserDisplayName(owner));
} }
} }
// Fix #3: single helper so onTheClock and picks use identical null-fallback logic // Fix #3: single helper so onTheClock and picks use identical null-fallback logic
const getUsername = (ownerId: string | null) => const getUsername = (ownerId: string | null) =>
ownerId ? (usernameByClerkId.get(ownerId) ?? null) : null; ownerId ? (usernameByUserId.get(ownerId) ?? null) : null;
const onTheClock = onTheClockSlot const onTheClock = onTheClockSlot
? { teamName: onTheClockSlot.team.name, username: getUsername(onTheClockSlot.team.ownerId) } ? { teamName: onTheClockSlot.team.name, username: getUsername(onTheClockSlot.team.ownerId) }

View file

@ -1,97 +0,0 @@
import { Webhook } from "svix";
import type { Route } from "./+types/clerk";
import { findOrCreateUser, getUserDisplayName } from "~/models/user";
import { logger } from "~/lib/logger";
export async function action({ request }: Route.ActionArgs) {
// Get the webhook secret from environment
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) {
throw new Error("Please add CLERK_WEBHOOK_SECRET to your .env file");
}
// Get the headers
const svix_id = request.headers.get("svix-id");
const svix_timestamp = request.headers.get("svix-timestamp");
const svix_signature = request.headers.get("svix-signature");
// If there are no headers, error out
if (!svix_id || !svix_timestamp || !svix_signature) {
return new Response("Error: Missing svix headers", { status: 400 });
}
// Get the body
const payload = await request.text();
// Create a new Svix instance with your webhook secret
const wh = new Webhook(WEBHOOK_SECRET);
let evt: { type: string; data: { id: string; email_addresses?: Array<{ email_address: string }>; username?: string; first_name?: string; last_name?: string; image_url?: string } };
// Verify the webhook
try {
evt = wh.verify(payload, {
"svix-id": svix_id,
"svix-timestamp": svix_timestamp,
"svix-signature": svix_signature,
}) as typeof evt;
} catch (err) {
logger.error("Error verifying webhook:", err);
return new Response("Error: Verification failed", { status: 400 });
}
// Handle the webhook
const eventType = evt.type;
logger.log(`Webhook received: ${eventType}`);
if (eventType === "user.created" || eventType === "user.updated") {
const { id, email_addresses, username, first_name, last_name, image_url } = evt.data;
try {
if (eventType === "user.created") {
// Create new user
const user = await findOrCreateUser({
id,
emailAddresses:
email_addresses?.map((e: { email_address: string }) => ({
emailAddress: e.email_address,
})) || [],
username,
firstName: first_name,
lastName: last_name,
imageUrl: image_url,
});
logger.log(
`User created in database: ${user.id} (${getUserDisplayName(user)})`
);
} else {
// Update existing user (or create if doesn't exist)
const user = await findOrCreateUser({
id,
emailAddresses:
email_addresses?.map((e: { email_address: string }) => ({
emailAddress: e.email_address,
})) || [],
username,
firstName: first_name,
lastName: last_name,
imageUrl: image_url,
});
logger.log(
`User updated in database: ${user.id} (${getUserDisplayName(user)})`
);
}
} catch (error) {
logger.error(`Error handling ${eventType}:`, error);
return new Response(
`Error: Failed to ${eventType === "user.created" ? "create" : "update"} user`,
{
status: 500,
}
);
}
}
return new Response("Webhook processed successfully", { status: 200 });
}

View file

@ -1,7 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { addDays, subDays } from "date-fns"; import { addDays, subDays } from "date-fns";
import type { Route } from "./+types/home"; import type { Route } from "./+types/home";
@ -34,7 +34,8 @@ export function meta() {
} }
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return { leagues: [], isLoggedIn: false, upcomingCalendarEvents: [] }; return { leagues: [], isLoggedIn: false, upcomingCalendarEvents: [] };

View file

@ -1,6 +1,5 @@
import { Form, redirect } from "react-router"; import { Form, redirect, Link } from "react-router";
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { SignInButton } from "@clerk/react-router";
import type { Route } from "./+types/i.$inviteCode"; import type { Route } from "./+types/i.$inviteCode";
import { import {
@ -10,7 +9,7 @@ import {
findAvailableTeams, findAvailableTeams,
claimTeam, claimTeam,
isUserLeagueMember, isUserLeagueMember,
findUserByClerkId, findUserById,
getUserDisplayName, getUserDisplayName,
} from "~/models"; } from "~/models";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@ -29,7 +28,8 @@ export function meta(): Route.MetaDescriptors {
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const { params } = args; const { params } = args;
const { inviteCode } = params; const { inviteCode } = params;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
// Find season by invite code // Find season by invite code
const season = await findSeasonByInviteCode(inviteCode); const season = await findSeasonByInviteCode(inviteCode);
@ -68,7 +68,8 @@ export async function loader(args: Route.LoaderArgs) {
export async function action(args: Route.ActionArgs) { export async function action(args: Route.ActionArgs) {
const { params } = args; const { params } = args;
const { inviteCode } = params; const { inviteCode } = params;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
throw new Response("You must be logged in to join a league", { status: 401 }); throw new Response("You must be logged in to join a league", { status: 401 });
@ -97,7 +98,7 @@ export async function action(args: Route.ActionArgs) {
} }
// Look up user before claiming to fail fast and get their name // Look up user before claiming to fail fast and get their name
const user = await findUserByClerkId(userId); const user = await findUserById(userId);
if (!user) { if (!user) {
throw new Response("User account not found. Please try again.", { status: 500 }); throw new Response("User account not found. Please try again.", { status: 500 });
} }
@ -162,9 +163,9 @@ export default function InvitePage({ loaderData }: Route.ComponentProps) {
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
You'll need to sign in or create an account to join this league. You'll need to sign in or create an account to join this league.
</p> </p>
<SignInButton mode="modal" forceRedirectUrl={`/i/${loaderData.inviteCode}`}> <Link to={`/login?redirectTo=/i/${loaderData.inviteCode}`}>
<Button className="w-full">Sign In to Join</Button> <Button className="w-full">Sign In to Join</Button>
</SignInButton> </Link>
</div> </div>
)} )}
</CardContent> </CardContent>

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { format } from "date-fns"; import { format } from "date-fns";
import { Link, Form, useSearchParams } from "react-router"; import { Link, Form, useSearchParams } from "react-router";
import type { Route } from "./+types/$leagueId.audit-log"; import type { Route } from "./+types/$leagueId.audit-log";
@ -42,7 +42,8 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
const { params, request } = args; const { params, request } = args;
const { leagueId } = params; const { leagueId } = params;

View file

@ -1,11 +1,10 @@
import { useLoaderData, Link, useRevalidator } from "react-router"; import { useLoaderData, Link, useRevalidator } from "react-router";
import { useAuth } from "@clerk/react-router";
import { useDraftSocket } from "~/hooks/useDraftSocket"; import { useDraftSocket } from "~/hooks/useDraftSocket";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { useDraftRoomState, type QueueItem } from "~/hooks/useDraftRoomState"; import { useDraftRoomState, type QueueItem } from "~/hooks/useDraftRoomState";
import { useDraftAuthRecovery } from "~/hooks/useDraftAuthRecovery"; import { useDraftAuthRecovery } from "~/hooks/useDraftAuthRecovery";
import { useDraftSocketEvents } from "~/hooks/useDraftSocketEvents"; import { useDraftSocketEvents } from "~/hooks/useDraftSocketEvents";
@ -60,7 +59,8 @@ const QUEUE_TAB = { id: "queue" as const, label: "Queue", Icon: ListOrdered };
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const { params } = args; const { params } = args;
const { seasonId, leagueId } = params; const { seasonId, leagueId } = params;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!seasonId) { if (!seasonId) {
throw new Response("Season ID is required", { status: 400 }); throw new Response("Season ID is required", { status: 400 });
@ -147,7 +147,6 @@ export default function DraftRoom() {
ownerMap, ownerMap,
} = useLoaderData<typeof loader>(); } = useLoaderData<typeof loader>();
const { revalidate, state: revalidatorState } = useRevalidator(); const { revalidate, state: revalidatorState } = useRevalidator();
const { userId: clerkUserId, getToken } = useAuth();
const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id); const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id);
const { const {
permissionState: notificationsPermission, permissionState: notificationsPermission,
@ -228,9 +227,7 @@ export default function DraftRoom() {
reconnectCount, reconnectCount,
revalidate, revalidate,
revalidatorState, revalidatorState,
clerkUserId,
currentUserId, currentUserId,
getToken,
userAutodraftSettings, userAutodraftSettings,
draftPicks, draftPicks,
season, season,

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { addDays, subDays } from "date-fns"; import { addDays, subDays } from "date-fns";
import { toEventSortKey } from "~/lib/date-utils"; import { toEventSortKey } from "~/lib/date-utils";
import { import {
@ -7,7 +7,7 @@ import {
findCommissionersByLeagueId, findCommissionersByLeagueId,
isUserLeagueMember, isUserLeagueMember,
isCommissioner, isCommissioner,
findUsersByClerkIds, findUsersByIds,
getUserDisplayName, getUserDisplayName,
findDraftSlotsBySeasonId, findDraftSlotsBySeasonId,
} from "~/models"; } from "~/models";
@ -20,7 +20,8 @@ import { getAuditLogForSeason } from "~/models/audit-log";
import type { Route } from "./+types/$leagueId"; import type { Route } from "./+types/$leagueId";
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
const { params } = args; const { params } = args;
const { leagueId } = params; const { leagueId } = params;
@ -81,19 +82,19 @@ export async function loader(args: Route.LoaderArgs) {
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))]; const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
const commissionerIds = commissioners.map((c) => c.userId); const commissionerIds = commissioners.map((c) => c.userId);
const allUserIds = [...new Set([...ownerIds, ...commissionerIds])]; const allUserIds = [...new Set([...ownerIds, ...commissionerIds])];
const userRows = await findUsersByClerkIds(allUserIds); const userRows = await findUsersByIds(allUserIds);
const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u])); const userById = new Map(userRows.map((u) => [u.id, u]));
const ownerMap = new Map( const ownerMap = new Map(
ownerIds ownerIds
.map((id) => [id, userByClerkId.get(id)] as const) .map((id) => [id, userById.get(id)] as const)
.filter((entry): entry is [string, NonNullable<typeof entry[1]>] => entry[1] !== undefined) .filter((entry): entry is [string, NonNullable<typeof entry[1]>] => entry[1] !== undefined)
.map(([id, user]) => [id, getUserDisplayName(user)]) .map(([id, user]) => [id, getUserDisplayName(user)])
); );
const commissionerMap = new Map( const commissionerMap = new Map(
commissionerIds commissionerIds
.map((id) => [id, userByClerkId.get(id)] as const) .map((id) => [id, userById.get(id)] as const)
.filter((entry): entry is [string, NonNullable<typeof entry[1]>] => entry[1] !== undefined) .filter((entry): entry is [string, NonNullable<typeof entry[1]>] => entry[1] !== undefined)
.map(([id, user]) => [id, getUserDisplayName(user)]) .map(([id, user]) => [id, getUserDisplayName(user)])
); );

View file

@ -1,6 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Form, Link, redirect, useNavigation } from "react-router"; import { Form, Link, redirect, useNavigation } from "react-router";
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { format } from "date-fns"; import { format } from "date-fns";
import { CalendarIcon } from "lucide-react"; import { CalendarIcon } from "lucide-react";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
@ -16,7 +16,7 @@ import {
} from "~/models/commissioner"; } from "~/models/commissioner";
import { findCurrentSeasonWithSports, updateSeason, type NewSeason } from "~/models/season"; import { findCurrentSeasonWithSports, updateSeason, type NewSeason } from "~/models/season";
import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam } from "~/models/team"; import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam } from "~/models/team";
import { findAllUsers, findUserByClerkId, findUsersByClerkIds, getUserDisplayName, isUserAdminByClerkId } from "~/models/user"; import { findAllUsers, findUsersByIds, findUserById, getUserDisplayName, isUserAdmin } from "~/models/user";
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport"; import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
import { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season"; import { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season";
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot"; import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
@ -73,7 +73,8 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
} }
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
const { params } = args; const { params } = args;
const { leagueId } = params; const { leagueId } = params;
@ -118,7 +119,7 @@ export async function loader(args: Route.LoaderArgs) {
const teamsWithOwners = teams.filter(team => team.ownerId !== null).length; const teamsWithOwners = teams.filter(team => team.ownerId !== null).length;
// Check if user is admin // Check if user is admin
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
// Get all users (only for admins - used in team assignment dropdown) // Get all users (only for admins - used in team assignment dropdown)
const allUsers = isAdmin ? await findAllUsers() : []; const allUsers = isAdmin ? await findAllUsers() : [];
@ -130,11 +131,11 @@ export async function loader(args: Route.LoaderArgs) {
const uniqueOwnerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))]; const uniqueOwnerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
const commissionerUserIds = commissioners.map((c) => c.userId); const commissionerUserIds = commissioners.map((c) => c.userId);
const allUserIds = [...new Set([...commissionerUserIds, ...uniqueOwnerIds])]; const allUserIds = [...new Set([...commissionerUserIds, ...uniqueOwnerIds])];
const userRows = await findUsersByClerkIds(allUserIds); const userRows = await findUsersByIds(allUserIds);
const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u])); const userById = new Map(userRows.map((u) => [u.id, u]));
const commissionerUserData = commissioners.map((c) => { const commissionerUserData = commissioners.map((c) => {
const user = userByClerkId.get(c.userId); const user = userById.get(c.userId);
return { return {
...c, ...c,
userName: user ? (getUserDisplayName(user) ?? "Unknown User") : "Unknown User", userName: user ? (getUserDisplayName(user) ?? "Unknown User") : "Unknown User",
@ -143,16 +144,15 @@ export async function loader(args: Route.LoaderArgs) {
const validOwners = uniqueOwnerIds const validOwners = uniqueOwnerIds
.map((ownerId) => { .map((ownerId) => {
const user = userByClerkId.get(ownerId); const user = userById.get(ownerId);
return user ? { clerkId: ownerId, name: getUserDisplayName(user), id: user.id } : null; return user ? { id: user.id, name: getUserDisplayName(user) } : null;
}) })
.filter((o): o is NonNullable<typeof o> => o !== null); .filter((o): o is NonNullable<typeof o> => o !== null);
const ownerMap = new Map(validOwners.map((o) => [o.clerkId, o.name])); const ownerMap = new Map(validOwners.map((o) => [o.id, o.name]));
// League members (team owners) - available to all commissioners for adding co-commissioners // League members (team owners) - available to all commissioners for adding co-commissioners
const leagueMembers = validOwners.map((o) => ({ const leagueMembers = validOwners.map((o) => ({
id: o.id, id: o.id,
clerkId: o.clerkId,
name: o.name, name: o.name,
})); }));
@ -174,7 +174,8 @@ export async function loader(args: Route.LoaderArgs) {
} }
export async function action(args: Route.ActionArgs) { export async function action(args: Route.ActionArgs) {
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
const { params, request } = args; const { params, request } = args;
const { leagueId } = params; const { leagueId } = params;
@ -236,7 +237,7 @@ export async function action(args: Route.ActionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId: currentSeason.id, seasonId: currentSeason.id,
leagueId, leagueId,
actorClerkId: userId, actorUserId: userId,
action: "league_settings_changed", action: "league_settings_changed",
details: { details: {
changedFields, changedFields,
@ -369,7 +370,7 @@ export async function action(args: Route.ActionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId: season.id, seasonId: season.id,
leagueId, leagueId,
actorClerkId: userId, actorUserId: userId,
action: "draft_order_set", action: "draft_order_set",
affectedTeamIds: teamIds, affectedTeamIds: teamIds,
details: { details: {
@ -401,7 +402,7 @@ export async function action(args: Route.ActionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId: season.id, seasonId: season.id,
leagueId, leagueId,
actorClerkId: userId, actorUserId: userId,
action: "draft_order_randomized", action: "draft_order_randomized",
affectedTeamIds: sortedSlots.map((s) => s.teamId), affectedTeamIds: sortedSlots.map((s) => s.teamId),
details: { details: {
@ -434,27 +435,27 @@ export async function action(args: Route.ActionArgs) {
if (intent === "assign-team-owner") { if (intent === "assign-team-owner") {
const teamId = formData.get("teamId") as string; const teamId = formData.get("teamId") as string;
const userClerkId = formData.get("userClerkId") as string; const assignedUserId = formData.get("userId") as string;
if (!teamId || !userClerkId) { if (!teamId || !assignedUserId) {
return { error: "Team ID and User ID are required" }; return { error: "Team ID and User ID are required" };
} }
// Check if user is admin (only admins can assign owners) // Check if user is admin (only admins can assign owners)
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
if (!isAdmin) { if (!isAdmin) {
return { error: "Only admins can assign team owners" }; return { error: "Only admins can assign team owners" };
} }
// Look up user before assigning to fail fast and get their name // Look up user before assigning to fail fast and get their name
const assignedUser = await findUserByClerkId(userClerkId); const assignedUser = await findUserById(assignedUserId);
if (!assignedUser) { if (!assignedUser) {
return { error: "User not found. Please try again." }; return { error: "User not found. Please try again." };
} }
// Check if user is already assigned to a team in this season // Check if user is already assigned to a team in this season
const teams = await findTeamsBySeasonId(season.id); const teams = await findTeamsBySeasonId(season.id);
const userAlreadyHasTeam = teams.some(team => team.ownerId === userClerkId); const userAlreadyHasTeam = teams.some(team => team.ownerId === assignedUserId);
if (userAlreadyHasTeam) { if (userAlreadyHasTeam) {
return { error: "This user is already assigned to a team in this league" }; return { error: "This user is already assigned to a team in this league" };
@ -468,7 +469,7 @@ export async function action(args: Route.ActionArgs) {
try { try {
const teamName = `Team ${getUserDisplayName(assignedUser) ?? "Member"}`; const teamName = `Team ${getUserDisplayName(assignedUser) ?? "Member"}`;
await claimTeam(teamId, userClerkId, teamName); await claimTeam(teamId, assignedUserId, teamName);
return { success: true, message: "Owner assigned successfully" }; return { success: true, message: "Owner assigned successfully" };
} catch (error) { } catch (error) {
@ -479,7 +480,7 @@ export async function action(args: Route.ActionArgs) {
if (intent === "reset-draft") { if (intent === "reset-draft") {
// Check if user is admin (only admins can reset draft) // Check if user is admin (only admins can reset draft)
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
if (!isAdmin) { if (!isAdmin) {
return { error: "Only admins can reset the draft" }; return { error: "Only admins can reset the draft" };
} }
@ -507,7 +508,7 @@ export async function action(args: Route.ActionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId: season.id, seasonId: season.id,
leagueId, leagueId,
actorClerkId: userId, actorUserId: userId,
action: "draft_reset", action: "draft_reset",
details: { previousPickNumber }, details: { previousPickNumber },
}); });
@ -625,7 +626,7 @@ export async function action(args: Route.ActionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId: season.id, seasonId: season.id,
leagueId, leagueId,
actorClerkId: userId, actorUserId: userId,
action: "draft_settings_changed", action: "draft_settings_changed",
details: { details: {
changedFields: draftFields, changedFields: draftFields,
@ -639,7 +640,7 @@ export async function action(args: Route.ActionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId: season.id, seasonId: season.id,
leagueId, leagueId,
actorClerkId: userId, actorUserId: userId,
action: "scoring_rules_changed", action: "scoring_rules_changed",
details: { details: {
changedFields: scoringChangedFields, changedFields: scoringChangedFields,
@ -690,7 +691,7 @@ export async function action(args: Route.ActionArgs) {
await logCommissionerAction({ await logCommissionerAction({
seasonId: season.id, seasonId: season.id,
leagueId, leagueId,
actorClerkId: userId, actorUserId: userId,
action: "sports_changed", action: "sports_changed",
details: { details: {
added: addedNames, added: addedNames,
@ -769,21 +770,21 @@ export async function action(args: Route.ActionArgs) {
} }
if (intent === "add-commissioner") { if (intent === "add-commissioner") {
const userClerkId = formData.get("userClerkId") as string; const newCommissionerUserId = formData.get("userId") as string;
if (!userClerkId) { if (!newCommissionerUserId) {
return { error: "User is required" }; return { error: "User is required" };
} }
// Check if user already has a commissioner record (don't use isCommissioner — it // Check if user already has a commissioner record (don't use isCommissioner — it
// returns true for site admins, causing a false "already a commissioner" error) // returns true for site admins, causing a false "already a commissioner" error)
const alreadyCommissioner = await hasCommissionerRecord(leagueId, userClerkId); const alreadyCommissioner = await hasCommissionerRecord(leagueId, newCommissionerUserId);
if (alreadyCommissioner) { if (alreadyCommissioner) {
return { error: "This user is already a commissioner" }; return { error: "This user is already a commissioner" };
} }
try { try {
await createCommissioner({ leagueId, userId: userClerkId }); await createCommissioner({ leagueId, userId: newCommissionerUserId });
return { success: true, message: "Commissioner added successfully" }; return { success: true, message: "Commissioner added successfully" };
} catch (error) { } catch (error) {
logger.error("Error adding commissioner:", error); logger.error("Error adding commissioner:", error);
@ -1484,17 +1485,17 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
<Form method="post" className="flex gap-2"> <Form method="post" className="flex gap-2">
<input type="hidden" name="intent" value="assign-team-owner" /> <input type="hidden" name="intent" value="assign-team-owner" />
<input type="hidden" name="teamId" value={team.id} /> <input type="hidden" name="teamId" value={team.id} />
<Select name="userClerkId" required> <Select name="userId" required>
<SelectTrigger className="w-[180px] h-9"> <SelectTrigger className="w-[180px] h-9">
<SelectValue placeholder="Select user" /> <SelectValue placeholder="Select user" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{allUsers.map((user) => { {allUsers.map((user) => {
const userOwnsTeam = teams.some((t) => t.ownerId === user.clerkId); const userOwnsTeam = teams.some((t) => t.ownerId === user.id);
return ( return (
<SelectItem <SelectItem
key={user.id} key={user.id}
value={user.clerkId} value={user.id}
disabled={userOwnsTeam} disabled={userOwnsTeam}
> >
{user.username || user.email} {user.username || user.email}
@ -1581,19 +1582,19 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
<p className="text-sm font-medium mb-3">Add Commissioner</p> <p className="text-sm font-medium mb-3">Add Commissioner</p>
<Form method="post" className="flex gap-2"> <Form method="post" className="flex gap-2">
<input type="hidden" name="intent" value="add-commissioner" /> <input type="hidden" name="intent" value="add-commissioner" />
<Select name="userClerkId" required> <Select name="userId" required>
<SelectTrigger className="flex-1"> <SelectTrigger className="flex-1">
<SelectValue placeholder="Select a user" /> <SelectValue placeholder="Select a user" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{leagueMembers.map((member) => { {leagueMembers.map((member) => {
const alreadyCommissioner = commissioners.some( const alreadyCommissioner = commissioners.some(
(c) => c.userId === member.clerkId (c) => c.userId === member.id
); );
return ( return (
<SelectItem <SelectItem
key={member.id} key={member.id}
value={member.clerkId} value={member.id}
disabled={alreadyCommissioner} disabled={alreadyCommissioner}
> >
{member.name || "Unknown"} {member.name || "Unknown"}

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId"; import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
import { import {
findLeagueById, findLeagueById,
@ -29,7 +29,8 @@ import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
const { params } = args; const { params } = args;
const { leagueId, sportsSeasonId } = params; const { leagueId, sportsSeasonId } = params;
@ -102,12 +103,12 @@ export async function loader(args: Route.LoaderArgs) {
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter(Boolean))] as string[]; const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter(Boolean))] as string[];
const ownerUserRows = ownerIds.length > 0 const ownerUserRows = ownerIds.length > 0
? await db.query.users.findMany({ ? await db.query.users.findMany({
where: inArray(schema.users.clerkId, ownerIds), where: inArray(schema.users.id, ownerIds),
columns: { clerkId: true, username: true, displayName: true }, columns: { id: true, username: true, displayName: true },
}) })
: []; : [];
const ownerMap = new Map( const ownerMap = new Map(
ownerUserRows.map((u) => [u.clerkId, getUserDisplayName(u) ?? "Unknown"]) ownerUserRows.map((u) => [u.id, getUserDisplayName(u) ?? "Unknown"])
); );
// Map draft picks to ownership // Map draft picks to ownership

View file

@ -9,7 +9,7 @@ import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/
import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server"; import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
import { getRecentTeamScoreEvents } from "~/models/team-score-events"; import { getRecentTeamScoreEvents } from "~/models/team-score-events";
import { PointProgressionChart } from "~/components/standings/PointProgressionChart"; import { PointProgressionChart } from "~/components/standings/PointProgressionChart";
import { findUsersByClerkIds, getUserDisplayName } from "~/models/user"; import { findUsersByIds, getUserDisplayName } from "~/models/user";
import { StandingsPreview } from "~/components/league/StandingsPreview"; import { StandingsPreview } from "~/components/league/StandingsPreview";
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display"; import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
import { RecentScoresCard } from "~/components/standings/RecentScoresCard"; import { RecentScoresCard } from "~/components/standings/RecentScoresCard";
@ -69,13 +69,13 @@ export async function loader(args: Route.LoaderArgs) {
// Build teamId -> ownerName map // Build teamId -> ownerName map
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))]; const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
const userRows = await findUsersByClerkIds(ownerIds); const userRows = await findUsersByIds(ownerIds);
const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u])); const userById = new Map(userRows.map((u) => [u.id, u]));
const ownerNameByTeamId = new Map( const ownerNameByTeamId = new Map(
teams teams
.filter((t): t is typeof t & { ownerId: string } => t.ownerId !== null) .filter((t): t is typeof t & { ownerId: string } => t.ownerId !== null)
.map((t) => { .map((t) => {
const user = userByClerkId.get(t.ownerId); const user = userById.get(t.ownerId);
return [t.id, user ? getUserDisplayName(user) : null] as const; return [t.id, user ? getUserDisplayName(user) : null] as const;
}) })
); );

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { addDays, subDays } from "date-fns"; import { addDays, subDays } from "date-fns";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { Link } from "react-router"; import { Link } from "react-router";
@ -23,7 +23,8 @@ export function meta({ data }: Route.MetaArgs) {
} }
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
const { leagueId } = args.params; const { leagueId } = args.params;
const league = await findLeagueById(leagueId); const league = await findLeagueById(leagueId);

View file

@ -9,11 +9,11 @@ vi.mock("~/database/context");
vi.mock("~/server/socket", () => ({ vi.mock("~/server/socket", () => ({
getSocketIO: vi.fn(), getSocketIO: vi.fn(),
})); }));
vi.mock("@clerk/react-router/server", () => ({ vi.mock("~/lib/auth.server", () => ({
getAuth: vi.fn(), auth: { api: { getSession: vi.fn() } },
})); }));
vi.mock("~/models/user", () => ({ vi.mock("~/models/user", () => ({
isUserAdminByClerkId: vi.fn().mockResolvedValue(false), isUserAdmin: vi.fn().mockResolvedValue(false),
})); }));
describe("Autodraft Settings API", () => { describe("Autodraft Settings API", () => {
@ -23,9 +23,9 @@ describe("Autodraft Settings API", () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
// Mock Clerk auth // Mock auth session
const { getAuth } = await import("@clerk/react-router/server"); const { auth } = await import("~/lib/auth.server");
vi.mocked(getAuth).mockResolvedValue({ userId: "user-789" } as any); vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: "user-789" } } as any);
// Mock Socket.IO // Mock Socket.IO
mockSocketIO = { mockSocketIO = {
@ -389,8 +389,8 @@ describe("Autodraft Settings API", () => {
const teamId = "team-xyz"; const teamId = "team-xyz";
const userId = "user-123"; const userId = "user-123";
const { getAuth } = await import("@clerk/react-router/server"); const { auth } = await import("~/lib/auth.server");
vi.mocked(getAuth).mockResolvedValue({ userId } as any); vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: userId } } as any);
mockDb.query.teams.findFirst.mockResolvedValue({ mockDb.query.teams.findFirst.mockResolvedValue({
id: teamId, id: teamId,

View file

@ -3,7 +3,7 @@ import { deleteAllDraftPicks } from '~/models/draft-pick';
import { clearAllQueuesForSeason } from '~/models/draft-queue'; import { clearAllQueuesForSeason } from '~/models/draft-queue';
import { deleteSeasonTimers } from '~/models/draft-timer'; import { deleteSeasonTimers } from '~/models/draft-timer';
import { updateSeason } from '~/models/season'; import { updateSeason } from '~/models/season';
import { isUserAdminByClerkId } from '~/models/user'; import { isUserAdmin } from '~/models/user';
// Helper function to create mock season objects with all required properties // Helper function to create mock season objects with all required properties
const createMockSeason = (overrides: { const createMockSeason = (overrides: {
@ -58,7 +58,7 @@ vi.mock('~/models/season', () => ({
})); }));
vi.mock('~/models/user', () => ({ vi.mock('~/models/user', () => ({
isUserAdminByClerkId: vi.fn(), isUserAdmin: vi.fn(),
})); }));
describe('Draft Reset - Authorization', () => { describe('Draft Reset - Authorization', () => {
@ -69,20 +69,20 @@ describe('Draft Reset - Authorization', () => {
it('should only allow admins to reset draft', async () => { it('should only allow admins to reset draft', async () => {
const userId = 'admin-user-id'; const userId = 'admin-user-id';
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true); vi.mocked(isUserAdmin).mockResolvedValue(true);
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
expect(isAdmin).toBe(true); expect(isAdmin).toBe(true);
expect(isUserAdminByClerkId).toHaveBeenCalledWith(userId); expect(isUserAdmin).toHaveBeenCalledWith(userId);
}); });
it('should reject non-admin users from resetting draft', async () => { it('should reject non-admin users from resetting draft', async () => {
const userId = 'regular-user-id'; const userId = 'regular-user-id';
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false); vi.mocked(isUserAdmin).mockResolvedValue(false);
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
expect(isAdmin).toBe(false); expect(isAdmin).toBe(false);
@ -98,9 +98,9 @@ describe('Draft Reset - Authorization', () => {
const commissionerUserId = 'commissioner-user-id'; const commissionerUserId = 'commissioner-user-id';
// Commissioner but not admin // Commissioner but not admin
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false); vi.mocked(isUserAdmin).mockResolvedValue(false);
const isAdmin = await isUserAdminByClerkId(commissionerUserId); const isAdmin = await isUserAdmin(commissionerUserId);
expect(isAdmin).toBe(false); expect(isAdmin).toBe(false);
}); });
@ -243,8 +243,8 @@ describe('Draft Reset - Complete Workflow', () => {
const userId = 'admin-user-id'; const userId = 'admin-user-id';
// Step 1: Verify admin // Step 1: Verify admin
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true); vi.mocked(isUserAdmin).mockResolvedValue(true);
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
expect(isAdmin).toBe(true); expect(isAdmin).toBe(true);
// Step 2: Delete draft picks // Step 2: Delete draft picks
@ -285,8 +285,8 @@ describe('Draft Reset - Complete Workflow', () => {
it('should not proceed with reset if admin check fails', async () => { it('should not proceed with reset if admin check fails', async () => {
const userId = 'regular-user-id'; const userId = 'regular-user-id';
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false); vi.mocked(isUserAdmin).mockResolvedValue(false);
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
expect(isAdmin).toBe(false); expect(isAdmin).toBe(false);

View file

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { removeTeamOwner, assignTeamOwner } from '~/models/team'; import { removeTeamOwner, assignTeamOwner } from '~/models/team';
import { findAllUsers, isUserAdminByClerkId } from '~/models/user'; import { findAllUsers, isUserAdmin } from '~/models/user';
// Helper function to create mock team objects with all required properties // Helper function to create mock team objects with all required properties
const createMockTeam = (overrides: { const createMockTeam = (overrides: {
@ -31,8 +31,8 @@ vi.mock('~/models/team', () => ({
vi.mock('~/models/user', () => ({ vi.mock('~/models/user', () => ({
findAllUsers: vi.fn(), findAllUsers: vi.fn(),
findUserByClerkId: vi.fn(), findUserById: vi.fn(),
isUserAdminByClerkId: vi.fn(), isUserAdmin: vi.fn(),
})); }));
vi.mock('~/models/league', () => ({ vi.mock('~/models/league', () => ({
@ -138,21 +138,21 @@ describe('Team Management - Assign Owner', () => {
const userId = 'admin-user-id'; const userId = 'admin-user-id';
// Mock admin check // Mock admin check
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true); vi.mocked(isUserAdmin).mockResolvedValue(true);
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
expect(isAdmin).toBe(true); expect(isAdmin).toBe(true);
expect(isUserAdminByClerkId).toHaveBeenCalledWith(userId); expect(isUserAdmin).toHaveBeenCalledWith(userId);
}); });
it('should prevent non-admins from assigning owners', async () => { it('should prevent non-admins from assigning owners', async () => {
const userId = 'regular-user-id'; const userId = 'regular-user-id';
// Mock non-admin check // Mock non-admin check
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false); vi.mocked(isUserAdmin).mockResolvedValue(false);
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
expect(isAdmin).toBe(false); expect(isAdmin).toBe(false);
}); });
@ -182,8 +182,9 @@ describe('Team Management - Admin User List', () => {
const mockUsers = [ const mockUsers = [
{ {
id: 'user-1', id: 'user-1',
clerkId: 'clerk-1', clerkId: null,
email: 'user1@example.com', email: 'user1@example.com',
emailVerified: true,
username: 'user1', username: 'user1',
displayName: 'User One', displayName: 'User One',
firstName: 'User', firstName: 'User',
@ -195,8 +196,9 @@ describe('Team Management - Admin User List', () => {
}, },
{ {
id: 'user-2', id: 'user-2',
clerkId: 'clerk-2', clerkId: null,
email: 'user2@example.com', email: 'user2@example.com',
emailVerified: true,
username: 'user2', username: 'user2',
displayName: 'User Two', displayName: 'User Two',
firstName: 'User', firstName: 'User',
@ -221,16 +223,17 @@ describe('Team Management - Admin User List', () => {
it('should only fetch users when user is admin', async () => { it('should only fetch users when user is admin', async () => {
const userId = 'admin-user-id'; const userId = 'admin-user-id';
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true); vi.mocked(isUserAdmin).mockResolvedValue(true);
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
if (isAdmin) { if (isAdmin) {
const mockUsers = [ const mockUsers = [
{ {
id: 'user-1', id: 'user-1',
clerkId: 'clerk-1', clerkId: null,
email: 'user1@example.com', email: 'user1@example.com',
emailVerified: true,
username: 'user1', username: 'user1',
displayName: 'User One', displayName: 'User One',
firstName: 'User', firstName: 'User',
@ -254,9 +257,9 @@ describe('Team Management - Admin User List', () => {
it('should return empty array for non-admin users', async () => { it('should return empty array for non-admin users', async () => {
const userId = 'regular-user-id'; const userId = 'regular-user-id';
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false); vi.mocked(isUserAdmin).mockResolvedValue(false);
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
// Simulate loader behavior: only fetch users if admin // Simulate loader behavior: only fetch users if admin
const users = isAdmin ? await findAllUsers() : []; const users = isAdmin ? await findAllUsers() : [];
@ -275,20 +278,20 @@ describe('Team Management - Authorization', () => {
it('should verify admin status before allowing assignment', async () => { it('should verify admin status before allowing assignment', async () => {
const userId = 'test-user-id'; const userId = 'test-user-id';
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true); vi.mocked(isUserAdmin).mockResolvedValue(true);
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
expect(isAdmin).toBe(true); expect(isAdmin).toBe(true);
expect(isUserAdminByClerkId).toHaveBeenCalledWith(userId); expect(isUserAdmin).toHaveBeenCalledWith(userId);
}); });
it('should reject assignment for non-admin users', async () => { it('should reject assignment for non-admin users', async () => {
const userId = 'regular-user-id'; const userId = 'regular-user-id';
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false); vi.mocked(isUserAdmin).mockResolvedValue(false);
const isAdmin = await isUserAdminByClerkId(userId); const isAdmin = await isUserAdmin(userId);
expect(isAdmin).toBe(false); expect(isAdmin).toBe(false);

View file

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Form, redirect, Link } from "react-router"; import { Form, redirect, Link } from "react-router";
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import type { Route } from "./+types/new"; import type { Route } from "./+types/new";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
@ -71,7 +71,8 @@ export async function loader() {
} }
export async function action(args: Route.ActionArgs) { export async function action(args: Route.ActionArgs) {
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
const { request } = args; const { request } = args;
if (!userId) { if (!userId) {

120
app/routes/login.tsx Normal file
View file

@ -0,0 +1,120 @@
import { useState } from "react";
import { Link, useSearchParams, redirect } from "react-router";
import { auth } from "~/lib/auth.server";
import { authClient } from "~/lib/auth-client";
import type { Route } from "./+types/login";
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: "Sign In - Brackt" }];
}
function safeRedirectTo(value: string | null): string {
if (!value) return "/";
return value.startsWith("/") && !value.startsWith("//") ? value : "/";
}
export async function loader(args: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
if (session) {
const raw = new URL(args.request.url).searchParams.get("redirectTo");
return redirect(safeRedirectTo(raw));
}
return {};
}
export default function LoginPage() {
const [searchParams] = useSearchParams();
const redirectTo = safeRedirectTo(searchParams.get("redirectTo"));
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleEmailSignIn(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const form = e.currentTarget;
const email = (form.elements.namedItem("email") as HTMLInputElement).value;
const password = (form.elements.namedItem("password") as HTMLInputElement).value;
const { error: signInError } = await authClient.signIn.email({
email,
password,
callbackURL: redirectTo,
});
if (signInError) {
setError(signInError.message ?? "Sign in failed. Please check your credentials.");
setLoading(false);
}
}
async function handleSocialSignIn(provider: "google" | "discord") {
await authClient.signIn.social({ provider, callbackURL: redirectTo });
}
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">Sign in</CardTitle>
<CardDescription>Sign in to your Brackt account</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Button
variant="outline"
className="w-full"
onClick={() => handleSocialSignIn("google")}
type="button"
>
Continue with Google
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => handleSocialSignIn("discord")}
type="button"
>
Continue with Discord
</Button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">or</span>
</div>
</div>
<form onSubmit={handleEmailSignIn} 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>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" name="password" type="password" required autoComplete="current-password" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in…" : "Sign In"}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link to={`/register?redirectTo=${encodeURIComponent(redirectTo)}`} className="underline">
Create one
</Link>
</p>
</CardContent>
</Card>
</div>
);
}

126
app/routes/register.tsx Normal file
View file

@ -0,0 +1,126 @@
import { useState } from "react";
import { Link, useSearchParams, redirect } from "react-router";
import { auth } from "~/lib/auth.server";
import { authClient } from "~/lib/auth-client";
import type { Route } from "./+types/register";
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: "Create Account - Brackt" }];
}
function safeRedirectTo(value: string | null): string {
if (!value) return "/";
return value.startsWith("/") && !value.startsWith("//") ? value : "/";
}
export async function loader(args: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
if (session) {
const raw = new URL(args.request.url).searchParams.get("redirectTo");
return redirect(safeRedirectTo(raw));
}
return {};
}
export default function RegisterPage() {
const [searchParams] = useSearchParams();
const redirectTo = safeRedirectTo(searchParams.get("redirectTo"));
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleRegister(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const form = e.currentTarget;
const name = (form.elements.namedItem("name") as HTMLInputElement).value;
const email = (form.elements.namedItem("email") as HTMLInputElement).value;
const password = (form.elements.namedItem("password") as HTMLInputElement).value;
const { error: signUpError } = await authClient.signUp.email({
name,
email,
password,
callbackURL: redirectTo,
});
if (signUpError) {
setError(signUpError.message ?? "Registration failed. Please try again.");
setLoading(false);
}
}
async function handleSocialSignIn(provider: "google" | "discord") {
await authClient.signIn.social({ provider, callbackURL: redirectTo });
}
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">Create account</CardTitle>
<CardDescription>Get started with Brackt</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Button
variant="outline"
className="w-full"
onClick={() => handleSocialSignIn("google")}
type="button"
>
Continue with Google
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => handleSocialSignIn("discord")}
type="button"
>
Continue with Discord
</Button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">or</span>
</div>
</div>
<form onSubmit={handleRegister} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Display Name</Label>
<Input id="name" name="name" type="text" required autoComplete="name" />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" required autoComplete="email" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" name="password" type="password" required autoComplete="new-password" minLength={8} />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Creating account…" : "Create Account"}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link to={`/login?redirectTo=${encodeURIComponent(redirectTo)}`} className="underline">
Sign in
</Link>
</p>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,5 +1,5 @@
import { Form, redirect, useNavigate } from "react-router"; import { Form, redirect, useNavigate } from "react-router";
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import type { Route } from "./+types/$teamId.settings"; import type { Route } from "./+types/$teamId.settings";
import { import {
@ -37,7 +37,8 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const { params } = args; const { params } = args;
const { teamId } = params; const { teamId } = params;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
throw new Response("You must be logged in", { status: 401 }); throw new Response("You must be logged in", { status: 401 });
@ -66,7 +67,8 @@ export async function loader(args: Route.LoaderArgs) {
export async function action(args: Route.ActionArgs) { export async function action(args: Route.ActionArgs) {
const { params, request } = args; const { params, request } = args;
const { teamId } = params; const { teamId } = params;
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
throw new Response("You must be logged in", { status: 401 }); throw new Response("You must be logged in", { status: 401 });

View file

@ -1,4 +1,4 @@
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { addDays, subDays } from "date-fns"; import { addDays, subDays } from "date-fns";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { Link } from "react-router"; import { Link } from "react-router";
@ -18,7 +18,8 @@ export function meta() {
} }
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!userId) { if (!userId) {
return { isLoggedIn: false as const, upcomingCalendarEvents: [] }; return { isLoggedIn: false as const, upcomingCalendarEvents: [] };

View file

@ -1,26 +1,106 @@
import { UserProfile } from "@clerk/react-router"; import { redirect, Form } from "react-router";
import { getAuth } from "@clerk/react-router/server"; import { auth } from "~/lib/auth.server";
import { redirect } from "react-router"; import { findUserById, updateUser } from "~/models/user";
import type { Route } from "./+types/user-profile"; import type { Route } from "./+types/user-profile";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
export function meta(): Route.MetaDescriptors { export function meta(): Route.MetaDescriptors {
return [{ title: "Profile - Brackt" }]; return [{ title: "Profile - Brackt" }];
} }
export async function loader(args: Route.LoaderArgs) { export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args); const session = await auth.api.getSession({ headers: args.request.headers });
if (!session) {
if (!userId) { return redirect("/login?redirectTo=/user-profile");
}
const user = await findUserById(session.user.id);
if (!user) {
return redirect("/"); return redirect("/");
} }
return { user };
return {};
} }
export default function UserProfilePage() { export async function action(args: Route.ActionArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
if (!session) {
return redirect("/login?redirectTo=/user-profile");
}
const formData = await args.request.formData();
const displayName = formData.get("displayName") as string;
const username = formData.get("username") as string | null;
const firstName = formData.get("firstName") as string | null;
const lastName = formData.get("lastName") as string | null;
await updateUser(session.user.id, {
displayName: displayName || undefined,
username: username || undefined,
firstName: firstName || undefined,
lastName: lastName || undefined,
});
return { success: true };
}
export default function UserProfilePage({ loaderData, actionData }: Route.ComponentProps) {
const { user } = loaderData;
return ( return (
<div className="container mx-auto py-8 px-4 flex justify-center"> <div className="container mx-auto py-8 px-4 max-w-lg">
<UserProfile /> <Card>
<CardHeader>
<CardTitle>Your Profile</CardTitle>
</CardHeader>
<CardContent>
{actionData?.success && (
<p className="text-sm text-green-500 mb-4">Profile updated successfully.</p>
)}
<Form method="post" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
name="displayName"
defaultValue={user.displayName ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
defaultValue={user.username ?? ""}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<Input
id="firstName"
name="firstName"
defaultValue={user.firstName ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input
id="lastName"
name="lastName"
defaultValue={user.lastName ?? ""}
/>
</div>
</div>
<div className="space-y-1">
<Label>Email</Label>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
<Button type="submit" className="w-full">Save Changes</Button>
</Form>
</CardContent>
</Card>
</div> </div>
); );
} }

View file

@ -2,6 +2,7 @@ export const mockUser = {
id: 'user-1', id: 'user-1',
clerkId: 'clerk-user-1', clerkId: 'clerk-user-1',
email: 'user1@example.com', email: 'user1@example.com',
emailVerified: true,
username: 'user1', username: 'user1',
displayName: 'User One', displayName: 'User One',
firstName: 'User', firstName: 'User',
@ -16,6 +17,7 @@ export const mockAdminUser = {
id: 'admin-1', id: 'admin-1',
clerkId: 'clerk-admin-1', clerkId: 'clerk-admin-1',
email: 'admin@example.com', email: 'admin@example.com',
emailVerified: true,
username: 'admin', username: 'admin',
displayName: 'Admin User', displayName: 'Admin User',
firstName: 'Admin', firstName: 'Admin',
@ -32,6 +34,7 @@ export const mockUsers = [
id: 'user-2', id: 'user-2',
clerkId: 'clerk-user-2', clerkId: 'clerk-user-2',
email: 'user2@example.com', email: 'user2@example.com',
emailVerified: true,
username: 'user2', username: 'user2',
displayName: 'User Two', displayName: 'User Two',
firstName: 'User', firstName: 'User',
@ -45,6 +48,7 @@ export const mockUsers = [
id: 'user-3', id: 'user-3',
clerkId: 'clerk-user-3', clerkId: 'clerk-user-3',
email: 'user3@example.com', email: 'user3@example.com',
emailVerified: true,
username: 'user3', username: 'user3',
displayName: 'User Three', displayName: 'User Three',
firstName: 'User', firstName: 'User',

View file

@ -10,11 +10,16 @@ afterEach(() => {
// Mock environment variables // Mock environment variables
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
// Mock Clerk // Mock BetterAuth
vi.mock('@clerk/react-router/server', () => ({ vi.mock('~/lib/auth.server', () => ({
getAuth: vi.fn(() => Promise.resolve({ userId: 'test-user-id' })), auth: {
clerkMiddleware: vi.fn(() => (req: unknown, res: unknown, next: () => void) => next()), api: {
rootAuthLoader: vi.fn(() => Promise.resolve({ userId: 'test-user-id' })), getSession: vi.fn(() => Promise.resolve({
user: { id: 'test-user-id', email: 'test@example.com', name: 'Test User' },
session: { userId: 'test-user-id' },
})),
},
},
})); }));
// Mock database context // Mock database context

View file

@ -1,21 +1,59 @@
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, index, jsonb } from "drizzle-orm/pg-core"; import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, index, jsonb } from "drizzle-orm/pg-core";
import { relations, sql } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
// Users table - synced from Clerk
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 }).notNull().unique(), clerkId: varchar("clerk_id", { length: 255 }).unique(), // nullable: new users won't have one
email: varchar("email", { length: 255 }).notNull(), email: varchar("email", { length: 255 }).notNull(),
emailVerified: boolean("email_verified").notNull().default(false),
username: varchar("username", { length: 255 }), username: varchar("username", { length: 255 }),
displayName: varchar("display_name", { length: 255 }), displayName: varchar("display_name", { length: 255 }), // mapped as BetterAuth's "name" field
firstName: varchar("first_name", { length: 255 }), firstName: varchar("first_name", { length: 255 }),
lastName: varchar("last_name", { length: 255 }), lastName: varchar("last_name", { length: 255 }),
imageUrl: varchar("image_url", { length: 512 }), imageUrl: varchar("image_url", { length: 512 }), // mapped as BetterAuth's "image" field
isAdmin: boolean("is_admin").notNull().default(false), isAdmin: boolean("is_admin").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); });
// BetterAuth session/account/verification tables
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
});
export const accounts = pgTable("accounts", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
expiresAt: timestamp("expires_at"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const verifications = pgTable("verifications", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
// Fantasy League Tables // Fantasy League Tables
export const seasonStatusEnum = pgEnum("season_status", [ export const seasonStatusEnum = pgEnum("season_status", [
"pre_draft", "pre_draft",

View file

@ -0,0 +1,49 @@
CREATE TABLE IF NOT EXISTS "accounts" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" uuid NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"expires_at" timestamp,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "sessions" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"user_id" uuid NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"ip_address" text,
"user_agent" text,
CONSTRAINT "sessions_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "verifications" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "clerk_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "email_verified" boolean DEFAULT false NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint

View file

@ -0,0 +1,3 @@
ALTER TABLE "accounts" ADD COLUMN "access_token_expires_at" timestamp;--> statement-breakpoint
ALTER TABLE "accounts" ADD COLUMN "refresh_token_expires_at" timestamp;--> statement-breakpoint
ALTER TABLE "accounts" ADD COLUMN "scope" text;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -568,6 +568,20 @@
"when": 1776318182740, "when": 1776318182740,
"tag": "0080_chemical_gorilla_man", "tag": "0080_chemical_gorilla_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 81,
"version": "7",
"when": 1777067050669,
"tag": "0081_careless_magdalene",
"breakpoints": true
},
{
"idx": 82,
"version": "7",
"when": 1777090754037,
"tag": "0082_stale_pride",
"breakpoints": true
} }
] ]
} }

535
package-lock.json generated
View file

@ -6,8 +6,6 @@
"": { "": {
"name": "brackt.com", "name": "brackt.com",
"dependencies": { "dependencies": {
"@clerk/react-router": "^2.1.0",
"@clerk/themes": "^2.4.55",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@ -31,6 +29,8 @@
"@sentry/react-router": "^10.43.0", "@sentry/react-router": "^10.43.0",
"@tanstack/react-virtual": "^3.13.24", "@tanstack/react-virtual": "^3.13.24",
"@types/nprogress": "^0.2.3", "@types/nprogress": "^0.2.3",
"bcrypt": "^6.0.0",
"better-auth": "^1.6.9",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"compression": "^1.8.0", "compression": "^1.8.0",
@ -52,7 +52,6 @@
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"svix": "^1.77.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
@ -67,6 +66,7 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/bcrypt": "^6.0.0",
"@types/compression": "^1.8.1", "@types/compression": "^1.8.1",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/express-serve-static-core": "^5.0.6", "@types/express-serve-static-core": "^5.0.6",
@ -671,126 +671,150 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@clerk/backend": { "node_modules/@better-auth/core": {
"version": "2.17.2", "version": "1.6.9",
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-2.17.2.tgz", "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.9.tgz",
"integrity": "sha512-zgKySfoOXySYOMEDc+S2vXLchCldwPVb85tyvP1NnmxvgyAm10yCA+xd4No4dNWm+lwkwHuNWX3rM9ro8khSmw==", "integrity": "sha512-ADFk5pwmLybmc+LvYvXJ6M1x2oY/EyYLkwLuH0x28FUq12DfjL0wnE7g+WRDf3yozDO+qIxTpFGXDGwLKbfz0w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@clerk/shared": "^3.27.3", "@opentelemetry/semantic-conventions": "^1.39.0",
"@clerk/types": "^4.92.0", "@standard-schema/spec": "^1.1.0",
"cookie": "1.0.2", "zod": "^4.3.6"
"standardwebhooks": "^1.0.0",
"tslib": "2.8.1"
},
"engines": {
"node": ">=18.17.0"
}
},
"node_modules/@clerk/backend/node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@clerk/clerk-react": {
"version": "5.51.0",
"resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.51.0.tgz",
"integrity": "sha512-jBreKiUS4DKm+JUIt59B1699aRr3wQmQ1O+DlWCEY3iEz+F6ETir1SJcRNG5mHVoA9EGn+m93USJSUWZBRG8yQ==",
"license": "MIT",
"dependencies": {
"@clerk/shared": "^3.27.3",
"@clerk/types": "^4.92.0",
"tslib": "2.8.1"
},
"engines": {
"node": ">=18.17.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "@better-auth/utils": "0.4.0",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" "@better-fetch/fetch": "1.1.21",
} "@cloudflare/workers-types": ">=4",
}, "@opentelemetry/api": "^1.9.0",
"node_modules/@clerk/react-router": { "better-call": "1.3.5",
"version": "2.1.0", "jose": "^6.1.0",
"resolved": "https://registry.npmjs.org/@clerk/react-router/-/react-router-2.1.0.tgz", "kysely": "^0.28.5",
"integrity": "sha512-qAfMxiiPmSs7Vv5oj1qXYedyCrGnL4c/susr2P74BW07Fyz13wT1/ZEJ5Y4g+u1GGSPxvbQtv8OWsNIsulclKw==", "nanostores": "^1.0.1"
"license": "MIT",
"dependencies": {
"@clerk/backend": "^2.17.2",
"@clerk/clerk-react": "^5.51.0",
"@clerk/shared": "^3.27.3",
"@clerk/types": "^4.92.0",
"cookie": "0.7.2",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-0",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0",
"react-router": "^7.9.0"
}
},
"node_modules/@clerk/shared": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.47.0.tgz",
"integrity": "sha512-EDWFysptTc58X96MGQIZ3LlcMFKLG+rhIF9kf6n+wnyQDWnfuyA8I8ge7GbjfUXMf00c//A/CGSjg7t/oupUpw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"csstype": "3.1.3",
"dequal": "2.0.3",
"glob-to-regexp": "0.4.1",
"js-cookie": "3.0.5",
"std-env": "^3.9.0",
"swr": "2.3.4"
},
"engines": {
"node": ">=18.17.0"
},
"peerDependencies": {
"react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0",
"react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"react": { "@cloudflare/workers-types": {
"optional": true "optional": true
}, },
"react-dom": { "@opentelemetry/api": {
"optional": true "optional": true
} }
} }
}, },
"node_modules/@clerk/themes": { "node_modules/@better-auth/drizzle-adapter": {
"version": "2.4.55", "version": "1.6.9",
"resolved": "https://registry.npmjs.org/@clerk/themes/-/themes-2.4.55.tgz", "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.9.tgz",
"integrity": "sha512-j9q8NtAaI2f7vNBuO2RAUDmAebab2UoZCXshlTzEhsbB1UH+94fPs4KyUlsbrSNxIJNfTrM2IKxAZKos3gcCJw==", "integrity": "sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw==",
"license": "MIT", "license": "MIT",
"dependencies": { "peerDependencies": {
"@clerk/shared": "^3.47.0", "@better-auth/core": "^1.6.9",
"tslib": "2.8.1" "@better-auth/utils": "0.4.0",
"drizzle-orm": "^0.45.2"
}, },
"engines": { "peerDependenciesMeta": {
"node": ">=18.17.0" "drizzle-orm": {
"optional": true
}
} }
}, },
"node_modules/@clerk/types": { "node_modules/@better-auth/kysely-adapter": {
"version": "4.92.0", "version": "1.6.9",
"resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.92.0.tgz", "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.9.tgz",
"integrity": "sha512-+bUiHjqVXEHJIOOhshIy3uYDF/c4/yNc2BPfgPTXxxsbz/2wG0XUx0PL+mxUPiruPZOD+D63AtmORuFW3yBa2w==", "integrity": "sha512-gyjuuxJtZ4o9G9z9q4kqn24X2kvMSp7F+KHogYxF03SnXY/2WleAcuj57iC4wP3e9mGDbjPOrnM5K6Kr3Ktdpw==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.6.9",
"@better-auth/utils": "0.4.0",
"kysely": "^0.28.14"
},
"peerDependenciesMeta": {
"kysely": {
"optional": true
}
}
},
"node_modules/@better-auth/memory-adapter": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.9.tgz",
"integrity": "sha512-XmIG4tUnOXZ+KEcWjHUjOI9Z5donD09dC2t/AQTXifAUIqx7cySg86w0KTM09ArzAxRx1fCqO36Wkt5nULnrkQ==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.6.9",
"@better-auth/utils": "0.4.0"
}
},
"node_modules/@better-auth/mongo-adapter": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.9.tgz",
"integrity": "sha512-h+AiRJ/TsBSi+ZDjySASBpbJ/9QCXBre34PSKgCz7QmTHrFM9Cg2EM4AM7LjR5lPXipEE+2rWPBc9wfnUBjhcw==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.6.9",
"@better-auth/utils": "0.4.0",
"mongodb": "^6.0.0 || ^7.0.0"
},
"peerDependenciesMeta": {
"mongodb": {
"optional": true
}
}
},
"node_modules/@better-auth/prisma-adapter": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.9.tgz",
"integrity": "sha512-XHks01ntK20orqK/jICq8wmEbJ/zT6dct49Fk8zTQKN9QNGDc+Ix5+7z/Kvui0DXGFf790GfvRozquzaLtXa8Q==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.6.9",
"@better-auth/utils": "0.4.0",
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0"
},
"peerDependenciesMeta": {
"@prisma/client": {
"optional": true
},
"prisma": {
"optional": true
}
}
},
"node_modules/@better-auth/telemetry": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.9.tgz",
"integrity": "sha512-0u5zkhSCAQFoN3DHvUkLHOF6MBbVTDAa6mU8mhPwiysdz1x21vMzhzfaAKN/ZGWaQ09v91/F+2qu42G/bhUV4A==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.6.9",
"@better-auth/utils": "0.4.0",
"@better-fetch/fetch": "1.1.21"
}
},
"node_modules/@better-auth/utils": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.0.tgz",
"integrity": "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "3.1.3" "@noble/hashes": "^2.0.1"
},
"engines": {
"node": ">=18.17.0"
} }
}, },
"node_modules/@better-auth/utils/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@better-fetch/fetch": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz",
"integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="
},
"node_modules/@csstools/color-helpers": { "node_modules/@csstools/color-helpers": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@ -4977,6 +5001,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4990,6 +5015,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5003,6 +5029,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5016,6 +5043,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5029,6 +5057,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5042,6 +5071,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5055,6 +5085,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5068,6 +5099,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5081,6 +5113,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5094,6 +5127,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5107,6 +5141,7 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5120,6 +5155,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5133,6 +5169,7 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5146,6 +5183,7 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5159,6 +5197,7 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5172,6 +5211,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5185,6 +5225,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5198,6 +5239,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5211,6 +5253,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5224,6 +5267,7 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5237,6 +5281,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5250,6 +5295,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5983,9 +6029,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.0.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/utils": { "node_modules/@standard-schema/utils": {
@ -6706,6 +6752,16 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@ -6837,6 +6893,7 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/express": { "node_modules/@types/express": {
@ -6957,7 +7014,7 @@
"version": "19.2.2", "version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
@ -6967,7 +7024,7 @@
"version": "19.2.1", "version": "19.2.1",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==", "integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
@ -7772,6 +7829,20 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/bcrypt-pbkdf": { "node_modules/bcrypt-pbkdf": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
@ -7782,6 +7853,161 @@
"tweetnacl": "^0.14.3" "tweetnacl": "^0.14.3"
} }
}, },
"node_modules/better-auth": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.9.tgz",
"integrity": "sha512-EBFURtglyiEZxbx4NJBoqUD8J65dX24yC+6I9AUbIXNgUkt76mshzGbHkxZ3n/lB7Dwq3kBC+hHt0hUQsnL7HA==",
"license": "MIT",
"dependencies": {
"@better-auth/core": "1.6.9",
"@better-auth/drizzle-adapter": "1.6.9",
"@better-auth/kysely-adapter": "1.6.9",
"@better-auth/memory-adapter": "1.6.9",
"@better-auth/mongo-adapter": "1.6.9",
"@better-auth/prisma-adapter": "1.6.9",
"@better-auth/telemetry": "1.6.9",
"@better-auth/utils": "0.4.0",
"@better-fetch/fetch": "1.1.21",
"@noble/ciphers": "^2.1.1",
"@noble/hashes": "^2.0.1",
"better-call": "1.3.5",
"defu": "^6.1.4",
"jose": "^6.1.3",
"kysely": "^0.28.14",
"nanostores": "^1.1.1",
"zod": "^4.3.6"
},
"peerDependencies": {
"@lynx-js/react": "*",
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
"@sveltejs/kit": "^2.0.0",
"@tanstack/react-start": "^1.0.0",
"@tanstack/solid-start": "^1.0.0",
"better-sqlite3": "^12.0.0",
"drizzle-kit": ">=0.31.4",
"drizzle-orm": "^0.45.2",
"mongodb": "^6.0.0 || ^7.0.0",
"mysql2": "^3.0.0",
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
"pg": "^8.0.0",
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"solid-js": "^1.0.0",
"svelte": "^4.0.0 || ^5.0.0",
"vitest": "^2.0.0 || ^3.0.0 || ^4.0.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"@lynx-js/react": {
"optional": true
},
"@prisma/client": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"@tanstack/react-start": {
"optional": true
},
"@tanstack/solid-start": {
"optional": true
},
"better-sqlite3": {
"optional": true
},
"drizzle-kit": {
"optional": true
},
"drizzle-orm": {
"optional": true
},
"mongodb": {
"optional": true
},
"mysql2": {
"optional": true
},
"next": {
"optional": true
},
"pg": {
"optional": true
},
"prisma": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vitest": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/better-auth/node_modules/@noble/ciphers": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz",
"integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/better-auth/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/better-call": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.5.tgz",
"integrity": "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==",
"license": "MIT",
"dependencies": {
"@better-auth/utils": "^0.4.0",
"@better-fetch/fetch": "^1.1.21",
"rou3": "^0.7.12",
"set-cookie-parser": "^3.0.1"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/better-call/node_modules/set-cookie-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
"license": "MIT"
},
"node_modules/bidi-js": { "node_modules/bidi-js": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@ -8778,6 +9004,7 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cypress": { "node_modules/cypress": {
@ -9145,6 +9372,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/defu": {
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -9168,6 +9401,7 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -10834,6 +11068,7 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -11034,12 +11269,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/glob-to-regexp": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"license": "BSD-2-Clause"
},
"node_modules/global-dirs": { "node_modules/global-dirs": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz",
@ -11802,21 +12031,11 @@
"version": "6.2.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz",
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
}, },
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -12006,6 +12225,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/kysely": {
"version": "0.28.16",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.16.tgz",
"integrity": "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/lazy-ass": { "node_modules/lazy-ass": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz",
@ -12952,6 +13180,21 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/nanostores": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.3.0.tgz",
"integrity": "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"engines": {
"node": "^20.0.0 || >=22.0.0"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.4", "version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
@ -12971,6 +13214,15 @@
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
} }
}, },
"node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-domexception": { "node_modules/node-domexception": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@ -13011,6 +13263,17 @@
"url": "https://opencollective.com/node-fetch" "url": "https://opencollective.com/node-fetch"
} }
}, },
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.23", "version": "2.0.23",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
@ -14249,6 +14512,7 @@
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-redux": { "node_modules/react-redux": {
@ -14645,6 +14909,7 @@
"version": "4.52.4", "version": "4.52.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
@ -14682,6 +14947,12 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rou3": {
"version": "0.7.12",
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz",
"integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==",
"license": "MIT"
},
"node_modules/router": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@ -15557,6 +15828,7 @@
"version": "3.9.0", "version": "3.9.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/stdin-discarder": { "node_modules/stdin-discarder": {
@ -15910,19 +16182,6 @@
"uuid": "^10.0.0" "uuid": "^10.0.0"
} }
}, },
"node_modules/swr": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.4.tgz",
"integrity": "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/symbol-tree": { "node_modules/symbol-tree": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@ -16326,7 +16585,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",

View file

@ -28,8 +28,6 @@
"build-storybook": "storybook build" "build-storybook": "storybook build"
}, },
"dependencies": { "dependencies": {
"@clerk/react-router": "^2.1.0",
"@clerk/themes": "^2.4.55",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@ -53,6 +51,8 @@
"@sentry/react-router": "^10.43.0", "@sentry/react-router": "^10.43.0",
"@tanstack/react-virtual": "^3.13.24", "@tanstack/react-virtual": "^3.13.24",
"@types/nprogress": "^0.2.3", "@types/nprogress": "^0.2.3",
"bcrypt": "^6.0.0",
"better-auth": "^1.6.9",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"compression": "^1.8.0", "compression": "^1.8.0",
@ -74,7 +74,6 @@
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"svix": "^1.77.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
@ -89,6 +88,7 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/bcrypt": "^6.0.0",
"@types/compression": "^1.8.1", "@types/compression": "^1.8.1",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/express-serve-static-core": "^5.0.6", "@types/express-serve-static-core": "^5.0.6",

View file

@ -0,0 +1,143 @@
/**
* One-time script: migrates email/password hashes from a Clerk CSV export into
* BetterAuth's accounts table.
*
* Usage:
* DATABASE_URL=... CLERK_SECRET_KEY=... node scripts/migrate-clerk-passwords.mjs
*
* Before running:
* 1. Go to Clerk dashboard Users Export Download CSV
* 2. Save the file as exported_users.csv in the project root
*
* The script is idempotent safe to run multiple times.
* Delete exported_users.csv after confirming the migration succeeded.
*/
import { readFileSync, existsSync } from "fs";
import { resolve } from "path";
import postgres from "postgres";
const DATABASE_URL = process.env.DATABASE_DIRECT_URL || process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error("ERROR: DATABASE_URL (or DATABASE_DIRECT_URL) is required");
process.exit(1);
}
const CSV_PATH = resolve(process.cwd(), "exported_users.csv");
if (!existsSync(CSV_PATH)) {
console.error(`ERROR: exported_users.csv not found at ${CSV_PATH}`);
console.error("Export users from the Clerk dashboard (Users → Export) and save as exported_users.csv");
process.exit(1);
}
const sql = postgres(DATABASE_URL, { max: 1 });
try {
await migratePasswords();
} catch (err) {
console.error("Migration failed:", err);
await sql.end().catch(() => {});
process.exit(1);
}
await sql.end().catch(() => {});
process.exit(0);
// ─────────────────────────────────────────────────────────────────────────────
function parseCSV(content) {
const lines = content.split("\n").filter((line) => line.trim());
if (lines.length < 2) return [];
const headers = lines[0].split(",").map((h) => h.trim().replace(/^"|"$/g, ""));
return lines.slice(1).map((line) => {
// Handle quoted fields that may contain commas
const values = [];
let current = "";
let inQuotes = false;
for (const char of line) {
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === "," && !inQuotes) {
values.push(current.trim());
current = "";
} else {
current += char;
}
}
values.push(current.trim());
return headers.reduce((obj, header, i) => {
obj[header] = (values[i] ?? "").replace(/^"|"$/g, "");
return obj;
}, {});
});
}
async function migratePasswords() {
const csvContent = readFileSync(CSV_PATH, "utf-8");
const rows = parseCSV(csvContent);
console.log(`Parsed ${rows.length} users from CSV`);
// Only process rows with a password hash
const passwordRows = rows.filter((r) => r.password_digest && r.password_hasher === "bcrypt");
console.log(`Found ${passwordRows.length} users with bcrypt password hashes`);
if (passwordRows.length === 0) {
console.log("Nothing to migrate.");
return;
}
let created = 0;
let skipped = 0;
let notFound = 0;
for (const row of passwordRows) {
const clerkId = row.id;
const passwordHash = row.password_digest;
// Find the user in our DB by their Clerk ID
const [dbUser] = await sql`
SELECT id FROM users WHERE clerk_id = ${clerkId} LIMIT 1
`;
if (!dbUser) {
notFound++;
continue;
}
// Check if a credential account already exists for this user
const [existing] = await sql`
SELECT 1 FROM accounts
WHERE user_id = ${dbUser.id}::uuid AND provider_id = 'credential'
LIMIT 1
`;
if (existing) {
skipped++;
continue;
}
// Insert credential account with the migrated bcrypt hash
await sql`
INSERT INTO accounts (id, account_id, provider_id, user_id, password, created_at, updated_at)
VALUES (
gen_random_uuid()::text,
${dbUser.id},
'credential',
${dbUser.id}::uuid,
${passwordHash},
now(),
now()
)
`;
created++;
}
console.log(`Done: ${created} credential accounts created, ${skipped} already existed, ${notFound} Clerk users not found in DB`);
if (notFound > 0) {
console.log(" (notFound users may have been deleted or never synced via webhook)");
}
console.log("\nIMPORTANT: Delete exported_users.csv now — it contains password hashes.");
}

View file

@ -29,5 +29,168 @@ try {
process.exit(1); process.exit(1);
} }
// ── Clerk → BetterAuth one-time data migration ────────────────────────────────
// Runs on every deploy but is fully idempotent. Skips automatically once
// CLERK_SECRET_KEY is removed from the environment.
const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY;
if (!CLERK_SECRET_KEY) {
console.log("CLERK_SECRET_KEY not set — skipping Clerk account migration");
} else {
console.log("Running Clerk account migration...");
try {
await runClerkMigration(client, CLERK_SECRET_KEY);
console.log("Clerk account migration complete");
} catch (err) {
console.error("Clerk account migration failed:", err);
await client.end().catch(() => {});
process.exit(1);
}
}
await client.end().catch(() => {}); await client.end().catch(() => {});
process.exit(0); process.exit(0);
// ─────────────────────────────────────────────────────────────────────────────
async function runClerkMigration(sql, secretKey) {
// 1. Convert teams.owner_id from Clerk IDs to users.id UUIDs
// Safe to run repeatedly — WHERE clause only matches unconverted rows.
const teamsResult = await sql`
UPDATE teams t
SET owner_id = u.id::text
FROM users u
WHERE t.owner_id = u.clerk_id
AND u.clerk_id IS NOT NULL
`;
console.log(` teams.owner_id: ${teamsResult.count} rows converted`);
// 2. Convert commissioners.user_id from Clerk IDs to users.id UUIDs
const commissResult = await sql`
UPDATE commissioners c
SET user_id = u.id::text
FROM users u
WHERE c.user_id = u.clerk_id
AND u.clerk_id IS NOT NULL
`;
console.log(` commissioners.user_id: ${commissResult.count} rows converted`);
// 3. Mark all Clerk users as email-verified (they already verified via Clerk)
await sql`
UPDATE users
SET email_verified = true
WHERE clerk_id IS NOT NULL
AND email_verified = false
`;
// 4. Migrate OAuth accounts from Clerk into BetterAuth's accounts table.
// Skip entirely if any OAuth accounts already exist to avoid duplicates.
// We check only OAuth providers (not 'credential') so running migrate-clerk-passwords.mjs
// first doesn't prevent OAuth accounts from being created.
const [{ count: existingCount }] = await sql`
SELECT COUNT(*)::int AS count FROM accounts WHERE provider_id IN ('google', 'discord')
`;
if (existingCount > 0) {
console.log(` ${existingCount} OAuth accounts already exist — skipping Clerk account import`);
return;
}
// Fetch all users with Clerk IDs from our DB
const dbUsers = await sql`
SELECT id, clerk_id FROM users WHERE clerk_id IS NOT NULL
`;
if (dbUsers.length === 0) {
console.log(" No Clerk users found in DB — nothing to migrate");
return;
}
console.log(` Found ${dbUsers.length} Clerk users in DB, fetching from Clerk API...`);
const dbUserByClerkId = new Map(dbUsers.map((u) => [u.clerk_id, u.id]));
// Fetch all users from Clerk API (paginated)
const clerkUsers = [];
let offset = 0;
const limit = 100;
while (true) {
const res = await fetch(
`https://api.clerk.com/v1/users?limit=${limit}&offset=${offset}`,
{ headers: { Authorization: `Bearer ${secretKey}` } }
);
if (!res.ok) {
throw new Error(`Clerk API error ${res.status}: ${await res.text()}`);
}
const batch = await res.json();
clerkUsers.push(...batch);
if (batch.length < limit) break;
offset += limit;
}
console.log(` Fetched ${clerkUsers.length} users from Clerk API`);
let googleCount = 0;
let discordCount = 0;
let emailPasswordCount = 0;
let skippedCount = 0;
for (const clerkUser of clerkUsers) {
const userId = dbUserByClerkId.get(clerkUser.id);
if (!userId) {
skippedCount++;
continue;
}
for (const ext of clerkUser.external_accounts ?? []) {
if (ext.provider === "google") {
await sql`
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
SELECT
gen_random_uuid()::text,
${ext.provider_user_id},
'google',
${userId}::uuid,
now(),
now()
WHERE NOT EXISTS (
SELECT 1 FROM accounts
WHERE user_id = ${userId}::uuid AND provider_id = 'google'
)
`;
googleCount++;
}
if (ext.provider === "discord") {
await sql`
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
SELECT
gen_random_uuid()::text,
${ext.provider_user_id},
'discord',
${userId}::uuid,
now(),
now()
WHERE NOT EXISTS (
SELECT 1 FROM accounts
WHERE user_id = ${userId}::uuid AND provider_id = 'discord'
)
`;
discordCount++;
}
}
if (clerkUser.password_enabled) {
emailPasswordCount++;
}
}
console.log(
` Accounts created: ${googleCount} Google, ${discordCount} Discord`
);
if (emailPasswordCount > 0) {
console.log(
` ${emailPasswordCount} email/password users — they must use "Forgot Password" on first login`
);
}
if (skippedCount > 0) {
console.log(` Skipped ${skippedCount} Clerk users with no matching DB row`);
}
}