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:
parent
e8e4981465
commit
ba9bf64e37
74 changed files with 11970 additions and 752 deletions
10
.env.example
10
.env.example
|
|
@ -7,11 +7,13 @@ DATABASE_URL=""
|
|||
# Leave both unset for local development with plain PostgreSQL.
|
||||
PGBOUNCER=
|
||||
DATABASE_DIRECT_URL=
|
||||
CLERK_SECRET_KEY=""
|
||||
CLERK_PUBLISHABLE_KEY=""
|
||||
CLERK_WEBHOOK_SECRET=""
|
||||
BETTER_AUTH_SECRET=""
|
||||
BETTER_AUTH_URL=""
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
DISCORD_CLIENT_ID=""
|
||||
DISCORD_CLIENT_SECRET=""
|
||||
CONTAINER_REGISTRY=""
|
||||
DEV_ADMIN_CLERK_ID=""
|
||||
SENTRY_AUTH_TOKEN=""
|
||||
SQUIGGLE_CONTACT_EMAIL=""
|
||||
RESEND_API_KEY=""
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -21,3 +21,6 @@
|
|||
storybook-static
|
||||
.grepai/
|
||||
.worktrees/
|
||||
|
||||
# Clerk migration export — contains password hashes, never commit
|
||||
exported_users.csv
|
||||
|
|
|
|||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
legacy-peer-deps=true
|
||||
183
BETTERAUTH_MIGRATION.md
Normal file
183
BETTERAUTH_MIGRATION.md
Normal 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 1–3 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`
|
||||
59
app/components/UserMenu.tsx
Normal file
59
app/components/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { Link, NavLink } from "react-router";
|
||||
import { Link, NavLink, useLocation } from "react-router";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
|
|
@ -8,7 +8,8 @@ import {
|
|||
SheetTrigger,
|
||||
} from "~/components/ui/sheet";
|
||||
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";
|
||||
|
||||
const NAV_LINK_CLASS =
|
||||
|
|
@ -30,6 +31,22 @@ interface NavbarProps {
|
|||
|
||||
export function Navbar({ isAdmin }: NavbarProps) {
|
||||
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 (
|
||||
<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" />
|
||||
</Link>
|
||||
<nav aria-label="Main navigation" className="hidden md:flex items-center gap-1">
|
||||
<SignedOut>
|
||||
<NavLink to="/" end className={navLinkClass}>Home</NavLink>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<NavLink to="/" end className={navLinkClass}>Dashboard</NavLink>
|
||||
</SignedIn>
|
||||
{session
|
||||
? <NavLink to="/" end className={navLinkClass}>Dashboard</NavLink>
|
||||
: <NavLink to="/" end className={navLinkClass}>Home</NavLink>
|
||||
}
|
||||
<NavLink to="/how-to-play" className={navLinkClass}>How To Play</NavLink>
|
||||
<NavLink to="/rules" className={navLinkClass}>Rules</NavLink>
|
||||
</nav>
|
||||
|
|
@ -69,14 +84,7 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
|||
<Settings className="h-5 w-5" />
|
||||
</Link>
|
||||
)}
|
||||
<SignedOut>
|
||||
<SignInButton mode="modal">
|
||||
<Button>Sign In</Button>
|
||||
</SignInButton>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<UserButton />
|
||||
</SignedIn>
|
||||
{authSection}
|
||||
</div>
|
||||
|
||||
{/* Mobile: icons + Auth + Hamburger */}
|
||||
|
|
@ -97,14 +105,7 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
|||
<Settings className="h-5 w-5" />
|
||||
</Link>
|
||||
)}
|
||||
<SignedOut>
|
||||
<SignInButton mode="modal">
|
||||
<Button>Sign In</Button>
|
||||
</SignInButton>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<UserButton />
|
||||
</SignedIn>
|
||||
{authSection}
|
||||
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
|
|
@ -120,26 +121,14 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
|||
<SheetTitle>Menu</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav aria-label="Mobile navigation" className="flex flex-col gap-4 mt-8">
|
||||
<SignedOut>
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={mobileNavLinkClass}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Home
|
||||
{session ? "Dashboard" : "Home"}
|
||||
</NavLink>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={mobileNavLinkClass}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
</SignedIn>
|
||||
<NavLink
|
||||
to="/how-to-play"
|
||||
className={mobileNavLinkClass}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
import type { getDraftPicksForSeason } from "~/models/draft-pick";
|
||||
import type { findSeasonWithTeamsAndLeague } from "~/models/season";
|
||||
import type { getSeasonTimers } from "~/models/draft-timer";
|
||||
|
|
@ -14,9 +15,7 @@ interface UseDraftAuthRecoveryParams {
|
|||
reconnectCount: number;
|
||||
revalidate: () => void;
|
||||
revalidatorState: "idle" | "loading" | "submitting";
|
||||
clerkUserId: 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;
|
||||
// Loader data for revalidation sync
|
||||
draftPicks: DraftPicks;
|
||||
|
|
@ -34,15 +33,12 @@ interface UseDraftAuthRecoveryParams {
|
|||
setAutodraftStatus: (fn: () => Record<string, AutodraftStatusEntry>) => void;
|
||||
}
|
||||
|
||||
const MAX_AUTH_RECOVERY_ATTEMPTS = 3;
|
||||
|
||||
export function useDraftAuthRecovery({
|
||||
reconnectCount,
|
||||
revalidate,
|
||||
revalidatorState,
|
||||
clerkUserId,
|
||||
currentUserId,
|
||||
getToken,
|
||||
userAutodraftSettings,
|
||||
draftPicks,
|
||||
season,
|
||||
|
|
@ -89,27 +85,8 @@ export function useDraftAuthRecovery({
|
|||
};
|
||||
}, [reconnectCount, revalidate]);
|
||||
|
||||
// Guard against Clerk JWT expiry race.
|
||||
const authRecoveryTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
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.
|
||||
// Re-check session when the tab becomes visible — cookie-based sessions don't
|
||||
// need manual token refresh, but a long-idle tab may have an expired session.
|
||||
useEffect(() => {
|
||||
if (authDegraded) return;
|
||||
|
||||
|
|
@ -117,50 +94,29 @@ export function useDraftAuthRecovery({
|
|||
let inFlight = false;
|
||||
|
||||
const handleVisibilityChange = async () => {
|
||||
if (document.visibilityState !== "visible" || !clerkUserId || inFlight) return;
|
||||
if (document.visibilityState !== "visible" || inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const token = await getToken({ skipCache: true });
|
||||
const { data: session } = await authClient.getSession();
|
||||
if (aborted) return;
|
||||
if (token !== null) {
|
||||
if (!currentUserId) {
|
||||
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();
|
||||
if (session) {
|
||||
if (!currentUserId) revalidate();
|
||||
} else {
|
||||
setAuthDegraded(true);
|
||||
}
|
||||
} catch {
|
||||
if (!aborted) setAuthDegraded(true);
|
||||
}
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
aborted = true;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [clerkUserId, currentUserId, getToken, authDegraded, revalidate]);
|
||||
}, [currentUserId, authDegraded, revalidate]);
|
||||
|
||||
// Track revalidation lifecycle to sync local state from fresh loader data.
|
||||
const revalidatorStateRef = useRef(revalidatorState);
|
||||
|
|
|
|||
3
app/lib/auth-client.ts
Normal file
3
app/lib/auth-client.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient();
|
||||
64
app/lib/auth.server.ts
Normal file
64
app/lib/auth.server.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import type { database } from "~/database/context";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
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
|
||||
* 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(
|
||||
args: LoaderFunctionArgs,
|
||||
|
|
@ -29,7 +26,8 @@ export async function requireLeagueAccess(
|
|||
unauthorizedMessage?: string;
|
||||
}
|
||||
): 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) {
|
||||
throw new Response(unauthMessage, { status: 401 });
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -11,16 +11,16 @@ export async function buildOwnerMap(
|
|||
slots.map((s) => s.team.ownerId).filter((id): id is string => id !== null)
|
||||
)];
|
||||
|
||||
const userRows = await findUsersByClerkIds(uniqueOwnerIds);
|
||||
const userByClerkId = new Map(
|
||||
const userRows = await findUsersByIds(uniqueOwnerIds);
|
||||
const userById = new Map(
|
||||
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)
|
||||
);
|
||||
|
||||
const ownerMap: Record<string, string> = {};
|
||||
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) {
|
||||
ownerMap[slot.team.id] = name;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("~/models/user", () => ({
|
||||
findUserByClerkId: vi.fn(),
|
||||
findUserById: vi.fn(),
|
||||
getUserDisplayName: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -14,18 +14,18 @@ import {
|
|||
getAuditLogForSeason,
|
||||
logCommissionerAction,
|
||||
} from "../audit-log";
|
||||
import { findUserByClerkId, getUserDisplayName } from "~/models/user";
|
||||
import { findUserById, getUserDisplayName } from "~/models/user";
|
||||
import { database } from "~/database/context";
|
||||
|
||||
const SEASON_ID = "season-1";
|
||||
const LEAGUE_ID = "league-1";
|
||||
const ACTOR_CLERK_ID = "user_clerk_1";
|
||||
const ACTOR_USER_ID = "user-uuid-1";
|
||||
|
||||
const SAMPLE_ENTRY = {
|
||||
id: "entry-1",
|
||||
seasonId: SEASON_ID,
|
||||
leagueId: LEAGUE_ID,
|
||||
actorClerkId: ACTOR_CLERK_ID,
|
||||
actorClerkId: ACTOR_USER_ID,
|
||||
actorDisplayName: "Alice",
|
||||
action: "draft_rollback" as const,
|
||||
affectedTeamIds: ["team-1"],
|
||||
|
|
@ -99,7 +99,7 @@ describe("createAuditLogEntry", () => {
|
|||
const result = await createAuditLogEntry({
|
||||
seasonId: SEASON_ID,
|
||||
leagueId: LEAGUE_ID,
|
||||
actorClerkId: ACTOR_CLERK_ID,
|
||||
actorClerkId: ACTOR_USER_ID,
|
||||
actorDisplayName: "Alice",
|
||||
action: "draft_rollback",
|
||||
affectedTeamIds: ["team-1"],
|
||||
|
|
@ -192,7 +192,7 @@ describe("getAuditLogForSeason", () => {
|
|||
describe("logCommissionerAction", () => {
|
||||
it("resolves the actor display name from the users table", async () => {
|
||||
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");
|
||||
|
||||
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
||||
|
|
@ -201,7 +201,7 @@ describe("logCommissionerAction", () => {
|
|||
await logCommissionerAction({
|
||||
seasonId: SEASON_ID,
|
||||
leagueId: LEAGUE_ID,
|
||||
actorClerkId: ACTOR_CLERK_ID,
|
||||
actorUserId: ACTOR_USER_ID,
|
||||
action: "draft_rollback",
|
||||
});
|
||||
|
||||
|
|
@ -210,7 +210,7 @@ describe("logCommissionerAction", () => {
|
|||
});
|
||||
|
||||
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);
|
||||
|
||||
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
||||
|
|
@ -219,16 +219,16 @@ describe("logCommissionerAction", () => {
|
|||
await logCommissionerAction({
|
||||
seasonId: SEASON_ID,
|
||||
leagueId: LEAGUE_ID,
|
||||
actorClerkId: ACTOR_CLERK_ID,
|
||||
actorUserId: ACTOR_USER_ID,
|
||||
action: "draft_rollback",
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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");
|
||||
|
||||
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
||||
|
|
@ -237,7 +237,7 @@ describe("logCommissionerAction", () => {
|
|||
await logCommissionerAction({
|
||||
seasonId: SEASON_ID,
|
||||
leagueId: LEAGUE_ID,
|
||||
actorClerkId: ACTOR_CLERK_ID,
|
||||
actorUserId: ACTOR_USER_ID,
|
||||
action: "draft_started",
|
||||
});
|
||||
|
||||
|
|
@ -246,7 +246,7 @@ describe("logCommissionerAction", () => {
|
|||
});
|
||||
|
||||
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");
|
||||
|
||||
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
||||
|
|
@ -255,7 +255,7 @@ describe("logCommissionerAction", () => {
|
|||
await logCommissionerAction({
|
||||
seasonId: SEASON_ID,
|
||||
leagueId: LEAGUE_ID,
|
||||
actorClerkId: ACTOR_CLERK_ID,
|
||||
actorUserId: ACTOR_USER_ID,
|
||||
action: "draft_started",
|
||||
});
|
||||
|
||||
|
|
@ -264,7 +264,7 @@ describe("logCommissionerAction", () => {
|
|||
});
|
||||
|
||||
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");
|
||||
|
||||
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
||||
|
|
@ -274,7 +274,7 @@ describe("logCommissionerAction", () => {
|
|||
await logCommissionerAction({
|
||||
seasonId: SEASON_ID,
|
||||
leagueId: LEAGUE_ID,
|
||||
actorClerkId: ACTOR_CLERK_ID,
|
||||
actorUserId: ACTOR_USER_ID,
|
||||
action: "force_manual_pick",
|
||||
affectedTeamIds: ["team-7"],
|
||||
details,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("~/models/user", () => ({
|
||||
isUserAdminByClerkId: vi.fn(),
|
||||
isUserAdmin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
|
|
@ -9,7 +9,7 @@ vi.mock("~/database/context", () => ({
|
|||
}));
|
||||
|
||||
import { isCommissioner, hasCommissionerRecord } from "../commissioner";
|
||||
import { isUserAdminByClerkId } from "~/models/user";
|
||||
import { isUserAdmin } from "~/models/user";
|
||||
import { database } from "~/database/context";
|
||||
|
||||
const LEAGUE_ID = "league-1";
|
||||
|
|
@ -31,7 +31,7 @@ beforeEach(() => {
|
|||
|
||||
describe("isCommissioner", () => {
|
||||
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);
|
||||
|
||||
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 () => {
|
||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||
vi.mocked(database).mockReturnValue(
|
||||
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 () => {
|
||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||
vi.mocked(database).mockReturnValue(
|
||||
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 () => {
|
||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||
vi.mocked(database).mockReturnValue(makeMockDb(null) as never);
|
||||
|
||||
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 () => {
|
||||
const order: string[] = [];
|
||||
vi.mocked(isUserAdminByClerkId).mockImplementation(async () => {
|
||||
vi.mocked(isUserAdmin).mockImplementation(async () => {
|
||||
order.push("admin");
|
||||
return false;
|
||||
});
|
||||
|
|
@ -110,6 +110,6 @@ describe("hasCommissionerRecord", () => {
|
|||
|
||||
const result = await hasCommissionerRecord(LEAGUE_ID, USER_ID);
|
||||
expect(result).toBe(false);
|
||||
expect(isUserAdminByClerkId).not.toHaveBeenCalled();
|
||||
expect(isUserAdmin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { eq, desc, and, inArray, sql } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
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 NewAuditLogEntry = typeof schema.commissionerAuditLog.$inferInsert;
|
||||
|
|
@ -65,20 +65,20 @@ export async function getAuditLogForSeason(
|
|||
export async function logCommissionerAction(params: {
|
||||
seasonId: string;
|
||||
leagueId: string;
|
||||
actorClerkId: string;
|
||||
actorUserId: string;
|
||||
action: AuditAction;
|
||||
affectedTeamIds?: string[];
|
||||
details?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const user = await findUserByClerkId(params.actorClerkId);
|
||||
const user = await findUserById(params.actorUserId);
|
||||
const actorDisplayName = user
|
||||
? (getUserDisplayName(user) ?? params.actorClerkId)
|
||||
: params.actorClerkId;
|
||||
? (getUserDisplayName(user) ?? params.actorUserId)
|
||||
: params.actorUserId;
|
||||
|
||||
await createAuditLogEntry({
|
||||
seasonId: params.seasonId,
|
||||
leagueId: params.leagueId,
|
||||
actorClerkId: params.actorClerkId,
|
||||
actorClerkId: params.actorUserId,
|
||||
actorDisplayName,
|
||||
action: params.action,
|
||||
affectedTeamIds: params.affectedTeamIds ?? [],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { eq, and } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
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 NewCommissioner = typeof schema.commissioners.$inferInsert;
|
||||
|
|
@ -57,7 +57,7 @@ export async function isCommissioner(
|
|||
): Promise<boolean> {
|
||||
const db = database();
|
||||
const [isAdmin, commissioner] = await Promise.all([
|
||||
isUserAdminByClerkId(userId),
|
||||
isUserAdmin(userId),
|
||||
db.query.commissioners.findFirst({
|
||||
where: and(
|
||||
eq(schema.commissioners.leagueId, leagueId),
|
||||
|
|
|
|||
|
|
@ -1446,18 +1446,18 @@ export async function recalculateAffectedLeagues(
|
|||
const webhookUrl = season?.league?.discordWebhookUrl;
|
||||
if (!webhookUrl || options?.skipDiscord) continue;
|
||||
|
||||
// Build clerkId → username map for all team owners in this season
|
||||
const ownerClerkIds = afterStandings
|
||||
// Build userId → username map for all team owners in this season
|
||||
const ownerUserIds = afterStandings
|
||||
.map((s) => s.team.ownerId)
|
||||
.filter((id): id is string => id !== null && id !== undefined);
|
||||
const teamOwnerUsers = ownerClerkIds.length
|
||||
const teamOwnerUsers = ownerUserIds.length
|
||||
? 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
|
||||
.map((u) => [u.clerkId, getUserDisplayName(u)])
|
||||
.map((u) => [u.id, getUserDisplayName(u)])
|
||||
.filter((entry): entry is [string, string] => entry[1] !== null)
|
||||
);
|
||||
|
||||
|
|
@ -1497,7 +1497,7 @@ export async function recalculateAffectedLeagues(
|
|||
if (!teamId) return undefined;
|
||||
const ownerId = ownerIdByTeamId.get(teamId);
|
||||
if (!ownerId) return undefined;
|
||||
return usernameByClerkId.get(ownerId);
|
||||
return usernameByUserId.get(ownerId);
|
||||
};
|
||||
|
||||
scoredMatches = relevant
|
||||
|
|
@ -1527,7 +1527,7 @@ export async function recalculateAffectedLeagues(
|
|||
const standings = afterStandings.map((s) => ({
|
||||
teamId: s.teamId,
|
||||
teamName: s.team.name,
|
||||
username: usernameByClerkId.get(s.team.ownerId ?? ""),
|
||||
username: usernameByUserId.get(s.team.ownerId ?? ""),
|
||||
totalPoints: parseFloat(s.totalPoints),
|
||||
rank: s.currentRank,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -5,11 +5,6 @@ import * as schema from "~/database/schema";
|
|||
export type User = typeof schema.users.$inferSelect;
|
||||
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(
|
||||
user: Pick<User, "username" | "displayName">
|
||||
): string | null {
|
||||
|
|
@ -29,22 +24,13 @@ export async function findUserById(id: string): Promise<User | undefined> {
|
|||
});
|
||||
}
|
||||
|
||||
export async function findUserByClerkId(
|
||||
clerkId: string
|
||||
): 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 [];
|
||||
export async function findUsersByIds(ids: string[]): Promise<User[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const db = database();
|
||||
return await db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(inArray(schema.users.clerkId, clerkIds));
|
||||
.where(inArray(schema.users.id, ids));
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
|
|
@ -60,71 +46,6 @@ export async function updateUser(
|
|||
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> {
|
||||
const db = database();
|
||||
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;
|
||||
}
|
||||
|
||||
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> {
|
||||
return await updateUser(userId, { isAdmin });
|
||||
}
|
||||
|
|
|
|||
24
app/root.tsx
24
app/root.tsx
|
|
@ -10,31 +10,19 @@ import {
|
|||
|
||||
import type { Route } from "./+types/root";
|
||||
import "./app.css";
|
||||
import { clerkMiddleware, rootAuthLoader, getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { Navbar } from "~/components/navbar";
|
||||
import { NavigationProgress } from "~/components/NavigationProgress";
|
||||
import { ClerkProvider } from "@clerk/react-router";
|
||||
import { dark } from "@clerk/themes";
|
||||
import { Toaster } from "~/components/ui/sonner";
|
||||
import { BracktGradients } from "~/components/ui/BracktGradients";
|
||||
import { Footer } from "~/components/marketing/Footer";
|
||||
import { isUserAdminByClerkId } from "~/models/user";
|
||||
import { AlertCircle, FileQuestion, Lock, ShieldOff, ServerCrash } from "lucide-react";
|
||||
import logoUrl from "../public/logo.svg?url";
|
||||
|
||||
export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()];
|
||||
|
||||
export async function loader(args: Route.LoaderArgs) {
|
||||
return rootAuthLoader(args, async () => {
|
||||
const { userId } = await getAuth(args);
|
||||
|
||||
let isAdmin = false;
|
||||
if (userId) {
|
||||
isAdmin = await isUserAdminByClerkId(userId);
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
const isAdmin = session?.user.isAdmin ?? false;
|
||||
return { isAdmin };
|
||||
});
|
||||
}
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
|
|
@ -79,7 +67,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
|
|||
const isDraftRoute = location.pathname.includes('/draft');
|
||||
|
||||
return (
|
||||
<ClerkProvider loaderData={loaderData} appearance={{ baseTheme: dark }}>
|
||||
<>
|
||||
<NavigationProgress />
|
||||
{!isDraftRoute && <Navbar isAdmin={loaderData?.isAdmin ?? false} />}
|
||||
{isDraftRoute ? (
|
||||
|
|
@ -93,7 +81,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
|
|||
</>
|
||||
)}
|
||||
<Toaster />
|
||||
</ClerkProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export default [
|
|||
"routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.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/remove", "routes/api/queue.remove.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/autodraft/update", "routes/api/autodraft.update.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("how-to-play", "routes/how-to-play.tsx"),
|
||||
route("rules", "routes/rules.tsx"),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
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 { logger } from "~/lib/logger";
|
||||
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 { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
|
|
@ -57,8 +57,9 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
export async function action(args: Route.ActionArgs) {
|
||||
const { request, params } = args;
|
||||
const { userId } = await getAuth(args);
|
||||
const isAdmin = userId ? await isUserAdminByClerkId(userId) : false;
|
||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||
const userId = session?.user.id ?? null;
|
||||
const isAdmin = userId ? await isUserAdmin(userId) : false;
|
||||
if (!isAdmin) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Form, Link, redirect, useNavigate, useNavigation } from "react-router";
|
||||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { isUserAdminByClerkId } from "~/models/user";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { isUserAdmin } from "~/models/user";
|
||||
import type { Route } from "./+types/admin.sports-seasons.$id";
|
||||
|
||||
import { logger } from "~/lib/logger";
|
||||
|
|
@ -100,8 +100,9 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
export async function action(args: Route.ActionArgs) {
|
||||
const { request, params } = args;
|
||||
const { userId } = await getAuth(args);
|
||||
const isAdmin = userId ? await isUserAdminByClerkId(userId) : false;
|
||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||
const userId = session?.user.id ?? null;
|
||||
const isAdmin = userId ? await isUserAdmin(userId) : false;
|
||||
if (!isAdmin) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
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 { isUserAdminByClerkId } from "~/models/user";
|
||||
import { isUserAdmin } from "~/models/user";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
|
|
@ -17,13 +17,14 @@ export function meta(): Route.MetaDescriptors {
|
|||
}
|
||||
|
||||
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) {
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
const isAdmin = await isUserAdminByClerkId(userId);
|
||||
const isAdmin = await isUserAdmin(userId);
|
||||
if (!isAdmin) {
|
||||
throw redirect("/");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { isUserAdminByClerkId } from "~/models/user";
|
||||
import { isUserAdmin } from "~/models/user";
|
||||
import { exportSportsDataToJSON } from "~/utils/sports-data-sync.server";
|
||||
|
||||
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) {
|
||||
throw new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const isAdmin = await isUserAdminByClerkId(userId);
|
||||
const isAdmin = await isUserAdmin(userId);
|
||||
if (!isAdmin) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
|
|
|||
10
app/routes/api.auth.$.ts
Normal file
10
app/routes/api.auth.$.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -8,8 +8,8 @@ vi.mock("~/database/context");
|
|||
vi.mock("~/server/socket", () => ({
|
||||
getSocketIO: vi.fn(),
|
||||
}));
|
||||
vi.mock("@clerk/react-router/server", () => ({
|
||||
getAuth: vi.fn(),
|
||||
vi.mock("~/lib/auth.server", () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
}));
|
||||
vi.mock("~/models/draft-pick", () => ({
|
||||
getDraftPicksWithSports: vi.fn(),
|
||||
|
|
@ -29,7 +29,7 @@ vi.mock("~/models/draft-utils", () => ({
|
|||
calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }),
|
||||
}));
|
||||
vi.mock("~/models/user", () => ({
|
||||
isUserAdminByClerkId: vi.fn(),
|
||||
isUserAdmin: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/audit-log", () => ({
|
||||
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -119,12 +119,12 @@ describe("draft.force-manual-pick action", () => {
|
|||
vi.clearAllMocks();
|
||||
|
||||
// Auth: default to authenticated commissioner
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
||||
const { auth } = await import("~/lib/auth.server");
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
|
||||
|
||||
// User model: default to non-admin
|
||||
const { isUserAdminByClerkId } = await import("~/models/user");
|
||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
||||
const { isUserAdmin } = await import("~/models/user");
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||
|
||||
// Socket
|
||||
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
|
||||
|
|
@ -195,8 +195,8 @@ describe("draft.force-manual-pick action", () => {
|
|||
|
||||
describe("authorization", () => {
|
||||
it("returns 401 when user is not authenticated", async () => {
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId: null } as any);
|
||||
const { auth } = await import("~/lib/auth.server");
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue(null as any);
|
||||
|
||||
const response = await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ vi.mock("~/database/context");
|
|||
vi.mock("~/server/socket", () => ({
|
||||
getSocketIO: vi.fn(),
|
||||
}));
|
||||
vi.mock("@clerk/react-router/server", () => ({
|
||||
getAuth: vi.fn(),
|
||||
vi.mock("~/lib/auth.server", () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
}));
|
||||
vi.mock("~/models/draft-pick", () => ({
|
||||
getDraftPicksWithSports: vi.fn(),
|
||||
|
|
@ -35,7 +35,7 @@ vi.mock("~/models/draft-utils", () => ({
|
|||
calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }),
|
||||
}));
|
||||
vi.mock("~/models/user", () => ({
|
||||
isUserAdminByClerkId: vi.fn(),
|
||||
isUserAdmin: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/audit-log", () => ({
|
||||
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -126,11 +126,11 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
|
|||
vi.clearAllMocks();
|
||||
|
||||
// Default: authenticated as commissioner
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
||||
const { auth } = await import("~/lib/auth.server");
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
|
||||
|
||||
const { isUserAdminByClerkId } = await import("~/models/user");
|
||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
||||
const { isUserAdmin } = await import("~/models/user");
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||
|
||||
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
|
||||
const socketModule = await import("~/server/socket");
|
||||
|
|
@ -236,11 +236,11 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
|
|||
|
||||
describe("admin force pick", () => {
|
||||
beforeEach(async () => {
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
|
||||
const { auth } = await import("~/lib/auth.server");
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: ADMIN_ID } } as any);
|
||||
|
||||
const { isUserAdminByClerkId } = await import("~/models/user");
|
||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
||||
const { isUserAdmin } = await import("~/models/user");
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||
|
||||
// Admin is not a commissioner
|
||||
mockDb.query.commissioners.findFirst.mockResolvedValue(null);
|
||||
|
|
@ -316,11 +316,11 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
|
|||
|
||||
describe("admin force pick", () => {
|
||||
beforeEach(async () => {
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
|
||||
const { auth } = await import("~/lib/auth.server");
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: ADMIN_ID } } as any);
|
||||
|
||||
const { isUserAdminByClerkId } = await import("~/models/user");
|
||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
||||
const { isUserAdmin } = await import("~/models/user");
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||
|
||||
mockDb.query.commissioners.findFirst.mockResolvedValue(null);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ vi.mock("~/database/context");
|
|||
vi.mock("~/server/socket", () => ({
|
||||
getSocketIO: vi.fn(),
|
||||
}));
|
||||
vi.mock("@clerk/react-router/server", () => ({
|
||||
getAuth: vi.fn(),
|
||||
vi.mock("~/lib/auth.server", () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
}));
|
||||
vi.mock("~/models/draft-pick", () => ({
|
||||
getDraftPicksWithSports: vi.fn(),
|
||||
|
|
@ -36,7 +36,7 @@ vi.mock("~/models/draft-utils", () => ({
|
|||
pruneIneligibleQueueItems: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
vi.mock("~/models/user", () => ({
|
||||
isUserAdminByClerkId: vi.fn(),
|
||||
isUserAdmin: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -123,11 +123,11 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
|||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId: OWNER_ID } as any);
|
||||
const { auth } = await import("~/lib/auth.server");
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: OWNER_ID } } as any);
|
||||
|
||||
const { isUserAdminByClerkId } = await import("~/models/user");
|
||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
||||
const { isUserAdmin } = await import("~/models/user");
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||
|
||||
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
|
||||
const socketModule = await import("~/server/socket");
|
||||
|
|
@ -231,8 +231,8 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
|||
|
||||
describe("commissioner pick", () => {
|
||||
beforeEach(async () => {
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
||||
const { auth } = await import("~/lib/auth.server");
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
|
||||
|
||||
mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID });
|
||||
// Commissioner does not own the team on the clock
|
||||
|
|
@ -273,11 +273,11 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
|||
|
||||
describe("admin pick", () => {
|
||||
beforeEach(async () => {
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
|
||||
const { auth } = await import("~/lib/auth.server");
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: ADMIN_ID } } as any);
|
||||
|
||||
const { isUserAdminByClerkId } = await import("~/models/user");
|
||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
||||
const { isUserAdmin } = await import("~/models/user");
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||
|
||||
// Admin does not own the team on the clock
|
||||
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 () => {
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
||||
const { auth } = await import("~/lib/auth.server");
|
||||
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.draftSlots.findMany.mockResolvedValue([
|
||||
{ ...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 () => {
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
|
||||
const { isUserAdminByClerkId } = await import("~/models/user");
|
||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
||||
const { auth } = await import("~/lib/auth.server");
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: ADMIN_ID } } as any);
|
||||
const { isUserAdmin } = await import("~/models/user");
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||
mockDb.query.draftSlots.findMany.mockResolvedValue([
|
||||
{ ...mockDraftSlots[0], team: { ...mockDraftSlots[0].team, ownerId: "someone-else" } },
|
||||
mockDraftSlots[1],
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ vi.mock("~/database/context");
|
|||
vi.mock("~/server/socket", () => ({
|
||||
getSocketIO: vi.fn(),
|
||||
}));
|
||||
vi.mock("@clerk/react-router/server", () => ({
|
||||
getAuth: vi.fn(),
|
||||
vi.mock("~/lib/auth.server", () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
}));
|
||||
vi.mock("~/models/commissioner", () => ({
|
||||
isCommissioner: vi.fn(),
|
||||
|
|
@ -62,8 +62,8 @@ describe("draft.start action — timer initialization", () => {
|
|||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
||||
const { auth } = await import("~/lib/auth.server");
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
|
||||
|
||||
const { isCommissioner } = await import("~/models/commissioner");
|
||||
vi.mocked(isCommissioner).mockResolvedValue(true);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and, asc } from "drizzle-orm";
|
||||
import { getSocketIO } from "~/server/socket";
|
||||
import { isUserAdminByClerkId } from "~/models/user";
|
||||
import { isUserAdmin } from "~/models/user";
|
||||
import { getTeamForPick } from "~/lib/draft-order";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { executeAutoPick } from "~/models/draft-utils";
|
||||
|
|
@ -11,7 +11,8 @@ import { executeAutoPick } from "~/models/draft-utils";
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
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) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
|
@ -51,7 +52,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
eq(schema.commissioners.userId, userId)
|
||||
),
|
||||
}),
|
||||
isUserAdminByClerkId(userId),
|
||||
isUserAdmin(userId),
|
||||
]);
|
||||
|
||||
if (!commissioner && !userIsAdmin) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
|
@ -10,7 +10,8 @@ import { logger } from "~/lib/logger";
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
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) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
|
@ -84,7 +85,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId,
|
||||
leagueId: season.leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "time_bank_edited",
|
||||
affectedTeamIds: [teamId],
|
||||
details: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
|
@ -9,7 +9,8 @@ import { logCommissionerAction } from "~/models/audit-log";
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
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) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
|
@ -61,7 +62,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId,
|
||||
leagueId: season.leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "force_autopick",
|
||||
affectedTeamIds: [teamId],
|
||||
details: {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { isUserAdminByClerkId } from "~/models/user";
|
||||
import { isUserAdmin } from "~/models/user";
|
||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
||||
import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
||||
|
|
@ -15,7 +15,8 @@ import type { ActionFunctionArgs } from "react-router";
|
|||
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
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) {
|
||||
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
|
||||
const [isAdmin, commissionerRecord] = await Promise.all([
|
||||
isUserAdminByClerkId(userId),
|
||||
isUserAdmin(userId),
|
||||
db.query.commissioners.findFirst({
|
||||
where: and(
|
||||
eq(schema.commissioners.leagueId, season.leagueId),
|
||||
|
|
@ -263,7 +264,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId,
|
||||
leagueId: season.leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "force_manual_pick",
|
||||
affectedTeamIds: [teamId],
|
||||
details: {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { isUserAdminByClerkId } from "~/models/user";
|
||||
import { isUserAdmin } from "~/models/user";
|
||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
||||
import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
||||
|
|
@ -14,7 +14,8 @@ import { logger } from "~/lib/logger";
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
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) {
|
||||
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
|
||||
const isTeamOwner = currentDraftSlot.team.ownerId === userId;
|
||||
const [isAdmin, commissionerRecord] = await Promise.all([
|
||||
isUserAdminByClerkId(userId),
|
||||
isUserAdmin(userId),
|
||||
db.query.commissioners.findFirst({
|
||||
where: and(
|
||||
eq(schema.commissioners.leagueId, season.leagueId),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
|
@ -10,7 +10,8 @@ import { logger } from "~/lib/logger";
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
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) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
|
@ -59,7 +60,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId,
|
||||
leagueId: season.leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "draft_paused",
|
||||
details: { pickNumber: season.currentPickNumber },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
|
@ -14,7 +14,8 @@ import { logger } from "~/lib/logger";
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
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) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
|
@ -152,7 +153,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId,
|
||||
leagueId: season.leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "draft_pick_changed",
|
||||
affectedTeamIds: [teamId],
|
||||
details: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
|
@ -10,7 +10,8 @@ import { logger } from "~/lib/logger";
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
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) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
|
@ -59,7 +60,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId,
|
||||
leagueId: season.leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "draft_resumed",
|
||||
details: { pickNumber: season.currentPickNumber },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and, gte } from "drizzle-orm";
|
||||
|
|
@ -10,7 +10,8 @@ import { logger } from "~/lib/logger";
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
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) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
|
@ -87,7 +88,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId,
|
||||
leagueId: season.leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "draft_rollback",
|
||||
affectedTeamIds: rollbackSlot?.teamId ? [rollbackSlot.teamId] : [],
|
||||
details: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
|
@ -11,7 +11,8 @@ import { logger } from "~/lib/logger";
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
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) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
|
@ -81,7 +82,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId,
|
||||
leagueId: season.leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "draft_started",
|
||||
details: { pickNumber: 1 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
|
@ -8,7 +8,8 @@ import { getSocketIO } from "../../../server/socket";
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
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) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
|
@ -8,7 +8,8 @@ import type { ActionFunctionArgs } from "react-router";
|
|||
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
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) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
|
@ -8,7 +8,8 @@ import { getSocketIO } from "../../../server/socket";
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
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) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
|
@ -8,7 +8,8 @@ import { getSocketIO } from "../../../server/socket";
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
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) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
|
|
|||
|
|
@ -67,24 +67,24 @@ export async function loader({ params }: LoaderFunctionArgs) {
|
|||
const clockOwnerId = onTheClockSlot?.team.ownerId ?? null;
|
||||
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) {
|
||||
const owners = await db
|
||||
.select({
|
||||
clerkId: schema.users.clerkId,
|
||||
id: schema.users.id,
|
||||
username: schema.users.username,
|
||||
displayName: schema.users.displayName,
|
||||
})
|
||||
.from(schema.users)
|
||||
.where(inArray(schema.users.clerkId, allOwnerIds));
|
||||
.where(inArray(schema.users.id, allOwnerIds));
|
||||
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
|
||||
const getUsername = (ownerId: string | null) =>
|
||||
ownerId ? (usernameByClerkId.get(ownerId) ?? null) : null;
|
||||
ownerId ? (usernameByUserId.get(ownerId) ?? null) : null;
|
||||
|
||||
const onTheClock = onTheClockSlot
|
||||
? { teamName: onTheClockSlot.team.name, username: getUsername(onTheClockSlot.team.ownerId) }
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { addDays, subDays } from "date-fns";
|
||||
|
||||
import type { Route } from "./+types/home";
|
||||
|
|
@ -34,7 +34,8 @@ export function meta() {
|
|||
}
|
||||
|
||||
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) {
|
||||
return { leagues: [], isLoggedIn: false, upcomingCalendarEvents: [] };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Form, redirect } from "react-router";
|
||||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { SignInButton } from "@clerk/react-router";
|
||||
import { Form, redirect, Link } from "react-router";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import type { Route } from "./+types/i.$inviteCode";
|
||||
|
||||
import {
|
||||
|
|
@ -10,7 +9,7 @@ import {
|
|||
findAvailableTeams,
|
||||
claimTeam,
|
||||
isUserLeagueMember,
|
||||
findUserByClerkId,
|
||||
findUserById,
|
||||
getUserDisplayName,
|
||||
} from "~/models";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
|
@ -29,7 +28,8 @@ export function meta(): Route.MetaDescriptors {
|
|||
export async function loader(args: Route.LoaderArgs) {
|
||||
const { params } = args;
|
||||
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
|
||||
const season = await findSeasonByInviteCode(inviteCode);
|
||||
|
|
@ -68,7 +68,8 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
export async function action(args: Route.ActionArgs) {
|
||||
const { params } = args;
|
||||
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) {
|
||||
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
|
||||
const user = await findUserByClerkId(userId);
|
||||
const user = await findUserById(userId);
|
||||
if (!user) {
|
||||
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">
|
||||
You'll need to sign in or create an account to join this league.
|
||||
</p>
|
||||
<SignInButton mode="modal" forceRedirectUrl={`/i/${loaderData.inviteCode}`}>
|
||||
<Link to={`/login?redirectTo=/i/${loaderData.inviteCode}`}>
|
||||
<Button className="w-full">Sign In to Join</Button>
|
||||
</SignInButton>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { format } from "date-fns";
|
||||
import { Link, Form, useSearchParams } from "react-router";
|
||||
import type { Route } from "./+types/$leagueId.audit-log";
|
||||
|
|
@ -42,7 +42,8 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|||
const PAGE_SIZE = 50;
|
||||
|
||||
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 { leagueId } = params;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { useLoaderData, Link, useRevalidator } from "react-router";
|
||||
import { useAuth } from "@clerk/react-router";
|
||||
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
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 { useDraftAuthRecovery } from "~/hooks/useDraftAuthRecovery";
|
||||
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) {
|
||||
const { params } = args;
|
||||
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) {
|
||||
throw new Response("Season ID is required", { status: 400 });
|
||||
|
|
@ -147,7 +147,6 @@ export default function DraftRoom() {
|
|||
ownerMap,
|
||||
} = useLoaderData<typeof loader>();
|
||||
const { revalidate, state: revalidatorState } = useRevalidator();
|
||||
const { userId: clerkUserId, getToken } = useAuth();
|
||||
const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id);
|
||||
const {
|
||||
permissionState: notificationsPermission,
|
||||
|
|
@ -228,9 +227,7 @@ export default function DraftRoom() {
|
|||
reconnectCount,
|
||||
revalidate,
|
||||
revalidatorState,
|
||||
clerkUserId,
|
||||
currentUserId,
|
||||
getToken,
|
||||
userAutodraftSettings,
|
||||
draftPicks,
|
||||
season,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { addDays, subDays } from "date-fns";
|
||||
import { toEventSortKey } from "~/lib/date-utils";
|
||||
import {
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
findCommissionersByLeagueId,
|
||||
isUserLeagueMember,
|
||||
isCommissioner,
|
||||
findUsersByClerkIds,
|
||||
findUsersByIds,
|
||||
getUserDisplayName,
|
||||
findDraftSlotsBySeasonId,
|
||||
} from "~/models";
|
||||
|
|
@ -20,7 +20,8 @@ import { getAuditLogForSeason } from "~/models/audit-log";
|
|||
import type { Route } from "./+types/$leagueId";
|
||||
|
||||
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 { 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 commissionerIds = commissioners.map((c) => c.userId);
|
||||
const allUserIds = [...new Set([...ownerIds, ...commissionerIds])];
|
||||
const userRows = await findUsersByClerkIds(allUserIds);
|
||||
const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u]));
|
||||
const userRows = await findUsersByIds(allUserIds);
|
||||
const userById = new Map(userRows.map((u) => [u.id, u]));
|
||||
|
||||
const ownerMap = new Map(
|
||||
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)
|
||||
.map(([id, user]) => [id, getUserDisplayName(user)])
|
||||
);
|
||||
|
||||
const commissionerMap = new Map(
|
||||
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)
|
||||
.map(([id, user]) => [id, getUserDisplayName(user)])
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect } from "react";
|
||||
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 { CalendarIcon } from "lucide-react";
|
||||
import { logger } from "~/lib/logger";
|
||||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from "~/models/commissioner";
|
||||
import { findCurrentSeasonWithSports, updateSeason, type NewSeason } from "~/models/season";
|
||||
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 { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season";
|
||||
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) {
|
||||
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 { leagueId } = params;
|
||||
|
||||
|
|
@ -118,7 +119,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
const teamsWithOwners = teams.filter(team => team.ownerId !== null).length;
|
||||
|
||||
// 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)
|
||||
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 commissionerUserIds = commissioners.map((c) => c.userId);
|
||||
const allUserIds = [...new Set([...commissionerUserIds, ...uniqueOwnerIds])];
|
||||
const userRows = await findUsersByClerkIds(allUserIds);
|
||||
const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u]));
|
||||
const userRows = await findUsersByIds(allUserIds);
|
||||
const userById = new Map(userRows.map((u) => [u.id, u]));
|
||||
|
||||
const commissionerUserData = commissioners.map((c) => {
|
||||
const user = userByClerkId.get(c.userId);
|
||||
const user = userById.get(c.userId);
|
||||
return {
|
||||
...c,
|
||||
userName: user ? (getUserDisplayName(user) ?? "Unknown User") : "Unknown User",
|
||||
|
|
@ -143,16 +144,15 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
|
||||
const validOwners = uniqueOwnerIds
|
||||
.map((ownerId) => {
|
||||
const user = userByClerkId.get(ownerId);
|
||||
return user ? { clerkId: ownerId, name: getUserDisplayName(user), id: user.id } : null;
|
||||
const user = userById.get(ownerId);
|
||||
return user ? { id: user.id, name: getUserDisplayName(user) } : 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
|
||||
const leagueMembers = validOwners.map((o) => ({
|
||||
id: o.id,
|
||||
clerkId: o.clerkId,
|
||||
name: o.name,
|
||||
}));
|
||||
|
||||
|
|
@ -174,7 +174,8 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
}
|
||||
|
||||
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 { leagueId } = params;
|
||||
|
||||
|
|
@ -236,7 +237,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId: currentSeason.id,
|
||||
leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "league_settings_changed",
|
||||
details: {
|
||||
changedFields,
|
||||
|
|
@ -369,7 +370,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId: season.id,
|
||||
leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "draft_order_set",
|
||||
affectedTeamIds: teamIds,
|
||||
details: {
|
||||
|
|
@ -401,7 +402,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId: season.id,
|
||||
leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "draft_order_randomized",
|
||||
affectedTeamIds: sortedSlots.map((s) => s.teamId),
|
||||
details: {
|
||||
|
|
@ -434,27 +435,27 @@ export async function action(args: Route.ActionArgs) {
|
|||
|
||||
if (intent === "assign-team-owner") {
|
||||
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" };
|
||||
}
|
||||
|
||||
// Check if user is admin (only admins can assign owners)
|
||||
const isAdmin = await isUserAdminByClerkId(userId);
|
||||
const isAdmin = await isUserAdmin(userId);
|
||||
if (!isAdmin) {
|
||||
return { error: "Only admins can assign team owners" };
|
||||
}
|
||||
|
||||
// Look up user before assigning to fail fast and get their name
|
||||
const assignedUser = await findUserByClerkId(userClerkId);
|
||||
const assignedUser = await findUserById(assignedUserId);
|
||||
if (!assignedUser) {
|
||||
return { error: "User not found. Please try again." };
|
||||
}
|
||||
|
||||
// Check if user is already assigned to a team in this season
|
||||
const teams = await findTeamsBySeasonId(season.id);
|
||||
const userAlreadyHasTeam = teams.some(team => team.ownerId === userClerkId);
|
||||
const userAlreadyHasTeam = teams.some(team => team.ownerId === assignedUserId);
|
||||
|
||||
if (userAlreadyHasTeam) {
|
||||
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 {
|
||||
const teamName = `Team ${getUserDisplayName(assignedUser) ?? "Member"}`;
|
||||
await claimTeam(teamId, userClerkId, teamName);
|
||||
await claimTeam(teamId, assignedUserId, teamName);
|
||||
|
||||
return { success: true, message: "Owner assigned successfully" };
|
||||
} catch (error) {
|
||||
|
|
@ -479,7 +480,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
|
||||
if (intent === "reset-draft") {
|
||||
// Check if user is admin (only admins can reset draft)
|
||||
const isAdmin = await isUserAdminByClerkId(userId);
|
||||
const isAdmin = await isUserAdmin(userId);
|
||||
if (!isAdmin) {
|
||||
return { error: "Only admins can reset the draft" };
|
||||
}
|
||||
|
|
@ -507,7 +508,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId: season.id,
|
||||
leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "draft_reset",
|
||||
details: { previousPickNumber },
|
||||
});
|
||||
|
|
@ -625,7 +626,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId: season.id,
|
||||
leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "draft_settings_changed",
|
||||
details: {
|
||||
changedFields: draftFields,
|
||||
|
|
@ -639,7 +640,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId: season.id,
|
||||
leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "scoring_rules_changed",
|
||||
details: {
|
||||
changedFields: scoringChangedFields,
|
||||
|
|
@ -690,7 +691,7 @@ export async function action(args: Route.ActionArgs) {
|
|||
await logCommissionerAction({
|
||||
seasonId: season.id,
|
||||
leagueId,
|
||||
actorClerkId: userId,
|
||||
actorUserId: userId,
|
||||
action: "sports_changed",
|
||||
details: {
|
||||
added: addedNames,
|
||||
|
|
@ -769,21 +770,21 @@ export async function action(args: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
|
||||
// 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)
|
||||
const alreadyCommissioner = await hasCommissionerRecord(leagueId, userClerkId);
|
||||
const alreadyCommissioner = await hasCommissionerRecord(leagueId, newCommissionerUserId);
|
||||
if (alreadyCommissioner) {
|
||||
return { error: "This user is already a commissioner" };
|
||||
}
|
||||
|
||||
try {
|
||||
await createCommissioner({ leagueId, userId: userClerkId });
|
||||
await createCommissioner({ leagueId, userId: newCommissionerUserId });
|
||||
return { success: true, message: "Commissioner added successfully" };
|
||||
} catch (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">
|
||||
<input type="hidden" name="intent" value="assign-team-owner" />
|
||||
<input type="hidden" name="teamId" value={team.id} />
|
||||
<Select name="userClerkId" required>
|
||||
<Select name="userId" required>
|
||||
<SelectTrigger className="w-[180px] h-9">
|
||||
<SelectValue placeholder="Select user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allUsers.map((user) => {
|
||||
const userOwnsTeam = teams.some((t) => t.ownerId === user.clerkId);
|
||||
const userOwnsTeam = teams.some((t) => t.ownerId === user.id);
|
||||
return (
|
||||
<SelectItem
|
||||
key={user.id}
|
||||
value={user.clerkId}
|
||||
value={user.id}
|
||||
disabled={userOwnsTeam}
|
||||
>
|
||||
{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>
|
||||
<Form method="post" className="flex gap-2">
|
||||
<input type="hidden" name="intent" value="add-commissioner" />
|
||||
<Select name="userClerkId" required>
|
||||
<Select name="userId" required>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select a user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{leagueMembers.map((member) => {
|
||||
const alreadyCommissioner = commissioners.some(
|
||||
(c) => c.userId === member.clerkId
|
||||
(c) => c.userId === member.id
|
||||
);
|
||||
return (
|
||||
<SelectItem
|
||||
key={member.id}
|
||||
value={member.clerkId}
|
||||
value={member.id}
|
||||
disabled={alreadyCommissioner}
|
||||
>
|
||||
{member.name || "Unknown"}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
findLeagueById,
|
||||
|
|
@ -29,7 +29,8 @@ import * as schema from "~/database/schema";
|
|||
import { eq, and, inArray } from "drizzle-orm";
|
||||
|
||||
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 { 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 ownerUserRows = ownerIds.length > 0
|
||||
? await db.query.users.findMany({
|
||||
where: inArray(schema.users.clerkId, ownerIds),
|
||||
columns: { clerkId: true, username: true, displayName: true },
|
||||
where: inArray(schema.users.id, ownerIds),
|
||||
columns: { id: true, username: true, displayName: true },
|
||||
})
|
||||
: [];
|
||||
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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/
|
|||
import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
|
||||
import { getRecentTeamScoreEvents } from "~/models/team-score-events";
|
||||
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 { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
||||
import { RecentScoresCard } from "~/components/standings/RecentScoresCard";
|
||||
|
|
@ -69,13 +69,13 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
|
||||
// Build teamId -> ownerName map
|
||||
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
|
||||
const userRows = await findUsersByClerkIds(ownerIds);
|
||||
const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u]));
|
||||
const userRows = await findUsersByIds(ownerIds);
|
||||
const userById = new Map(userRows.map((u) => [u.id, u]));
|
||||
const ownerNameByTeamId = new Map(
|
||||
teams
|
||||
.filter((t): t is typeof t & { ownerId: string } => t.ownerId !== null)
|
||||
.map((t) => {
|
||||
const user = userByClerkId.get(t.ownerId);
|
||||
const user = userById.get(t.ownerId);
|
||||
return [t.id, user ? getUserDisplayName(user) : null] as const;
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { addDays, subDays } from "date-fns";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
|
@ -23,7 +23,8 @@ export function meta({ data }: Route.MetaArgs) {
|
|||
}
|
||||
|
||||
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 league = await findLeagueById(leagueId);
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ vi.mock("~/database/context");
|
|||
vi.mock("~/server/socket", () => ({
|
||||
getSocketIO: vi.fn(),
|
||||
}));
|
||||
vi.mock("@clerk/react-router/server", () => ({
|
||||
getAuth: vi.fn(),
|
||||
vi.mock("~/lib/auth.server", () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
}));
|
||||
vi.mock("~/models/user", () => ({
|
||||
isUserAdminByClerkId: vi.fn().mockResolvedValue(false),
|
||||
isUserAdmin: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
describe("Autodraft Settings API", () => {
|
||||
|
|
@ -23,9 +23,9 @@ describe("Autodraft Settings API", () => {
|
|||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock Clerk auth
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId: "user-789" } as any);
|
||||
// Mock auth session
|
||||
const { auth } = await import("~/lib/auth.server");
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: "user-789" } } as any);
|
||||
|
||||
// Mock Socket.IO
|
||||
mockSocketIO = {
|
||||
|
|
@ -389,8 +389,8 @@ describe("Autodraft Settings API", () => {
|
|||
const teamId = "team-xyz";
|
||||
const userId = "user-123";
|
||||
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId } as any);
|
||||
const { auth } = await import("~/lib/auth.server");
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: userId } } as any);
|
||||
|
||||
mockDb.query.teams.findFirst.mockResolvedValue({
|
||||
id: teamId,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { deleteAllDraftPicks } from '~/models/draft-pick';
|
|||
import { clearAllQueuesForSeason } from '~/models/draft-queue';
|
||||
import { deleteSeasonTimers } from '~/models/draft-timer';
|
||||
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
|
||||
const createMockSeason = (overrides: {
|
||||
|
|
@ -58,7 +58,7 @@ vi.mock('~/models/season', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('~/models/user', () => ({
|
||||
isUserAdminByClerkId: vi.fn(),
|
||||
isUserAdmin: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Draft Reset - Authorization', () => {
|
||||
|
|
@ -69,20 +69,20 @@ describe('Draft Reset - Authorization', () => {
|
|||
it('should only allow admins to reset draft', async () => {
|
||||
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(isUserAdminByClerkId).toHaveBeenCalledWith(userId);
|
||||
expect(isUserAdmin).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it('should reject non-admin users from resetting draft', async () => {
|
||||
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);
|
||||
|
||||
|
|
@ -98,9 +98,9 @@ describe('Draft Reset - Authorization', () => {
|
|||
const commissionerUserId = 'commissioner-user-id';
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
|
@ -243,8 +243,8 @@ describe('Draft Reset - Complete Workflow', () => {
|
|||
const userId = 'admin-user-id';
|
||||
|
||||
// Step 1: Verify admin
|
||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
||||
const isAdmin = await isUserAdminByClerkId(userId);
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||
const isAdmin = await isUserAdmin(userId);
|
||||
expect(isAdmin).toBe(true);
|
||||
|
||||
// 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 () => {
|
||||
const userId = 'regular-user-id';
|
||||
|
||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
||||
const isAdmin = await isUserAdminByClerkId(userId);
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||
const isAdmin = await isUserAdmin(userId);
|
||||
|
||||
expect(isAdmin).toBe(false);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
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
|
||||
const createMockTeam = (overrides: {
|
||||
|
|
@ -31,8 +31,8 @@ vi.mock('~/models/team', () => ({
|
|||
|
||||
vi.mock('~/models/user', () => ({
|
||||
findAllUsers: vi.fn(),
|
||||
findUserByClerkId: vi.fn(),
|
||||
isUserAdminByClerkId: vi.fn(),
|
||||
findUserById: vi.fn(),
|
||||
isUserAdmin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('~/models/league', () => ({
|
||||
|
|
@ -138,21 +138,21 @@ describe('Team Management - Assign Owner', () => {
|
|||
const userId = 'admin-user-id';
|
||||
|
||||
// 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(isUserAdminByClerkId).toHaveBeenCalledWith(userId);
|
||||
expect(isUserAdmin).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it('should prevent non-admins from assigning owners', async () => {
|
||||
const userId = 'regular-user-id';
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
|
@ -182,8 +182,9 @@ describe('Team Management - Admin User List', () => {
|
|||
const mockUsers = [
|
||||
{
|
||||
id: 'user-1',
|
||||
clerkId: 'clerk-1',
|
||||
clerkId: null,
|
||||
email: 'user1@example.com',
|
||||
emailVerified: true,
|
||||
username: 'user1',
|
||||
displayName: 'User One',
|
||||
firstName: 'User',
|
||||
|
|
@ -195,8 +196,9 @@ describe('Team Management - Admin User List', () => {
|
|||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
clerkId: 'clerk-2',
|
||||
clerkId: null,
|
||||
email: 'user2@example.com',
|
||||
emailVerified: true,
|
||||
username: 'user2',
|
||||
displayName: 'User Two',
|
||||
firstName: 'User',
|
||||
|
|
@ -221,16 +223,17 @@ describe('Team Management - Admin User List', () => {
|
|||
it('should only fetch users when user is admin', async () => {
|
||||
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) {
|
||||
const mockUsers = [
|
||||
{
|
||||
id: 'user-1',
|
||||
clerkId: 'clerk-1',
|
||||
clerkId: null,
|
||||
email: 'user1@example.com',
|
||||
emailVerified: true,
|
||||
username: 'user1',
|
||||
displayName: 'User One',
|
||||
firstName: 'User',
|
||||
|
|
@ -254,9 +257,9 @@ describe('Team Management - Admin User List', () => {
|
|||
it('should return empty array for non-admin users', async () => {
|
||||
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
|
||||
const users = isAdmin ? await findAllUsers() : [];
|
||||
|
|
@ -275,20 +278,20 @@ describe('Team Management - Authorization', () => {
|
|||
it('should verify admin status before allowing assignment', async () => {
|
||||
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(isUserAdminByClerkId).toHaveBeenCalledWith(userId);
|
||||
expect(isUserAdmin).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
it('should reject assignment for non-admin users', async () => {
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from "react";
|
||||
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 { logger } from "~/lib/logger";
|
||||
|
|
@ -71,7 +71,8 @@ export async function loader() {
|
|||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!userId) {
|
||||
|
|
|
|||
120
app/routes/login.tsx
Normal file
120
app/routes/login.tsx
Normal 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'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
126
app/routes/register.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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 {
|
||||
|
|
@ -37,7 +37,8 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|||
export async function loader(args: Route.LoaderArgs) {
|
||||
const { params } = args;
|
||||
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) {
|
||||
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) {
|
||||
const { params, request } = args;
|
||||
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) {
|
||||
throw new Response("You must be logged in", { status: 401 });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { addDays, subDays } from "date-fns";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
|
@ -18,7 +18,8 @@ export function meta() {
|
|||
}
|
||||
|
||||
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) {
|
||||
return { isLoggedIn: false as const, upcomingCalendarEvents: [] };
|
||||
|
|
|
|||
|
|
@ -1,26 +1,106 @@
|
|||
import { UserProfile } from "@clerk/react-router";
|
||||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { redirect } from "react-router";
|
||||
import { redirect, Form } from "react-router";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { findUserById, updateUser } from "~/models/user";
|
||||
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 {
|
||||
return [{ title: "Profile - Brackt" }];
|
||||
}
|
||||
|
||||
export async function loader(args: Route.LoaderArgs) {
|
||||
const { userId } = await getAuth(args);
|
||||
|
||||
if (!userId) {
|
||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||
if (!session) {
|
||||
return redirect("/login?redirectTo=/user-profile");
|
||||
}
|
||||
const user = await findUserById(session.user.id);
|
||||
if (!user) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
return {};
|
||||
return { user };
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="container mx-auto py-8 px-4 flex justify-center">
|
||||
<UserProfile />
|
||||
<div className="container mx-auto py-8 px-4 max-w-lg">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
4
app/test/fixtures/user.ts
vendored
4
app/test/fixtures/user.ts
vendored
|
|
@ -2,6 +2,7 @@ export const mockUser = {
|
|||
id: 'user-1',
|
||||
clerkId: 'clerk-user-1',
|
||||
email: 'user1@example.com',
|
||||
emailVerified: true,
|
||||
username: 'user1',
|
||||
displayName: 'User One',
|
||||
firstName: 'User',
|
||||
|
|
@ -16,6 +17,7 @@ export const mockAdminUser = {
|
|||
id: 'admin-1',
|
||||
clerkId: 'clerk-admin-1',
|
||||
email: 'admin@example.com',
|
||||
emailVerified: true,
|
||||
username: 'admin',
|
||||
displayName: 'Admin User',
|
||||
firstName: 'Admin',
|
||||
|
|
@ -32,6 +34,7 @@ export const mockUsers = [
|
|||
id: 'user-2',
|
||||
clerkId: 'clerk-user-2',
|
||||
email: 'user2@example.com',
|
||||
emailVerified: true,
|
||||
username: 'user2',
|
||||
displayName: 'User Two',
|
||||
firstName: 'User',
|
||||
|
|
@ -45,6 +48,7 @@ export const mockUsers = [
|
|||
id: 'user-3',
|
||||
clerkId: 'clerk-user-3',
|
||||
email: 'user3@example.com',
|
||||
emailVerified: true,
|
||||
username: 'user3',
|
||||
displayName: 'User Three',
|
||||
firstName: 'User',
|
||||
|
|
|
|||
|
|
@ -10,11 +10,16 @@ afterEach(() => {
|
|||
// Mock environment variables
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// Mock Clerk
|
||||
vi.mock('@clerk/react-router/server', () => ({
|
||||
getAuth: vi.fn(() => Promise.resolve({ userId: 'test-user-id' })),
|
||||
clerkMiddleware: vi.fn(() => (req: unknown, res: unknown, next: () => void) => next()),
|
||||
rootAuthLoader: vi.fn(() => Promise.resolve({ userId: 'test-user-id' })),
|
||||
// Mock BetterAuth
|
||||
vi.mock('~/lib/auth.server', () => ({
|
||||
auth: {
|
||||
api: {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,21 +1,59 @@
|
|||
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";
|
||||
|
||||
// Users table - synced from Clerk
|
||||
export const users = pgTable("users", {
|
||||
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(),
|
||||
emailVerified: boolean("email_verified").notNull().default(false),
|
||||
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 }),
|
||||
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),
|
||||
createdAt: timestamp("created_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
|
||||
export const seasonStatusEnum = pgEnum("season_status", [
|
||||
"pre_draft",
|
||||
|
|
|
|||
49
drizzle/0081_careless_magdalene.sql
Normal file
49
drizzle/0081_careless_magdalene.sql
Normal 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
|
||||
3
drizzle/0082_stale_pride.sql
Normal file
3
drizzle/0082_stale_pride.sql
Normal 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;
|
||||
5050
drizzle/meta/0081_snapshot.json
Normal file
5050
drizzle/meta/0081_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5061
drizzle/meta/0082_snapshot.json
Normal file
5061
drizzle/meta/0082_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -568,6 +568,20 @@
|
|||
"when": 1776318182740,
|
||||
"tag": "0080_chemical_gorilla_man",
|
||||
"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
535
package-lock.json
generated
|
|
@ -6,8 +6,6 @@
|
|||
"": {
|
||||
"name": "brackt.com",
|
||||
"dependencies": {
|
||||
"@clerk/react-router": "^2.1.0",
|
||||
"@clerk/themes": "^2.4.55",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
|
@ -31,6 +29,8 @@
|
|||
"@sentry/react-router": "^10.43.0",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "^1.6.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"compression": "^1.8.0",
|
||||
|
|
@ -52,7 +52,6 @@
|
|||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"svix": "^1.77.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
|
@ -67,6 +66,7 @@
|
|||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/express-serve-static-core": "^5.0.6",
|
||||
|
|
@ -671,126 +671,150 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/backend": {
|
||||
"version": "2.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-2.17.2.tgz",
|
||||
"integrity": "sha512-zgKySfoOXySYOMEDc+S2vXLchCldwPVb85tyvP1NnmxvgyAm10yCA+xd4No4dNWm+lwkwHuNWX3rM9ro8khSmw==",
|
||||
"node_modules/@better-auth/core": {
|
||||
"version": "1.6.9",
|
||||
"resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.9.tgz",
|
||||
"integrity": "sha512-ADFk5pwmLybmc+LvYvXJ6M1x2oY/EyYLkwLuH0x28FUq12DfjL0wnE7g+WRDf3yozDO+qIxTpFGXDGwLKbfz0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/shared": "^3.27.3",
|
||||
"@clerk/types": "^4.92.0",
|
||||
"cookie": "1.0.2",
|
||||
"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"
|
||||
"@opentelemetry/semantic-conventions": "^1.39.0",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/react-router": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/react-router/-/react-router-2.1.0.tgz",
|
||||
"integrity": "sha512-qAfMxiiPmSs7Vv5oj1qXYedyCrGnL4c/susr2P74BW07Fyz13wT1/ZEJ5Y4g+u1GGSPxvbQtv8OWsNIsulclKw==",
|
||||
"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"
|
||||
"@better-auth/utils": "0.4.0",
|
||||
"@better-fetch/fetch": "1.1.21",
|
||||
"@cloudflare/workers-types": ">=4",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"better-call": "1.3.5",
|
||||
"jose": "^6.1.0",
|
||||
"kysely": "^0.28.5",
|
||||
"nanostores": "^1.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"@cloudflare/workers-types": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/themes": {
|
||||
"version": "2.4.55",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/themes/-/themes-2.4.55.tgz",
|
||||
"integrity": "sha512-j9q8NtAaI2f7vNBuO2RAUDmAebab2UoZCXshlTzEhsbB1UH+94fPs4KyUlsbrSNxIJNfTrM2IKxAZKos3gcCJw==",
|
||||
"node_modules/@better-auth/drizzle-adapter": {
|
||||
"version": "1.6.9",
|
||||
"resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.9.tgz",
|
||||
"integrity": "sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/shared": "^3.47.0",
|
||||
"tslib": "2.8.1"
|
||||
"peerDependencies": {
|
||||
"@better-auth/core": "^1.6.9",
|
||||
"@better-auth/utils": "0.4.0",
|
||||
"drizzle-orm": "^0.45.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
"peerDependenciesMeta": {
|
||||
"drizzle-orm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/types": {
|
||||
"version": "4.92.0",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.92.0.tgz",
|
||||
"integrity": "sha512-+bUiHjqVXEHJIOOhshIy3uYDF/c4/yNc2BPfgPTXxxsbz/2wG0XUx0PL+mxUPiruPZOD+D63AtmORuFW3yBa2w==",
|
||||
"node_modules/@better-auth/kysely-adapter": {
|
||||
"version": "1.6.9",
|
||||
"resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.9.tgz",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"csstype": "3.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
"@noble/hashes": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
|
|
@ -4977,6 +5001,7 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -4990,6 +5015,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5003,6 +5029,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5016,6 +5043,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5029,6 +5057,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5042,6 +5071,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5055,6 +5085,7 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5068,6 +5099,7 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5081,6 +5113,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5094,6 +5127,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5107,6 +5141,7 @@
|
|||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5120,6 +5155,7 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5133,6 +5169,7 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5146,6 +5183,7 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5159,6 +5197,7 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5172,6 +5211,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5185,6 +5225,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5198,6 +5239,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5211,6 +5253,7 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5224,6 +5267,7 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5237,6 +5281,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5250,6 +5295,7 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5983,9 +6029,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
|
|
@ -6706,6 +6752,16 @@
|
|||
"@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": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
|
|
@ -6837,6 +6893,7 @@
|
|||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
|
|
@ -6957,7 +7014,7 @@
|
|||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
|
|
@ -6967,7 +7024,7 @@
|
|||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz",
|
||||
"integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
|
|
@ -7772,6 +7829,20 @@
|
|||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
|
|
@ -7782,6 +7853,161 @@
|
|||
"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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
|
|
@ -8778,6 +9004,7 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cypress": {
|
||||
|
|
@ -9145,6 +9372,12 @@
|
|||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
|
|
@ -9168,6 +9401,7 @@
|
|||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
|
@ -10834,6 +11068,7 @@
|
|||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
|
@ -11034,12 +11269,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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz",
|
||||
|
|
@ -11802,21 +12031,11 @@
|
|||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz",
|
||||
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -12006,6 +12225,15 @@
|
|||
"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": {
|
||||
"version": "1.6.0",
|
||||
"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_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": {
|
||||
"version": "0.6.4",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
|
|
@ -13011,6 +13263,17 @@
|
|||
"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": {
|
||||
"version": "2.0.23",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
|
||||
|
|
@ -14249,6 +14512,7 @@
|
|||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
|
|
@ -14645,6 +14909,7 @@
|
|||
"version": "4.52.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
|
||||
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
|
|
@ -14682,6 +14947,12 @@
|
|||
"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": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
|
|
@ -15557,6 +15828,7 @@
|
|||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
|
||||
"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stdin-discarder": {
|
||||
|
|
@ -15910,19 +16182,6 @@
|
|||
"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": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
|
|
@ -16326,7 +16585,7 @@
|
|||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@
|
|||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/react-router": "^2.1.0",
|
||||
"@clerk/themes": "^2.4.55",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
|
@ -53,6 +51,8 @@
|
|||
"@sentry/react-router": "^10.43.0",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "^1.6.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"compression": "^1.8.0",
|
||||
|
|
@ -74,7 +74,6 @@
|
|||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"svix": "^1.77.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
|
@ -89,6 +88,7 @@
|
|||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/express-serve-static-core": "^5.0.6",
|
||||
|
|
|
|||
143
scripts/migrate-clerk-passwords.mjs
Normal file
143
scripts/migrate-clerk-passwords.mjs
Normal 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.");
|
||||
}
|
||||
|
|
@ -29,5 +29,168 @@ try {
|
|||
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(() => {});
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue