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.
|
# Leave both unset for local development with plain PostgreSQL.
|
||||||
PGBOUNCER=
|
PGBOUNCER=
|
||||||
DATABASE_DIRECT_URL=
|
DATABASE_DIRECT_URL=
|
||||||
CLERK_SECRET_KEY=""
|
BETTER_AUTH_SECRET=""
|
||||||
CLERK_PUBLISHABLE_KEY=""
|
BETTER_AUTH_URL=""
|
||||||
CLERK_WEBHOOK_SECRET=""
|
GOOGLE_CLIENT_ID=""
|
||||||
|
GOOGLE_CLIENT_SECRET=""
|
||||||
|
DISCORD_CLIENT_ID=""
|
||||||
|
DISCORD_CLIENT_SECRET=""
|
||||||
CONTAINER_REGISTRY=""
|
CONTAINER_REGISTRY=""
|
||||||
DEV_ADMIN_CLERK_ID=""
|
|
||||||
SENTRY_AUTH_TOKEN=""
|
SENTRY_AUTH_TOKEN=""
|
||||||
SQUIGGLE_CONTACT_EMAIL=""
|
SQUIGGLE_CONTACT_EMAIL=""
|
||||||
RESEND_API_KEY=""
|
RESEND_API_KEY=""
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -21,3 +21,6 @@
|
||||||
storybook-static
|
storybook-static
|
||||||
.grepai/
|
.grepai/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
# Clerk migration export — contains password hashes, never commit
|
||||||
|
exported_users.csv
|
||||||
|
|
|
||||||
1
.npmrc
Normal file
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 { useState } from "react";
|
||||||
import { Link, NavLink } from "react-router";
|
import { Link, NavLink, useLocation } from "react-router";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
|
|
@ -8,7 +8,8 @@ import {
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "~/components/ui/sheet";
|
} from "~/components/ui/sheet";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/react-router";
|
import { authClient } from "~/lib/auth-client";
|
||||||
|
import { UserMenu } from "~/components/UserMenu";
|
||||||
import { HelpCircle, MenuIcon, Settings } from "lucide-react";
|
import { HelpCircle, MenuIcon, Settings } from "lucide-react";
|
||||||
|
|
||||||
const NAV_LINK_CLASS =
|
const NAV_LINK_CLASS =
|
||||||
|
|
@ -30,6 +31,22 @@ interface NavbarProps {
|
||||||
|
|
||||||
export function Navbar({ isAdmin }: NavbarProps) {
|
export function Navbar({ isAdmin }: NavbarProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
|
const signInHref = `/login?redirectTo=${encodeURIComponent(location.pathname)}`;
|
||||||
|
|
||||||
|
const authSection = session ? (
|
||||||
|
<UserMenu
|
||||||
|
name={session.user.name ?? null}
|
||||||
|
email={session.user.email}
|
||||||
|
imageUrl={session.user.image ?? null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Link to={signInHref}>
|
||||||
|
<Button>Sign In</Button>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
|
@ -40,12 +57,10 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
||||||
<img src="/logo.svg" alt="Brackt" className="h-14" />
|
<img src="/logo.svg" alt="Brackt" className="h-14" />
|
||||||
</Link>
|
</Link>
|
||||||
<nav aria-label="Main navigation" className="hidden md:flex items-center gap-1">
|
<nav aria-label="Main navigation" className="hidden md:flex items-center gap-1">
|
||||||
<SignedOut>
|
{session
|
||||||
<NavLink to="/" end className={navLinkClass}>Home</NavLink>
|
? <NavLink to="/" end className={navLinkClass}>Dashboard</NavLink>
|
||||||
</SignedOut>
|
: <NavLink to="/" end className={navLinkClass}>Home</NavLink>
|
||||||
<SignedIn>
|
}
|
||||||
<NavLink to="/" end className={navLinkClass}>Dashboard</NavLink>
|
|
||||||
</SignedIn>
|
|
||||||
<NavLink to="/how-to-play" className={navLinkClass}>How To Play</NavLink>
|
<NavLink to="/how-to-play" className={navLinkClass}>How To Play</NavLink>
|
||||||
<NavLink to="/rules" className={navLinkClass}>Rules</NavLink>
|
<NavLink to="/rules" className={navLinkClass}>Rules</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -69,14 +84,7 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
||||||
<Settings className="h-5 w-5" />
|
<Settings className="h-5 w-5" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<SignedOut>
|
{authSection}
|
||||||
<SignInButton mode="modal">
|
|
||||||
<Button>Sign In</Button>
|
|
||||||
</SignInButton>
|
|
||||||
</SignedOut>
|
|
||||||
<SignedIn>
|
|
||||||
<UserButton />
|
|
||||||
</SignedIn>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: icons + Auth + Hamburger */}
|
{/* Mobile: icons + Auth + Hamburger */}
|
||||||
|
|
@ -97,14 +105,7 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
||||||
<Settings className="h-5 w-5" />
|
<Settings className="h-5 w-5" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<SignedOut>
|
{authSection}
|
||||||
<SignInButton mode="modal">
|
|
||||||
<Button>Sign In</Button>
|
|
||||||
</SignInButton>
|
|
||||||
</SignedOut>
|
|
||||||
<SignedIn>
|
|
||||||
<UserButton />
|
|
||||||
</SignedIn>
|
|
||||||
|
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
|
|
@ -120,26 +121,14 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
||||||
<SheetTitle>Menu</SheetTitle>
|
<SheetTitle>Menu</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<nav aria-label="Mobile navigation" className="flex flex-col gap-4 mt-8">
|
<nav aria-label="Mobile navigation" className="flex flex-col gap-4 mt-8">
|
||||||
<SignedOut>
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/"
|
to="/"
|
||||||
end
|
end
|
||||||
className={mobileNavLinkClass}
|
className={mobileNavLinkClass}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
Home
|
{session ? "Dashboard" : "Home"}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</SignedOut>
|
|
||||||
<SignedIn>
|
|
||||||
<NavLink
|
|
||||||
to="/"
|
|
||||||
end
|
|
||||||
className={mobileNavLinkClass}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</NavLink>
|
|
||||||
</SignedIn>
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/how-to-play"
|
to="/how-to-play"
|
||||||
className={mobileNavLinkClass}
|
className={mobileNavLinkClass}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { authClient } from "~/lib/auth-client";
|
||||||
import type { getDraftPicksForSeason } from "~/models/draft-pick";
|
import type { getDraftPicksForSeason } from "~/models/draft-pick";
|
||||||
import type { findSeasonWithTeamsAndLeague } from "~/models/season";
|
import type { findSeasonWithTeamsAndLeague } from "~/models/season";
|
||||||
import type { getSeasonTimers } from "~/models/draft-timer";
|
import type { getSeasonTimers } from "~/models/draft-timer";
|
||||||
|
|
@ -14,9 +15,7 @@ interface UseDraftAuthRecoveryParams {
|
||||||
reconnectCount: number;
|
reconnectCount: number;
|
||||||
revalidate: () => void;
|
revalidate: () => void;
|
||||||
revalidatorState: "idle" | "loading" | "submitting";
|
revalidatorState: "idle" | "loading" | "submitting";
|
||||||
clerkUserId: string | null | undefined;
|
|
||||||
currentUserId: string | null | undefined;
|
currentUserId: string | null | undefined;
|
||||||
getToken: (opts?: { skipCache?: boolean }) => Promise<string | null>;
|
|
||||||
userAutodraftSettings: { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean } | null;
|
userAutodraftSettings: { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean } | null;
|
||||||
// Loader data for revalidation sync
|
// Loader data for revalidation sync
|
||||||
draftPicks: DraftPicks;
|
draftPicks: DraftPicks;
|
||||||
|
|
@ -34,15 +33,12 @@ interface UseDraftAuthRecoveryParams {
|
||||||
setAutodraftStatus: (fn: () => Record<string, AutodraftStatusEntry>) => void;
|
setAutodraftStatus: (fn: () => Record<string, AutodraftStatusEntry>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_AUTH_RECOVERY_ATTEMPTS = 3;
|
|
||||||
|
|
||||||
export function useDraftAuthRecovery({
|
export function useDraftAuthRecovery({
|
||||||
reconnectCount,
|
reconnectCount,
|
||||||
revalidate,
|
revalidate,
|
||||||
revalidatorState,
|
revalidatorState,
|
||||||
clerkUserId,
|
|
||||||
currentUserId,
|
currentUserId,
|
||||||
getToken,
|
|
||||||
userAutodraftSettings,
|
userAutodraftSettings,
|
||||||
draftPicks,
|
draftPicks,
|
||||||
season,
|
season,
|
||||||
|
|
@ -89,27 +85,8 @@ export function useDraftAuthRecovery({
|
||||||
};
|
};
|
||||||
}, [reconnectCount, revalidate]);
|
}, [reconnectCount, revalidate]);
|
||||||
|
|
||||||
// Guard against Clerk JWT expiry race.
|
// Re-check session when the tab becomes visible — cookie-based sessions don't
|
||||||
const authRecoveryTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
// need manual token refresh, but a long-idle tab may have an expired session.
|
||||||
const authRecoveryAttemptsRef = useRef(0);
|
|
||||||
useEffect(() => {
|
|
||||||
if (clerkUserId && !currentUserId) {
|
|
||||||
if (authRecoveryAttemptsRef.current >= MAX_AUTH_RECOVERY_ATTEMPTS) {
|
|
||||||
setAuthDegraded(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearTimeout(authRecoveryTimerRef.current);
|
|
||||||
authRecoveryTimerRef.current = setTimeout(() => {
|
|
||||||
authRecoveryAttemptsRef.current += 1;
|
|
||||||
revalidate();
|
|
||||||
}, 100);
|
|
||||||
} else if (currentUserId) {
|
|
||||||
authRecoveryAttemptsRef.current = 0;
|
|
||||||
}
|
|
||||||
return () => clearTimeout(authRecoveryTimerRef.current);
|
|
||||||
}, [clerkUserId, currentUserId, revalidate]);
|
|
||||||
|
|
||||||
// Proactively refresh the Clerk JWT when the tab becomes visible again.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authDegraded) return;
|
if (authDegraded) return;
|
||||||
|
|
||||||
|
|
@ -117,50 +94,29 @@ export function useDraftAuthRecovery({
|
||||||
let inFlight = false;
|
let inFlight = false;
|
||||||
|
|
||||||
const handleVisibilityChange = async () => {
|
const handleVisibilityChange = async () => {
|
||||||
if (document.visibilityState !== "visible" || !clerkUserId || inFlight) return;
|
if (document.visibilityState !== "visible" || inFlight) return;
|
||||||
inFlight = true;
|
inFlight = true;
|
||||||
try {
|
try {
|
||||||
const token = await getToken({ skipCache: true });
|
const { data: session } = await authClient.getSession();
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
if (token !== null) {
|
if (session) {
|
||||||
if (!currentUserId) {
|
if (!currentUserId) revalidate();
|
||||||
revalidate();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await new Promise((r) => setTimeout(r, 2500));
|
|
||||||
if (aborted) return;
|
|
||||||
const retryToken = await getToken({ skipCache: true });
|
|
||||||
if (aborted) return;
|
|
||||||
if (retryToken !== null) {
|
|
||||||
revalidate();
|
|
||||||
} else {
|
|
||||||
setAuthDegraded(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
await new Promise((r) => setTimeout(r, 2500));
|
|
||||||
if (aborted) return;
|
|
||||||
const retryToken = await getToken({ skipCache: true });
|
|
||||||
if (aborted) return;
|
|
||||||
if (retryToken !== null) {
|
|
||||||
revalidate();
|
|
||||||
} else {
|
} else {
|
||||||
setAuthDegraded(true);
|
setAuthDegraded(true);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!aborted) setAuthDegraded(true);
|
if (!aborted) setAuthDegraded(true);
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
inFlight = false;
|
inFlight = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
return () => {
|
return () => {
|
||||||
aborted = true;
|
aborted = true;
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, [clerkUserId, currentUserId, getToken, authDegraded, revalidate]);
|
}, [currentUserId, authDegraded, revalidate]);
|
||||||
|
|
||||||
// Track revalidation lifecycle to sync local state from fresh loader data.
|
// Track revalidation lifecycle to sync local state from fresh loader data.
|
||||||
const revalidatorStateRef = useRef(revalidatorState);
|
const revalidatorStateRef = useRef(revalidatorState);
|
||||||
|
|
|
||||||
3
app/lib/auth-client.ts
Normal file
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 type { database } from "~/database/context";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
@ -9,9 +9,6 @@ type Db = ReturnType<typeof database>;
|
||||||
/**
|
/**
|
||||||
* Verifies the request has an authenticated user who is either a commissioner
|
* Verifies the request has an authenticated user who is either a commissioner
|
||||||
* or a team owner in the given league/season. Throws Response on failure.
|
* or a team owner in the given league/season. Throws Response on failure.
|
||||||
*
|
|
||||||
* @param unauthMessage - 401 message when not logged in
|
|
||||||
* @param unauthorizedMessage - 403 message when logged in but not a member
|
|
||||||
*/
|
*/
|
||||||
export async function requireLeagueAccess(
|
export async function requireLeagueAccess(
|
||||||
args: LoaderFunctionArgs,
|
args: LoaderFunctionArgs,
|
||||||
|
|
@ -29,7 +26,8 @@ export async function requireLeagueAccess(
|
||||||
unauthorizedMessage?: string;
|
unauthorizedMessage?: string;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Response(unauthMessage, { status: 401 });
|
throw new Response(unauthMessage, { status: 401 });
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { findUsersByClerkIds, getUserDisplayName } from "~/models/user";
|
import { findUsersByIds, getUserDisplayName } from "~/models/user";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a map of teamId -> manager username (or displayName fallback).
|
* Builds a map of teamId -> manager username (or displayName fallback).
|
||||||
|
|
@ -11,16 +11,16 @@ export async function buildOwnerMap(
|
||||||
slots.map((s) => s.team.ownerId).filter((id): id is string => id !== null)
|
slots.map((s) => s.team.ownerId).filter((id): id is string => id !== null)
|
||||||
)];
|
)];
|
||||||
|
|
||||||
const userRows = await findUsersByClerkIds(uniqueOwnerIds);
|
const userRows = await findUsersByIds(uniqueOwnerIds);
|
||||||
const userByClerkId = new Map(
|
const userById = new Map(
|
||||||
userRows
|
userRows
|
||||||
.map((u) => [u.clerkId, getUserDisplayName(u)] as const)
|
.map((u) => [u.id, getUserDisplayName(u)] as const)
|
||||||
.filter((entry): entry is [string, string] => entry[1] !== null && entry[1] !== undefined)
|
.filter((entry): entry is [string, string] => entry[1] !== null && entry[1] !== undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
const ownerMap: Record<string, string> = {};
|
const ownerMap: Record<string, string> = {};
|
||||||
for (const slot of slots) {
|
for (const slot of slots) {
|
||||||
const name = slot.team.ownerId ? userByClerkId.get(slot.team.ownerId) : undefined;
|
const name = slot.team.ownerId ? userById.get(slot.team.ownerId) : undefined;
|
||||||
if (name) {
|
if (name) {
|
||||||
ownerMap[slot.team.id] = name;
|
ownerMap[slot.team.id] = name;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("~/models/user", () => ({
|
vi.mock("~/models/user", () => ({
|
||||||
findUserByClerkId: vi.fn(),
|
findUserById: vi.fn(),
|
||||||
getUserDisplayName: vi.fn(),
|
getUserDisplayName: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -14,18 +14,18 @@ import {
|
||||||
getAuditLogForSeason,
|
getAuditLogForSeason,
|
||||||
logCommissionerAction,
|
logCommissionerAction,
|
||||||
} from "../audit-log";
|
} from "../audit-log";
|
||||||
import { findUserByClerkId, getUserDisplayName } from "~/models/user";
|
import { findUserById, getUserDisplayName } from "~/models/user";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
|
|
||||||
const SEASON_ID = "season-1";
|
const SEASON_ID = "season-1";
|
||||||
const LEAGUE_ID = "league-1";
|
const LEAGUE_ID = "league-1";
|
||||||
const ACTOR_CLERK_ID = "user_clerk_1";
|
const ACTOR_USER_ID = "user-uuid-1";
|
||||||
|
|
||||||
const SAMPLE_ENTRY = {
|
const SAMPLE_ENTRY = {
|
||||||
id: "entry-1",
|
id: "entry-1",
|
||||||
seasonId: SEASON_ID,
|
seasonId: SEASON_ID,
|
||||||
leagueId: LEAGUE_ID,
|
leagueId: LEAGUE_ID,
|
||||||
actorClerkId: ACTOR_CLERK_ID,
|
actorClerkId: ACTOR_USER_ID,
|
||||||
actorDisplayName: "Alice",
|
actorDisplayName: "Alice",
|
||||||
action: "draft_rollback" as const,
|
action: "draft_rollback" as const,
|
||||||
affectedTeamIds: ["team-1"],
|
affectedTeamIds: ["team-1"],
|
||||||
|
|
@ -99,7 +99,7 @@ describe("createAuditLogEntry", () => {
|
||||||
const result = await createAuditLogEntry({
|
const result = await createAuditLogEntry({
|
||||||
seasonId: SEASON_ID,
|
seasonId: SEASON_ID,
|
||||||
leagueId: LEAGUE_ID,
|
leagueId: LEAGUE_ID,
|
||||||
actorClerkId: ACTOR_CLERK_ID,
|
actorClerkId: ACTOR_USER_ID,
|
||||||
actorDisplayName: "Alice",
|
actorDisplayName: "Alice",
|
||||||
action: "draft_rollback",
|
action: "draft_rollback",
|
||||||
affectedTeamIds: ["team-1"],
|
affectedTeamIds: ["team-1"],
|
||||||
|
|
@ -192,7 +192,7 @@ describe("getAuditLogForSeason", () => {
|
||||||
describe("logCommissionerAction", () => {
|
describe("logCommissionerAction", () => {
|
||||||
it("resolves the actor display name from the users table", async () => {
|
it("resolves the actor display name from the users table", async () => {
|
||||||
const mockUser = { username: "alice", displayName: "Alice Smith" };
|
const mockUser = { username: "alice", displayName: "Alice Smith" };
|
||||||
vi.mocked(findUserByClerkId).mockResolvedValue(mockUser as never);
|
vi.mocked(findUserById).mockResolvedValue(mockUser as never);
|
||||||
vi.mocked(getUserDisplayName).mockReturnValue("alice");
|
vi.mocked(getUserDisplayName).mockReturnValue("alice");
|
||||||
|
|
||||||
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
||||||
|
|
@ -201,7 +201,7 @@ describe("logCommissionerAction", () => {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId: SEASON_ID,
|
seasonId: SEASON_ID,
|
||||||
leagueId: LEAGUE_ID,
|
leagueId: LEAGUE_ID,
|
||||||
actorClerkId: ACTOR_CLERK_ID,
|
actorUserId: ACTOR_USER_ID,
|
||||||
action: "draft_rollback",
|
action: "draft_rollback",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -210,7 +210,7 @@ describe("logCommissionerAction", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to actorClerkId if the user is not found", async () => {
|
it("falls back to actorClerkId if the user is not found", async () => {
|
||||||
vi.mocked(findUserByClerkId).mockResolvedValue(undefined);
|
vi.mocked(findUserById).mockResolvedValue(undefined);
|
||||||
vi.mocked(getUserDisplayName).mockReturnValue(null);
|
vi.mocked(getUserDisplayName).mockReturnValue(null);
|
||||||
|
|
||||||
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
||||||
|
|
@ -219,16 +219,16 @@ describe("logCommissionerAction", () => {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId: SEASON_ID,
|
seasonId: SEASON_ID,
|
||||||
leagueId: LEAGUE_ID,
|
leagueId: LEAGUE_ID,
|
||||||
actorClerkId: ACTOR_CLERK_ID,
|
actorUserId: ACTOR_USER_ID,
|
||||||
action: "draft_rollback",
|
action: "draft_rollback",
|
||||||
});
|
});
|
||||||
|
|
||||||
const insertValues = (insertDb.insert as ReturnType<typeof vi.fn>).mock.results[0].value.values.mock.calls[0][0];
|
const insertValues = (insertDb.insert as ReturnType<typeof vi.fn>).mock.results[0].value.values.mock.calls[0][0];
|
||||||
expect(insertValues.actorDisplayName).toBe(ACTOR_CLERK_ID);
|
expect(insertValues.actorDisplayName).toBe(ACTOR_USER_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults affectedTeamIds to [] when not provided", async () => {
|
it("defaults affectedTeamIds to [] when not provided", async () => {
|
||||||
vi.mocked(findUserByClerkId).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
|
vi.mocked(findUserById).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
|
||||||
vi.mocked(getUserDisplayName).mockReturnValue("alice");
|
vi.mocked(getUserDisplayName).mockReturnValue("alice");
|
||||||
|
|
||||||
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
||||||
|
|
@ -237,7 +237,7 @@ describe("logCommissionerAction", () => {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId: SEASON_ID,
|
seasonId: SEASON_ID,
|
||||||
leagueId: LEAGUE_ID,
|
leagueId: LEAGUE_ID,
|
||||||
actorClerkId: ACTOR_CLERK_ID,
|
actorUserId: ACTOR_USER_ID,
|
||||||
action: "draft_started",
|
action: "draft_started",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -246,7 +246,7 @@ describe("logCommissionerAction", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults details to {} when not provided", async () => {
|
it("defaults details to {} when not provided", async () => {
|
||||||
vi.mocked(findUserByClerkId).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
|
vi.mocked(findUserById).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
|
||||||
vi.mocked(getUserDisplayName).mockReturnValue("alice");
|
vi.mocked(getUserDisplayName).mockReturnValue("alice");
|
||||||
|
|
||||||
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
||||||
|
|
@ -255,7 +255,7 @@ describe("logCommissionerAction", () => {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId: SEASON_ID,
|
seasonId: SEASON_ID,
|
||||||
leagueId: LEAGUE_ID,
|
leagueId: LEAGUE_ID,
|
||||||
actorClerkId: ACTOR_CLERK_ID,
|
actorUserId: ACTOR_USER_ID,
|
||||||
action: "draft_started",
|
action: "draft_started",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -264,7 +264,7 @@ describe("logCommissionerAction", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes through provided affectedTeamIds and details", async () => {
|
it("passes through provided affectedTeamIds and details", async () => {
|
||||||
vi.mocked(findUserByClerkId).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
|
vi.mocked(findUserById).mockResolvedValue({ username: "alice", displayName: "Alice" } as never);
|
||||||
vi.mocked(getUserDisplayName).mockReturnValue("alice");
|
vi.mocked(getUserDisplayName).mockReturnValue("alice");
|
||||||
|
|
||||||
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
const insertDb = makeInsertDb(SAMPLE_ENTRY);
|
||||||
|
|
@ -274,7 +274,7 @@ describe("logCommissionerAction", () => {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId: SEASON_ID,
|
seasonId: SEASON_ID,
|
||||||
leagueId: LEAGUE_ID,
|
leagueId: LEAGUE_ID,
|
||||||
actorClerkId: ACTOR_CLERK_ID,
|
actorUserId: ACTOR_USER_ID,
|
||||||
action: "force_manual_pick",
|
action: "force_manual_pick",
|
||||||
affectedTeamIds: ["team-7"],
|
affectedTeamIds: ["team-7"],
|
||||||
details,
|
details,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("~/models/user", () => ({
|
vi.mock("~/models/user", () => ({
|
||||||
isUserAdminByClerkId: vi.fn(),
|
isUserAdmin: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({
|
vi.mock("~/database/context", () => ({
|
||||||
|
|
@ -9,7 +9,7 @@ vi.mock("~/database/context", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { isCommissioner, hasCommissionerRecord } from "../commissioner";
|
import { isCommissioner, hasCommissionerRecord } from "../commissioner";
|
||||||
import { isUserAdminByClerkId } from "~/models/user";
|
import { isUserAdmin } from "~/models/user";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
|
|
||||||
const LEAGUE_ID = "league-1";
|
const LEAGUE_ID = "league-1";
|
||||||
|
|
@ -31,7 +31,7 @@ beforeEach(() => {
|
||||||
|
|
||||||
describe("isCommissioner", () => {
|
describe("isCommissioner", () => {
|
||||||
it("returns true for a site admin without a commissioner record", async () => {
|
it("returns true for a site admin without a commissioner record", async () => {
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||||
vi.mocked(database).mockReturnValue(makeMockDb(null) as never);
|
vi.mocked(database).mockReturnValue(makeMockDb(null) as never);
|
||||||
|
|
||||||
const result = await isCommissioner(LEAGUE_ID, USER_ID);
|
const result = await isCommissioner(LEAGUE_ID, USER_ID);
|
||||||
|
|
@ -39,7 +39,7 @@ describe("isCommissioner", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns true for a user with a commissioner record", async () => {
|
it("returns true for a user with a commissioner record", async () => {
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||||
vi.mocked(database).mockReturnValue(
|
vi.mocked(database).mockReturnValue(
|
||||||
makeMockDb({ id: "comm-1", leagueId: LEAGUE_ID, userId: USER_ID }) as never
|
makeMockDb({ id: "comm-1", leagueId: LEAGUE_ID, userId: USER_ID }) as never
|
||||||
);
|
);
|
||||||
|
|
@ -49,7 +49,7 @@ describe("isCommissioner", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns true for a site admin who also has a commissioner record", async () => {
|
it("returns true for a site admin who also has a commissioner record", async () => {
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||||
vi.mocked(database).mockReturnValue(
|
vi.mocked(database).mockReturnValue(
|
||||||
makeMockDb({ id: "comm-1", leagueId: LEAGUE_ID, userId: USER_ID }) as never
|
makeMockDb({ id: "comm-1", leagueId: LEAGUE_ID, userId: USER_ID }) as never
|
||||||
);
|
);
|
||||||
|
|
@ -59,7 +59,7 @@ describe("isCommissioner", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false for a non-admin user without a commissioner record", async () => {
|
it("returns false for a non-admin user without a commissioner record", async () => {
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||||
vi.mocked(database).mockReturnValue(makeMockDb(null) as never);
|
vi.mocked(database).mockReturnValue(makeMockDb(null) as never);
|
||||||
|
|
||||||
const result = await isCommissioner(LEAGUE_ID, USER_ID);
|
const result = await isCommissioner(LEAGUE_ID, USER_ID);
|
||||||
|
|
@ -68,7 +68,7 @@ describe("isCommissioner", () => {
|
||||||
|
|
||||||
it("runs the admin check and DB query in parallel", async () => {
|
it("runs the admin check and DB query in parallel", async () => {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
vi.mocked(isUserAdminByClerkId).mockImplementation(async () => {
|
vi.mocked(isUserAdmin).mockImplementation(async () => {
|
||||||
order.push("admin");
|
order.push("admin");
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
@ -110,6 +110,6 @@ describe("hasCommissionerRecord", () => {
|
||||||
|
|
||||||
const result = await hasCommissionerRecord(LEAGUE_ID, USER_ID);
|
const result = await hasCommissionerRecord(LEAGUE_ID, USER_ID);
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
expect(isUserAdminByClerkId).not.toHaveBeenCalled();
|
expect(isUserAdmin).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { eq, desc, and, inArray, sql } from "drizzle-orm";
|
import { eq, desc, and, inArray, sql } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { findUserByClerkId, getUserDisplayName } from "~/models/user";
|
import { findUserById, getUserDisplayName } from "~/models/user";
|
||||||
|
|
||||||
export type AuditLogEntry = typeof schema.commissionerAuditLog.$inferSelect;
|
export type AuditLogEntry = typeof schema.commissionerAuditLog.$inferSelect;
|
||||||
export type NewAuditLogEntry = typeof schema.commissionerAuditLog.$inferInsert;
|
export type NewAuditLogEntry = typeof schema.commissionerAuditLog.$inferInsert;
|
||||||
|
|
@ -65,20 +65,20 @@ export async function getAuditLogForSeason(
|
||||||
export async function logCommissionerAction(params: {
|
export async function logCommissionerAction(params: {
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
actorClerkId: string;
|
actorUserId: string;
|
||||||
action: AuditAction;
|
action: AuditAction;
|
||||||
affectedTeamIds?: string[];
|
affectedTeamIds?: string[];
|
||||||
details?: Record<string, unknown>;
|
details?: Record<string, unknown>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const user = await findUserByClerkId(params.actorClerkId);
|
const user = await findUserById(params.actorUserId);
|
||||||
const actorDisplayName = user
|
const actorDisplayName = user
|
||||||
? (getUserDisplayName(user) ?? params.actorClerkId)
|
? (getUserDisplayName(user) ?? params.actorUserId)
|
||||||
: params.actorClerkId;
|
: params.actorUserId;
|
||||||
|
|
||||||
await createAuditLogEntry({
|
await createAuditLogEntry({
|
||||||
seasonId: params.seasonId,
|
seasonId: params.seasonId,
|
||||||
leagueId: params.leagueId,
|
leagueId: params.leagueId,
|
||||||
actorClerkId: params.actorClerkId,
|
actorClerkId: params.actorUserId,
|
||||||
actorDisplayName,
|
actorDisplayName,
|
||||||
action: params.action,
|
action: params.action,
|
||||||
affectedTeamIds: params.affectedTeamIds ?? [],
|
affectedTeamIds: params.affectedTeamIds ?? [],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { isUserAdminByClerkId } from "~/models/user";
|
import { isUserAdmin } from "~/models/user";
|
||||||
|
|
||||||
export type Commissioner = typeof schema.commissioners.$inferSelect;
|
export type Commissioner = typeof schema.commissioners.$inferSelect;
|
||||||
export type NewCommissioner = typeof schema.commissioners.$inferInsert;
|
export type NewCommissioner = typeof schema.commissioners.$inferInsert;
|
||||||
|
|
@ -57,7 +57,7 @@ export async function isCommissioner(
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const db = database();
|
const db = database();
|
||||||
const [isAdmin, commissioner] = await Promise.all([
|
const [isAdmin, commissioner] = await Promise.all([
|
||||||
isUserAdminByClerkId(userId),
|
isUserAdmin(userId),
|
||||||
db.query.commissioners.findFirst({
|
db.query.commissioners.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(schema.commissioners.leagueId, leagueId),
|
eq(schema.commissioners.leagueId, leagueId),
|
||||||
|
|
|
||||||
|
|
@ -1446,18 +1446,18 @@ export async function recalculateAffectedLeagues(
|
||||||
const webhookUrl = season?.league?.discordWebhookUrl;
|
const webhookUrl = season?.league?.discordWebhookUrl;
|
||||||
if (!webhookUrl || options?.skipDiscord) continue;
|
if (!webhookUrl || options?.skipDiscord) continue;
|
||||||
|
|
||||||
// Build clerkId → username map for all team owners in this season
|
// Build userId → username map for all team owners in this season
|
||||||
const ownerClerkIds = afterStandings
|
const ownerUserIds = afterStandings
|
||||||
.map((s) => s.team.ownerId)
|
.map((s) => s.team.ownerId)
|
||||||
.filter((id): id is string => id !== null && id !== undefined);
|
.filter((id): id is string => id !== null && id !== undefined);
|
||||||
const teamOwnerUsers = ownerClerkIds.length
|
const teamOwnerUsers = ownerUserIds.length
|
||||||
? await db.query.users.findMany({
|
? await db.query.users.findMany({
|
||||||
where: inArray(schema.users.clerkId, ownerClerkIds),
|
where: inArray(schema.users.id, ownerUserIds),
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const usernameByClerkId = new Map(
|
const usernameByUserId = new Map(
|
||||||
teamOwnerUsers
|
teamOwnerUsers
|
||||||
.map((u) => [u.clerkId, getUserDisplayName(u)])
|
.map((u) => [u.id, getUserDisplayName(u)])
|
||||||
.filter((entry): entry is [string, string] => entry[1] !== null)
|
.filter((entry): entry is [string, string] => entry[1] !== null)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1497,7 +1497,7 @@ export async function recalculateAffectedLeagues(
|
||||||
if (!teamId) return undefined;
|
if (!teamId) return undefined;
|
||||||
const ownerId = ownerIdByTeamId.get(teamId);
|
const ownerId = ownerIdByTeamId.get(teamId);
|
||||||
if (!ownerId) return undefined;
|
if (!ownerId) return undefined;
|
||||||
return usernameByClerkId.get(ownerId);
|
return usernameByUserId.get(ownerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
scoredMatches = relevant
|
scoredMatches = relevant
|
||||||
|
|
@ -1527,7 +1527,7 @@ export async function recalculateAffectedLeagues(
|
||||||
const standings = afterStandings.map((s) => ({
|
const standings = afterStandings.map((s) => ({
|
||||||
teamId: s.teamId,
|
teamId: s.teamId,
|
||||||
teamName: s.team.name,
|
teamName: s.team.name,
|
||||||
username: usernameByClerkId.get(s.team.ownerId ?? ""),
|
username: usernameByUserId.get(s.team.ownerId ?? ""),
|
||||||
totalPoints: parseFloat(s.totalPoints),
|
totalPoints: parseFloat(s.totalPoints),
|
||||||
rank: s.currentRank,
|
rank: s.currentRank,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,6 @@ import * as schema from "~/database/schema";
|
||||||
export type User = typeof schema.users.$inferSelect;
|
export type User = typeof schema.users.$inferSelect;
|
||||||
export type NewUser = typeof schema.users.$inferInsert;
|
export type NewUser = typeof schema.users.$inferInsert;
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the best available display identifier for a user.
|
|
||||||
* Prefers Clerk username; falls back to displayName (always set from
|
|
||||||
* firstName+lastName or email prefix). Returns null only if both are absent.
|
|
||||||
*/
|
|
||||||
export function getUserDisplayName(
|
export function getUserDisplayName(
|
||||||
user: Pick<User, "username" | "displayName">
|
user: Pick<User, "username" | "displayName">
|
||||||
): string | null {
|
): string | null {
|
||||||
|
|
@ -29,22 +24,13 @@ export async function findUserById(id: string): Promise<User | undefined> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findUserByClerkId(
|
export async function findUsersByIds(ids: string[]): Promise<User[]> {
|
||||||
clerkId: string
|
if (ids.length === 0) return [];
|
||||||
): Promise<User | undefined> {
|
|
||||||
const db = database();
|
|
||||||
return await db.query.users.findFirst({
|
|
||||||
where: eq(schema.users.clerkId, clerkId),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findUsersByClerkIds(clerkIds: string[]): Promise<User[]> {
|
|
||||||
if (clerkIds.length === 0) return [];
|
|
||||||
const db = database();
|
const db = database();
|
||||||
return await db
|
return await db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.users)
|
.from(schema.users)
|
||||||
.where(inArray(schema.users.clerkId, clerkIds));
|
.where(inArray(schema.users.id, ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUser(
|
export async function updateUser(
|
||||||
|
|
@ -60,71 +46,6 @@ export async function updateUser(
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUserByClerkId(
|
|
||||||
clerkId: string,
|
|
||||||
data: Partial<NewUser>
|
|
||||||
): Promise<User> {
|
|
||||||
const db = database();
|
|
||||||
const [user] = await db
|
|
||||||
.update(schema.users)
|
|
||||||
.set({ ...data, updatedAt: new Date() })
|
|
||||||
.where(eq(schema.users.clerkId, clerkId))
|
|
||||||
.returning();
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findOrCreateUser(clerkUser: {
|
|
||||||
id: string;
|
|
||||||
emailAddresses?: Array<{ emailAddress: string }>;
|
|
||||||
username?: string | null;
|
|
||||||
firstName?: string | null;
|
|
||||||
lastName?: string | null;
|
|
||||||
imageUrl?: string;
|
|
||||||
}): Promise<User> {
|
|
||||||
// Try to find existing user
|
|
||||||
const existingUser = await findUserByClerkId(clerkUser.id);
|
|
||||||
|
|
||||||
const email = clerkUser.emailAddresses?.[0]?.emailAddress;
|
|
||||||
if (!email) {
|
|
||||||
throw new Error("User must have an email address");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate display name: firstName lastName, or email username
|
|
||||||
let displayName = "";
|
|
||||||
if (clerkUser.firstName || clerkUser.lastName) {
|
|
||||||
displayName = [clerkUser.firstName, clerkUser.lastName]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
.trim();
|
|
||||||
} else {
|
|
||||||
// Use email username as fallback
|
|
||||||
displayName = email.split("@")[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingUser) {
|
|
||||||
// Update user info in case it changed in Clerk
|
|
||||||
return await updateUserByClerkId(clerkUser.id, {
|
|
||||||
email,
|
|
||||||
username: clerkUser.username || undefined,
|
|
||||||
displayName,
|
|
||||||
firstName: clerkUser.firstName || undefined,
|
|
||||||
lastName: clerkUser.lastName || undefined,
|
|
||||||
imageUrl: clerkUser.imageUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new user
|
|
||||||
return await createUser({
|
|
||||||
clerkId: clerkUser.id,
|
|
||||||
email,
|
|
||||||
username: clerkUser.username || undefined,
|
|
||||||
displayName,
|
|
||||||
firstName: clerkUser.firstName || undefined,
|
|
||||||
lastName: clerkUser.lastName || undefined,
|
|
||||||
imageUrl: clerkUser.imageUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteUser(id: string): Promise<void> {
|
export async function deleteUser(id: string): Promise<void> {
|
||||||
const db = database();
|
const db = database();
|
||||||
await db.delete(schema.users).where(eq(schema.users.id, id));
|
await db.delete(schema.users).where(eq(schema.users.id, id));
|
||||||
|
|
@ -143,14 +64,6 @@ export async function isUserAdmin(userId: string): Promise<boolean> {
|
||||||
return user?.isAdmin ?? false;
|
return user?.isAdmin ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isUserAdminByClerkId(clerkId: string): Promise<boolean> {
|
|
||||||
if (process.env.NODE_ENV === "development" && process.env.DEV_ADMIN_CLERK_ID === clerkId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const user = await findUserByClerkId(clerkId);
|
|
||||||
return user?.isAdmin ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<User> {
|
export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<User> {
|
||||||
return await updateUser(userId, { isAdmin });
|
return await updateUser(userId, { isAdmin });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
app/root.tsx
24
app/root.tsx
|
|
@ -10,31 +10,19 @@ import {
|
||||||
|
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
import { clerkMiddleware, rootAuthLoader, getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { Navbar } from "~/components/navbar";
|
import { Navbar } from "~/components/navbar";
|
||||||
import { NavigationProgress } from "~/components/NavigationProgress";
|
import { NavigationProgress } from "~/components/NavigationProgress";
|
||||||
import { ClerkProvider } from "@clerk/react-router";
|
|
||||||
import { dark } from "@clerk/themes";
|
|
||||||
import { Toaster } from "~/components/ui/sonner";
|
import { Toaster } from "~/components/ui/sonner";
|
||||||
import { BracktGradients } from "~/components/ui/BracktGradients";
|
import { BracktGradients } from "~/components/ui/BracktGradients";
|
||||||
import { Footer } from "~/components/marketing/Footer";
|
import { Footer } from "~/components/marketing/Footer";
|
||||||
import { isUserAdminByClerkId } from "~/models/user";
|
|
||||||
import { AlertCircle, FileQuestion, Lock, ShieldOff, ServerCrash } from "lucide-react";
|
import { AlertCircle, FileQuestion, Lock, ShieldOff, ServerCrash } from "lucide-react";
|
||||||
import logoUrl from "../public/logo.svg?url";
|
import logoUrl from "../public/logo.svg?url";
|
||||||
|
|
||||||
export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()];
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
const isAdmin = session?.user.isAdmin ?? false;
|
||||||
return rootAuthLoader(args, async () => {
|
|
||||||
const { userId } = await getAuth(args);
|
|
||||||
|
|
||||||
let isAdmin = false;
|
|
||||||
if (userId) {
|
|
||||||
isAdmin = await isUserAdminByClerkId(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isAdmin };
|
return { isAdmin };
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
|
|
@ -79,7 +67,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
|
||||||
const isDraftRoute = location.pathname.includes('/draft');
|
const isDraftRoute = location.pathname.includes('/draft');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClerkProvider loaderData={loaderData} appearance={{ baseTheme: dark }}>
|
<>
|
||||||
<NavigationProgress />
|
<NavigationProgress />
|
||||||
{!isDraftRoute && <Navbar isAdmin={loaderData?.isAdmin ?? false} />}
|
{!isDraftRoute && <Navbar isAdmin={loaderData?.isAdmin ?? false} />}
|
||||||
{isDraftRoute ? (
|
{isDraftRoute ? (
|
||||||
|
|
@ -93,7 +81,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ClerkProvider>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export default [
|
||||||
"routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.tsx"
|
"routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.tsx"
|
||||||
),
|
),
|
||||||
route("teams/:teamId/settings", "routes/teams/$teamId.settings.tsx"),
|
route("teams/:teamId/settings", "routes/teams/$teamId.settings.tsx"),
|
||||||
route("api/webhooks/clerk", "routes/api/webhooks/clerk.ts"),
|
route("api/auth/*", "routes/api.auth.$.ts"),
|
||||||
route("api/queue/add", "routes/api/queue.add.ts"),
|
route("api/queue/add", "routes/api/queue.add.ts"),
|
||||||
route("api/queue/remove", "routes/api/queue.remove.ts"),
|
route("api/queue/remove", "routes/api/queue.remove.ts"),
|
||||||
route("api/queue/clear", "routes/api/queue.clear.ts"),
|
route("api/queue/clear", "routes/api/queue.clear.ts"),
|
||||||
|
|
@ -48,6 +48,8 @@ export default [
|
||||||
route("api/draft/adjust-time-bank", "routes/api/draft.adjust-time-bank.ts"),
|
route("api/draft/adjust-time-bank", "routes/api/draft.adjust-time-bank.ts"),
|
||||||
route("api/autodraft/update", "routes/api/autodraft.update.ts"),
|
route("api/autodraft/update", "routes/api/autodraft.update.ts"),
|
||||||
route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
|
route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
|
||||||
|
route("login", "routes/login.tsx"),
|
||||||
|
route("register", "routes/register.tsx"),
|
||||||
route("user-profile", "routes/user-profile.tsx"),
|
route("user-profile", "routes/user-profile.tsx"),
|
||||||
route("how-to-play", "routes/how-to-play.tsx"),
|
route("how-to-play", "routes/how-to-play.tsx"),
|
||||||
route("rules", "routes/rules.tsx"),
|
route("rules", "routes/rules.tsx"),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { Form, Link, redirect } from "react-router";
|
import { Form, Link, redirect } from "react-router";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import type { Route } from "./+types/admin.sports-seasons.$id.clone";
|
import type { Route } from "./+types/admin.sports-seasons.$id.clone";
|
||||||
|
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { findSportsSeasonById, cloneSportsSeason, shiftDateByYears, type NewSportsSeason } from "~/models/sports-season";
|
import { findSportsSeasonById, cloneSportsSeason, shiftDateByYears, type NewSportsSeason } from "~/models/sports-season";
|
||||||
import { isUserAdminByClerkId } from "~/models/user";
|
import { isUserAdmin } from "~/models/user";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
|
|
@ -57,8 +57,9 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
|
||||||
export async function action(args: Route.ActionArgs) {
|
export async function action(args: Route.ActionArgs) {
|
||||||
const { request, params } = args;
|
const { request, params } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
const isAdmin = userId ? await isUserAdminByClerkId(userId) : false;
|
const userId = session?.user.id ?? null;
|
||||||
|
const isAdmin = userId ? await isUserAdmin(userId) : false;
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
throw new Response("Forbidden", { status: 403 });
|
throw new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Form, Link, redirect, useNavigate, useNavigation } from "react-router";
|
import { Form, Link, redirect, useNavigate, useNavigation } from "react-router";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { isUserAdminByClerkId } from "~/models/user";
|
import { isUserAdmin } from "~/models/user";
|
||||||
import type { Route } from "./+types/admin.sports-seasons.$id";
|
import type { Route } from "./+types/admin.sports-seasons.$id";
|
||||||
|
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
|
@ -100,8 +100,9 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
|
||||||
export async function action(args: Route.ActionArgs) {
|
export async function action(args: Route.ActionArgs) {
|
||||||
const { request, params } = args;
|
const { request, params } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
const isAdmin = userId ? await isUserAdminByClerkId(userId) : false;
|
const userId = session?.user.id ?? null;
|
||||||
|
const isAdmin = userId ? await isUserAdmin(userId) : false;
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
throw new Response("Forbidden", { status: 403 });
|
throw new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Link, Outlet, redirect } from "react-router";
|
import { Link, Outlet, redirect } from "react-router";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import type { Route } from "./+types/admin";
|
import type { Route } from "./+types/admin";
|
||||||
|
|
||||||
import { isUserAdminByClerkId } from "~/models/user";
|
import { isUserAdmin } from "~/models/user";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
|
@ -17,13 +17,14 @@ export function meta(): Route.MetaDescriptors {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw redirect("/");
|
throw redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
throw redirect("/");
|
throw redirect("/");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import type { Route } from "./+types/api.admin.export-sports-data";
|
import type { Route } from "./+types/api.admin.export-sports-data";
|
||||||
import { isUserAdminByClerkId } from "~/models/user";
|
import { isUserAdmin } from "~/models/user";
|
||||||
import { exportSportsDataToJSON } from "~/utils/sports-data-sync.server";
|
import { exportSportsDataToJSON } from "~/utils/sports-data-sync.server";
|
||||||
|
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Response("Unauthorized", { status: 401 });
|
throw new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
throw new Response("Forbidden", { status: 403 });
|
throw new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
app/routes/api.auth.$.ts
Normal file
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", () => ({
|
vi.mock("~/server/socket", () => ({
|
||||||
getSocketIO: vi.fn(),
|
getSocketIO: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@clerk/react-router/server", () => ({
|
vi.mock("~/lib/auth.server", () => ({
|
||||||
getAuth: vi.fn(),
|
auth: { api: { getSession: vi.fn() } },
|
||||||
}));
|
}));
|
||||||
vi.mock("~/models/draft-pick", () => ({
|
vi.mock("~/models/draft-pick", () => ({
|
||||||
getDraftPicksWithSports: vi.fn(),
|
getDraftPicksWithSports: vi.fn(),
|
||||||
|
|
@ -29,7 +29,7 @@ vi.mock("~/models/draft-utils", () => ({
|
||||||
calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }),
|
calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/models/user", () => ({
|
vi.mock("~/models/user", () => ({
|
||||||
isUserAdminByClerkId: vi.fn(),
|
isUserAdmin: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/models/audit-log", () => ({
|
vi.mock("~/models/audit-log", () => ({
|
||||||
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|
@ -119,12 +119,12 @@ describe("draft.force-manual-pick action", () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Auth: default to authenticated commissioner
|
// Auth: default to authenticated commissioner
|
||||||
const { getAuth } = await import("@clerk/react-router/server");
|
const { auth } = await import("~/lib/auth.server");
|
||||||
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
|
||||||
|
|
||||||
// User model: default to non-admin
|
// User model: default to non-admin
|
||||||
const { isUserAdminByClerkId } = await import("~/models/user");
|
const { isUserAdmin } = await import("~/models/user");
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||||
|
|
||||||
// Socket
|
// Socket
|
||||||
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
|
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
|
||||||
|
|
@ -195,8 +195,8 @@ describe("draft.force-manual-pick action", () => {
|
||||||
|
|
||||||
describe("authorization", () => {
|
describe("authorization", () => {
|
||||||
it("returns 401 when user is not authenticated", async () => {
|
it("returns 401 when user is not authenticated", async () => {
|
||||||
const { getAuth } = await import("@clerk/react-router/server");
|
const { auth } = await import("~/lib/auth.server");
|
||||||
vi.mocked(getAuth).mockResolvedValue({ userId: null } as any);
|
vi.mocked(auth.api.getSession).mockResolvedValue(null as any);
|
||||||
|
|
||||||
const response = await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
const response = await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ vi.mock("~/database/context");
|
||||||
vi.mock("~/server/socket", () => ({
|
vi.mock("~/server/socket", () => ({
|
||||||
getSocketIO: vi.fn(),
|
getSocketIO: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@clerk/react-router/server", () => ({
|
vi.mock("~/lib/auth.server", () => ({
|
||||||
getAuth: vi.fn(),
|
auth: { api: { getSession: vi.fn() } },
|
||||||
}));
|
}));
|
||||||
vi.mock("~/models/draft-pick", () => ({
|
vi.mock("~/models/draft-pick", () => ({
|
||||||
getDraftPicksWithSports: vi.fn(),
|
getDraftPicksWithSports: vi.fn(),
|
||||||
|
|
@ -35,7 +35,7 @@ vi.mock("~/models/draft-utils", () => ({
|
||||||
calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }),
|
calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/models/user", () => ({
|
vi.mock("~/models/user", () => ({
|
||||||
isUserAdminByClerkId: vi.fn(),
|
isUserAdmin: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/models/audit-log", () => ({
|
vi.mock("~/models/audit-log", () => ({
|
||||||
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|
@ -126,11 +126,11 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Default: authenticated as commissioner
|
// Default: authenticated as commissioner
|
||||||
const { getAuth } = await import("@clerk/react-router/server");
|
const { auth } = await import("~/lib/auth.server");
|
||||||
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
|
||||||
|
|
||||||
const { isUserAdminByClerkId } = await import("~/models/user");
|
const { isUserAdmin } = await import("~/models/user");
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||||
|
|
||||||
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
|
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
|
||||||
const socketModule = await import("~/server/socket");
|
const socketModule = await import("~/server/socket");
|
||||||
|
|
@ -236,11 +236,11 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
|
||||||
|
|
||||||
describe("admin force pick", () => {
|
describe("admin force pick", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { getAuth } = await import("@clerk/react-router/server");
|
const { auth } = await import("~/lib/auth.server");
|
||||||
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
|
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: ADMIN_ID } } as any);
|
||||||
|
|
||||||
const { isUserAdminByClerkId } = await import("~/models/user");
|
const { isUserAdmin } = await import("~/models/user");
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||||
|
|
||||||
// Admin is not a commissioner
|
// Admin is not a commissioner
|
||||||
mockDb.query.commissioners.findFirst.mockResolvedValue(null);
|
mockDb.query.commissioners.findFirst.mockResolvedValue(null);
|
||||||
|
|
@ -316,11 +316,11 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
|
||||||
|
|
||||||
describe("admin force pick", () => {
|
describe("admin force pick", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { getAuth } = await import("@clerk/react-router/server");
|
const { auth } = await import("~/lib/auth.server");
|
||||||
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
|
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: ADMIN_ID } } as any);
|
||||||
|
|
||||||
const { isUserAdminByClerkId } = await import("~/models/user");
|
const { isUserAdmin } = await import("~/models/user");
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||||
|
|
||||||
mockDb.query.commissioners.findFirst.mockResolvedValue(null);
|
mockDb.query.commissioners.findFirst.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ vi.mock("~/database/context");
|
||||||
vi.mock("~/server/socket", () => ({
|
vi.mock("~/server/socket", () => ({
|
||||||
getSocketIO: vi.fn(),
|
getSocketIO: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@clerk/react-router/server", () => ({
|
vi.mock("~/lib/auth.server", () => ({
|
||||||
getAuth: vi.fn(),
|
auth: { api: { getSession: vi.fn() } },
|
||||||
}));
|
}));
|
||||||
vi.mock("~/models/draft-pick", () => ({
|
vi.mock("~/models/draft-pick", () => ({
|
||||||
getDraftPicksWithSports: vi.fn(),
|
getDraftPicksWithSports: vi.fn(),
|
||||||
|
|
@ -36,7 +36,7 @@ vi.mock("~/models/draft-utils", () => ({
|
||||||
pruneIneligibleQueueItems: vi.fn().mockResolvedValue([]),
|
pruneIneligibleQueueItems: vi.fn().mockResolvedValue([]),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/models/user", () => ({
|
vi.mock("~/models/user", () => ({
|
||||||
isUserAdminByClerkId: vi.fn(),
|
isUserAdmin: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -123,11 +123,11 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
const { getAuth } = await import("@clerk/react-router/server");
|
const { auth } = await import("~/lib/auth.server");
|
||||||
vi.mocked(getAuth).mockResolvedValue({ userId: OWNER_ID } as any);
|
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: OWNER_ID } } as any);
|
||||||
|
|
||||||
const { isUserAdminByClerkId } = await import("~/models/user");
|
const { isUserAdmin } = await import("~/models/user");
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||||
|
|
||||||
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
|
mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() };
|
||||||
const socketModule = await import("~/server/socket");
|
const socketModule = await import("~/server/socket");
|
||||||
|
|
@ -231,8 +231,8 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
|
|
||||||
describe("commissioner pick", () => {
|
describe("commissioner pick", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { getAuth } = await import("@clerk/react-router/server");
|
const { auth } = await import("~/lib/auth.server");
|
||||||
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
|
||||||
|
|
||||||
mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID });
|
mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID });
|
||||||
// Commissioner does not own the team on the clock
|
// Commissioner does not own the team on the clock
|
||||||
|
|
@ -273,11 +273,11 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
|
|
||||||
describe("admin pick", () => {
|
describe("admin pick", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { getAuth } = await import("@clerk/react-router/server");
|
const { auth } = await import("~/lib/auth.server");
|
||||||
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
|
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: ADMIN_ID } } as any);
|
||||||
|
|
||||||
const { isUserAdminByClerkId } = await import("~/models/user");
|
const { isUserAdmin } = await import("~/models/user");
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||||
|
|
||||||
// Admin does not own the team on the clock
|
// Admin does not own the team on the clock
|
||||||
mockDb.query.draftSlots.findMany.mockResolvedValue([
|
mockDb.query.draftSlots.findMany.mockResolvedValue([
|
||||||
|
|
@ -354,8 +354,8 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("commissioner pick — resets bank to exactly draftIncrementTime", async () => {
|
it("commissioner pick — resets bank to exactly draftIncrementTime", async () => {
|
||||||
const { getAuth } = await import("@clerk/react-router/server");
|
const { auth } = await import("~/lib/auth.server");
|
||||||
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
|
||||||
mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID });
|
mockDb.query.commissioners.findFirst.mockResolvedValue({ id: "c-1", userId: COMMISSIONER_ID });
|
||||||
mockDb.query.draftSlots.findMany.mockResolvedValue([
|
mockDb.query.draftSlots.findMany.mockResolvedValue([
|
||||||
{ ...mockDraftSlots[0], team: { ...mockDraftSlots[0].team, ownerId: "someone-else" } },
|
{ ...mockDraftSlots[0], team: { ...mockDraftSlots[0].team, ownerId: "someone-else" } },
|
||||||
|
|
@ -375,10 +375,10 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("admin pick — resets bank to exactly draftIncrementTime", async () => {
|
it("admin pick — resets bank to exactly draftIncrementTime", async () => {
|
||||||
const { getAuth } = await import("@clerk/react-router/server");
|
const { auth } = await import("~/lib/auth.server");
|
||||||
vi.mocked(getAuth).mockResolvedValue({ userId: ADMIN_ID } as any);
|
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: ADMIN_ID } } as any);
|
||||||
const { isUserAdminByClerkId } = await import("~/models/user");
|
const { isUserAdmin } = await import("~/models/user");
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||||
mockDb.query.draftSlots.findMany.mockResolvedValue([
|
mockDb.query.draftSlots.findMany.mockResolvedValue([
|
||||||
{ ...mockDraftSlots[0], team: { ...mockDraftSlots[0].team, ownerId: "someone-else" } },
|
{ ...mockDraftSlots[0], team: { ...mockDraftSlots[0].team, ownerId: "someone-else" } },
|
||||||
mockDraftSlots[1],
|
mockDraftSlots[1],
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ vi.mock("~/database/context");
|
||||||
vi.mock("~/server/socket", () => ({
|
vi.mock("~/server/socket", () => ({
|
||||||
getSocketIO: vi.fn(),
|
getSocketIO: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@clerk/react-router/server", () => ({
|
vi.mock("~/lib/auth.server", () => ({
|
||||||
getAuth: vi.fn(),
|
auth: { api: { getSession: vi.fn() } },
|
||||||
}));
|
}));
|
||||||
vi.mock("~/models/commissioner", () => ({
|
vi.mock("~/models/commissioner", () => ({
|
||||||
isCommissioner: vi.fn(),
|
isCommissioner: vi.fn(),
|
||||||
|
|
@ -62,8 +62,8 @@ describe("draft.start action — timer initialization", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
const { getAuth } = await import("@clerk/react-router/server");
|
const { auth } = await import("~/lib/auth.server");
|
||||||
vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any);
|
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: COMMISSIONER_ID } } as any);
|
||||||
|
|
||||||
const { isCommissioner } = await import("~/models/commissioner");
|
const { isCommissioner } = await import("~/models/commissioner");
|
||||||
vi.mocked(isCommissioner).mockResolvedValue(true);
|
vi.mocked(isCommissioner).mockResolvedValue(true);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and, asc } from "drizzle-orm";
|
import { eq, and, asc } from "drizzle-orm";
|
||||||
import { getSocketIO } from "~/server/socket";
|
import { getSocketIO } from "~/server/socket";
|
||||||
import { isUserAdminByClerkId } from "~/models/user";
|
import { isUserAdmin } from "~/models/user";
|
||||||
import { getTeamForPick } from "~/lib/draft-order";
|
import { getTeamForPick } from "~/lib/draft-order";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { executeAutoPick } from "~/models/draft-utils";
|
import { executeAutoPick } from "~/models/draft-utils";
|
||||||
|
|
@ -11,7 +11,8 @@ import { executeAutoPick } from "~/models/draft-utils";
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
@ -51,7 +52,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
eq(schema.commissioners.userId, userId)
|
eq(schema.commissioners.userId, userId)
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
isUserAdminByClerkId(userId),
|
isUserAdmin(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!commissioner && !userIsAdmin) {
|
if (!commissioner && !userIsAdmin) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
@ -10,7 +10,8 @@ import { logger } from "~/lib/logger";
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
@ -84,7 +85,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId,
|
seasonId,
|
||||||
leagueId: season.leagueId,
|
leagueId: season.leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "time_bank_edited",
|
action: "time_bank_edited",
|
||||||
affectedTeamIds: [teamId],
|
affectedTeamIds: [teamId],
|
||||||
details: {
|
details: {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
@ -9,7 +9,8 @@ import { logCommissionerAction } from "~/models/audit-log";
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
@ -61,7 +62,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId,
|
seasonId,
|
||||||
leagueId: season.leagueId,
|
leagueId: season.leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "force_autopick",
|
action: "force_autopick",
|
||||||
affectedTeamIds: [teamId],
|
affectedTeamIds: [teamId],
|
||||||
details: {
|
details: {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import { isUserAdminByClerkId } from "~/models/user";
|
import { isUserAdmin } from "~/models/user";
|
||||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||||
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
||||||
import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
||||||
|
|
@ -15,7 +15,8 @@ import type { ActionFunctionArgs } from "react-router";
|
||||||
|
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
@ -44,7 +45,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
|
|
||||||
// Check if user is commissioner or site admin; capture both to set pickedByType accurately
|
// Check if user is commissioner or site admin; capture both to set pickedByType accurately
|
||||||
const [isAdmin, commissionerRecord] = await Promise.all([
|
const [isAdmin, commissionerRecord] = await Promise.all([
|
||||||
isUserAdminByClerkId(userId),
|
isUserAdmin(userId),
|
||||||
db.query.commissioners.findFirst({
|
db.query.commissioners.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(schema.commissioners.leagueId, season.leagueId),
|
eq(schema.commissioners.leagueId, season.leagueId),
|
||||||
|
|
@ -263,7 +264,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId,
|
seasonId,
|
||||||
leagueId: season.leagueId,
|
leagueId: season.leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "force_manual_pick",
|
action: "force_manual_pick",
|
||||||
affectedTeamIds: [teamId],
|
affectedTeamIds: [teamId],
|
||||||
details: {
|
details: {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import { isUserAdminByClerkId } from "~/models/user";
|
import { isUserAdmin } from "~/models/user";
|
||||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||||
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
||||||
import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
||||||
|
|
@ -14,7 +14,8 @@ import { logger } from "~/lib/logger";
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
@ -69,7 +70,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
// Capture both results to set pickedByType accurately in the audit record
|
// Capture both results to set pickedByType accurately in the audit record
|
||||||
const isTeamOwner = currentDraftSlot.team.ownerId === userId;
|
const isTeamOwner = currentDraftSlot.team.ownerId === userId;
|
||||||
const [isAdmin, commissionerRecord] = await Promise.all([
|
const [isAdmin, commissionerRecord] = await Promise.all([
|
||||||
isUserAdminByClerkId(userId),
|
isUserAdmin(userId),
|
||||||
db.query.commissioners.findFirst({
|
db.query.commissioners.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(schema.commissioners.leagueId, season.leagueId),
|
eq(schema.commissioners.leagueId, season.leagueId),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
@ -10,7 +10,8 @@ import { logger } from "~/lib/logger";
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
@ -59,7 +60,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId,
|
seasonId,
|
||||||
leagueId: season.leagueId,
|
leagueId: season.leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "draft_paused",
|
action: "draft_paused",
|
||||||
details: { pickNumber: season.currentPickNumber },
|
details: { pickNumber: season.currentPickNumber },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
@ -14,7 +14,8 @@ import { logger } from "~/lib/logger";
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
@ -152,7 +153,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId,
|
seasonId,
|
||||||
leagueId: season.leagueId,
|
leagueId: season.leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "draft_pick_changed",
|
action: "draft_pick_changed",
|
||||||
affectedTeamIds: [teamId],
|
affectedTeamIds: [teamId],
|
||||||
details: {
|
details: {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
@ -10,7 +10,8 @@ import { logger } from "~/lib/logger";
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
@ -59,7 +60,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId,
|
seasonId,
|
||||||
leagueId: season.leagueId,
|
leagueId: season.leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "draft_resumed",
|
action: "draft_resumed",
|
||||||
details: { pickNumber: season.currentPickNumber },
|
details: { pickNumber: season.currentPickNumber },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and, gte } from "drizzle-orm";
|
import { eq, and, gte } from "drizzle-orm";
|
||||||
|
|
@ -10,7 +10,8 @@ import { logger } from "~/lib/logger";
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
@ -87,7 +88,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId,
|
seasonId,
|
||||||
leagueId: season.leagueId,
|
leagueId: season.leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "draft_rollback",
|
action: "draft_rollback",
|
||||||
affectedTeamIds: rollbackSlot?.teamId ? [rollbackSlot.teamId] : [],
|
affectedTeamIds: rollbackSlot?.teamId ? [rollbackSlot.teamId] : [],
|
||||||
details: {
|
details: {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
@ -11,7 +11,8 @@ import { logger } from "~/lib/logger";
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
@ -81,7 +82,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId,
|
seasonId,
|
||||||
leagueId: season.leagueId,
|
leagueId: season.leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "draft_started",
|
action: "draft_started",
|
||||||
details: { pickNumber: 1 },
|
details: { pickNumber: 1 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
@ -8,7 +8,8 @@ import { getSocketIO } from "../../../server/socket";
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
@ -8,7 +8,8 @@ import type { ActionFunctionArgs } from "react-router";
|
||||||
|
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
@ -8,7 +8,8 @@ import { getSocketIO } from "../../../server/socket";
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
@ -8,7 +8,8 @@ import { getSocketIO } from "../../../server/socket";
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
|
||||||
|
|
@ -67,24 +67,24 @@ export async function loader({ params }: LoaderFunctionArgs) {
|
||||||
const clockOwnerId = onTheClockSlot?.team.ownerId ?? null;
|
const clockOwnerId = onTheClockSlot?.team.ownerId ?? null;
|
||||||
const allOwnerIds = [...new Set([...picksOwnerIds, ...(clockOwnerId ? [clockOwnerId] : [])])];
|
const allOwnerIds = [...new Set([...picksOwnerIds, ...(clockOwnerId ? [clockOwnerId] : [])])];
|
||||||
|
|
||||||
const usernameByClerkId = new Map<string, string | null>();
|
const usernameByUserId = new Map<string, string | null>();
|
||||||
if (allOwnerIds.length > 0) {
|
if (allOwnerIds.length > 0) {
|
||||||
const owners = await db
|
const owners = await db
|
||||||
.select({
|
.select({
|
||||||
clerkId: schema.users.clerkId,
|
id: schema.users.id,
|
||||||
username: schema.users.username,
|
username: schema.users.username,
|
||||||
displayName: schema.users.displayName,
|
displayName: schema.users.displayName,
|
||||||
})
|
})
|
||||||
.from(schema.users)
|
.from(schema.users)
|
||||||
.where(inArray(schema.users.clerkId, allOwnerIds));
|
.where(inArray(schema.users.id, allOwnerIds));
|
||||||
for (const owner of owners) {
|
for (const owner of owners) {
|
||||||
usernameByClerkId.set(owner.clerkId, getUserDisplayName(owner));
|
usernameByUserId.set(owner.id, getUserDisplayName(owner));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix #3: single helper so onTheClock and picks use identical null-fallback logic
|
// Fix #3: single helper so onTheClock and picks use identical null-fallback logic
|
||||||
const getUsername = (ownerId: string | null) =>
|
const getUsername = (ownerId: string | null) =>
|
||||||
ownerId ? (usernameByClerkId.get(ownerId) ?? null) : null;
|
ownerId ? (usernameByUserId.get(ownerId) ?? null) : null;
|
||||||
|
|
||||||
const onTheClock = onTheClockSlot
|
const onTheClock = onTheClockSlot
|
||||||
? { teamName: onTheClockSlot.team.name, username: getUsername(onTheClockSlot.team.ownerId) }
|
? { teamName: onTheClockSlot.team.name, username: getUsername(onTheClockSlot.team.ownerId) }
|
||||||
|
|
|
||||||
|
|
@ -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 { useEffect } from "react";
|
||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { addDays, subDays } from "date-fns";
|
import { addDays, subDays } from "date-fns";
|
||||||
|
|
||||||
import type { Route } from "./+types/home";
|
import type { Route } from "./+types/home";
|
||||||
|
|
@ -34,7 +34,8 @@ export function meta() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return { leagues: [], isLoggedIn: false, upcomingCalendarEvents: [] };
|
return { leagues: [], isLoggedIn: false, upcomingCalendarEvents: [] };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Form, redirect } from "react-router";
|
import { Form, redirect, Link } from "react-router";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { SignInButton } from "@clerk/react-router";
|
|
||||||
import type { Route } from "./+types/i.$inviteCode";
|
import type { Route } from "./+types/i.$inviteCode";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,7 +9,7 @@ import {
|
||||||
findAvailableTeams,
|
findAvailableTeams,
|
||||||
claimTeam,
|
claimTeam,
|
||||||
isUserLeagueMember,
|
isUserLeagueMember,
|
||||||
findUserByClerkId,
|
findUserById,
|
||||||
getUserDisplayName,
|
getUserDisplayName,
|
||||||
} from "~/models";
|
} from "~/models";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
@ -29,7 +28,8 @@ export function meta(): Route.MetaDescriptors {
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
const { params } = args;
|
const { params } = args;
|
||||||
const { inviteCode } = params;
|
const { inviteCode } = params;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
// Find season by invite code
|
// Find season by invite code
|
||||||
const season = await findSeasonByInviteCode(inviteCode);
|
const season = await findSeasonByInviteCode(inviteCode);
|
||||||
|
|
@ -68,7 +68,8 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
export async function action(args: Route.ActionArgs) {
|
export async function action(args: Route.ActionArgs) {
|
||||||
const { params } = args;
|
const { params } = args;
|
||||||
const { inviteCode } = params;
|
const { inviteCode } = params;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Response("You must be logged in to join a league", { status: 401 });
|
throw new Response("You must be logged in to join a league", { status: 401 });
|
||||||
|
|
@ -97,7 +98,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up user before claiming to fail fast and get their name
|
// Look up user before claiming to fail fast and get their name
|
||||||
const user = await findUserByClerkId(userId);
|
const user = await findUserById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Response("User account not found. Please try again.", { status: 500 });
|
throw new Response("User account not found. Please try again.", { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
@ -162,9 +163,9 @@ export default function InvitePage({ loaderData }: Route.ComponentProps) {
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
You'll need to sign in or create an account to join this league.
|
You'll need to sign in or create an account to join this league.
|
||||||
</p>
|
</p>
|
||||||
<SignInButton mode="modal" forceRedirectUrl={`/i/${loaderData.inviteCode}`}>
|
<Link to={`/login?redirectTo=/i/${loaderData.inviteCode}`}>
|
||||||
<Button className="w-full">Sign In to Join</Button>
|
<Button className="w-full">Sign In to Join</Button>
|
||||||
</SignInButton>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Link, Form, useSearchParams } from "react-router";
|
import { Link, Form, useSearchParams } from "react-router";
|
||||||
import type { Route } from "./+types/$leagueId.audit-log";
|
import type { Route } from "./+types/$leagueId.audit-log";
|
||||||
|
|
@ -42,7 +42,8 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
const { params, request } = args;
|
const { params, request } = args;
|
||||||
const { leagueId } = params;
|
const { leagueId } = params;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { useLoaderData, Link, useRevalidator } from "react-router";
|
import { useLoaderData, Link, useRevalidator } from "react-router";
|
||||||
import { useAuth } from "@clerk/react-router";
|
|
||||||
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { useDraftRoomState, type QueueItem } from "~/hooks/useDraftRoomState";
|
import { useDraftRoomState, type QueueItem } from "~/hooks/useDraftRoomState";
|
||||||
import { useDraftAuthRecovery } from "~/hooks/useDraftAuthRecovery";
|
import { useDraftAuthRecovery } from "~/hooks/useDraftAuthRecovery";
|
||||||
import { useDraftSocketEvents } from "~/hooks/useDraftSocketEvents";
|
import { useDraftSocketEvents } from "~/hooks/useDraftSocketEvents";
|
||||||
|
|
@ -60,7 +59,8 @@ const QUEUE_TAB = { id: "queue" as const, label: "Queue", Icon: ListOrdered };
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
const { params } = args;
|
const { params } = args;
|
||||||
const { seasonId, leagueId } = params;
|
const { seasonId, leagueId } = params;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!seasonId) {
|
if (!seasonId) {
|
||||||
throw new Response("Season ID is required", { status: 400 });
|
throw new Response("Season ID is required", { status: 400 });
|
||||||
|
|
@ -147,7 +147,6 @@ export default function DraftRoom() {
|
||||||
ownerMap,
|
ownerMap,
|
||||||
} = useLoaderData<typeof loader>();
|
} = useLoaderData<typeof loader>();
|
||||||
const { revalidate, state: revalidatorState } = useRevalidator();
|
const { revalidate, state: revalidatorState } = useRevalidator();
|
||||||
const { userId: clerkUserId, getToken } = useAuth();
|
|
||||||
const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id);
|
const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id);
|
||||||
const {
|
const {
|
||||||
permissionState: notificationsPermission,
|
permissionState: notificationsPermission,
|
||||||
|
|
@ -228,9 +227,7 @@ export default function DraftRoom() {
|
||||||
reconnectCount,
|
reconnectCount,
|
||||||
revalidate,
|
revalidate,
|
||||||
revalidatorState,
|
revalidatorState,
|
||||||
clerkUserId,
|
|
||||||
currentUserId,
|
currentUserId,
|
||||||
getToken,
|
|
||||||
userAutodraftSettings,
|
userAutodraftSettings,
|
||||||
draftPicks,
|
draftPicks,
|
||||||
season,
|
season,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { addDays, subDays } from "date-fns";
|
import { addDays, subDays } from "date-fns";
|
||||||
import { toEventSortKey } from "~/lib/date-utils";
|
import { toEventSortKey } from "~/lib/date-utils";
|
||||||
import {
|
import {
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
findCommissionersByLeagueId,
|
findCommissionersByLeagueId,
|
||||||
isUserLeagueMember,
|
isUserLeagueMember,
|
||||||
isCommissioner,
|
isCommissioner,
|
||||||
findUsersByClerkIds,
|
findUsersByIds,
|
||||||
getUserDisplayName,
|
getUserDisplayName,
|
||||||
findDraftSlotsBySeasonId,
|
findDraftSlotsBySeasonId,
|
||||||
} from "~/models";
|
} from "~/models";
|
||||||
|
|
@ -20,7 +20,8 @@ import { getAuditLogForSeason } from "~/models/audit-log";
|
||||||
import type { Route } from "./+types/$leagueId";
|
import type { Route } from "./+types/$leagueId";
|
||||||
|
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
const { params } = args;
|
const { params } = args;
|
||||||
const { leagueId } = params;
|
const { leagueId } = params;
|
||||||
|
|
||||||
|
|
@ -81,19 +82,19 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
|
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
|
||||||
const commissionerIds = commissioners.map((c) => c.userId);
|
const commissionerIds = commissioners.map((c) => c.userId);
|
||||||
const allUserIds = [...new Set([...ownerIds, ...commissionerIds])];
|
const allUserIds = [...new Set([...ownerIds, ...commissionerIds])];
|
||||||
const userRows = await findUsersByClerkIds(allUserIds);
|
const userRows = await findUsersByIds(allUserIds);
|
||||||
const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u]));
|
const userById = new Map(userRows.map((u) => [u.id, u]));
|
||||||
|
|
||||||
const ownerMap = new Map(
|
const ownerMap = new Map(
|
||||||
ownerIds
|
ownerIds
|
||||||
.map((id) => [id, userByClerkId.get(id)] as const)
|
.map((id) => [id, userById.get(id)] as const)
|
||||||
.filter((entry): entry is [string, NonNullable<typeof entry[1]>] => entry[1] !== undefined)
|
.filter((entry): entry is [string, NonNullable<typeof entry[1]>] => entry[1] !== undefined)
|
||||||
.map(([id, user]) => [id, getUserDisplayName(user)])
|
.map(([id, user]) => [id, getUserDisplayName(user)])
|
||||||
);
|
);
|
||||||
|
|
||||||
const commissionerMap = new Map(
|
const commissionerMap = new Map(
|
||||||
commissionerIds
|
commissionerIds
|
||||||
.map((id) => [id, userByClerkId.get(id)] as const)
|
.map((id) => [id, userById.get(id)] as const)
|
||||||
.filter((entry): entry is [string, NonNullable<typeof entry[1]>] => entry[1] !== undefined)
|
.filter((entry): entry is [string, NonNullable<typeof entry[1]>] => entry[1] !== undefined)
|
||||||
.map(([id, user]) => [id, getUserDisplayName(user)])
|
.map(([id, user]) => [id, getUserDisplayName(user)])
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Form, Link, redirect, useNavigation } from "react-router";
|
import { Form, Link, redirect, useNavigation } from "react-router";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { CalendarIcon } from "lucide-react";
|
import { CalendarIcon } from "lucide-react";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
} from "~/models/commissioner";
|
} from "~/models/commissioner";
|
||||||
import { findCurrentSeasonWithSports, updateSeason, type NewSeason } from "~/models/season";
|
import { findCurrentSeasonWithSports, updateSeason, type NewSeason } from "~/models/season";
|
||||||
import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam } from "~/models/team";
|
import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam } from "~/models/team";
|
||||||
import { findAllUsers, findUserByClerkId, findUsersByClerkIds, getUserDisplayName, isUserAdminByClerkId } from "~/models/user";
|
import { findAllUsers, findUsersByIds, findUserById, getUserDisplayName, isUserAdmin } from "~/models/user";
|
||||||
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
|
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
|
||||||
import { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season";
|
import { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season";
|
||||||
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
|
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
|
||||||
|
|
@ -73,7 +73,8 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
const { params } = args;
|
const { params } = args;
|
||||||
const { leagueId } = params;
|
const { leagueId } = params;
|
||||||
|
|
||||||
|
|
@ -118,7 +119,7 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
const teamsWithOwners = teams.filter(team => team.ownerId !== null).length;
|
const teamsWithOwners = teams.filter(team => team.ownerId !== null).length;
|
||||||
|
|
||||||
// Check if user is admin
|
// Check if user is admin
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
|
||||||
// Get all users (only for admins - used in team assignment dropdown)
|
// Get all users (only for admins - used in team assignment dropdown)
|
||||||
const allUsers = isAdmin ? await findAllUsers() : [];
|
const allUsers = isAdmin ? await findAllUsers() : [];
|
||||||
|
|
@ -130,11 +131,11 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
const uniqueOwnerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
|
const uniqueOwnerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
|
||||||
const commissionerUserIds = commissioners.map((c) => c.userId);
|
const commissionerUserIds = commissioners.map((c) => c.userId);
|
||||||
const allUserIds = [...new Set([...commissionerUserIds, ...uniqueOwnerIds])];
|
const allUserIds = [...new Set([...commissionerUserIds, ...uniqueOwnerIds])];
|
||||||
const userRows = await findUsersByClerkIds(allUserIds);
|
const userRows = await findUsersByIds(allUserIds);
|
||||||
const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u]));
|
const userById = new Map(userRows.map((u) => [u.id, u]));
|
||||||
|
|
||||||
const commissionerUserData = commissioners.map((c) => {
|
const commissionerUserData = commissioners.map((c) => {
|
||||||
const user = userByClerkId.get(c.userId);
|
const user = userById.get(c.userId);
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
userName: user ? (getUserDisplayName(user) ?? "Unknown User") : "Unknown User",
|
userName: user ? (getUserDisplayName(user) ?? "Unknown User") : "Unknown User",
|
||||||
|
|
@ -143,16 +144,15 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
|
|
||||||
const validOwners = uniqueOwnerIds
|
const validOwners = uniqueOwnerIds
|
||||||
.map((ownerId) => {
|
.map((ownerId) => {
|
||||||
const user = userByClerkId.get(ownerId);
|
const user = userById.get(ownerId);
|
||||||
return user ? { clerkId: ownerId, name: getUserDisplayName(user), id: user.id } : null;
|
return user ? { id: user.id, name: getUserDisplayName(user) } : null;
|
||||||
})
|
})
|
||||||
.filter((o): o is NonNullable<typeof o> => o !== null);
|
.filter((o): o is NonNullable<typeof o> => o !== null);
|
||||||
const ownerMap = new Map(validOwners.map((o) => [o.clerkId, o.name]));
|
const ownerMap = new Map(validOwners.map((o) => [o.id, o.name]));
|
||||||
|
|
||||||
// League members (team owners) - available to all commissioners for adding co-commissioners
|
// League members (team owners) - available to all commissioners for adding co-commissioners
|
||||||
const leagueMembers = validOwners.map((o) => ({
|
const leagueMembers = validOwners.map((o) => ({
|
||||||
id: o.id,
|
id: o.id,
|
||||||
clerkId: o.clerkId,
|
|
||||||
name: o.name,
|
name: o.name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -174,7 +174,8 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action(args: Route.ActionArgs) {
|
export async function action(args: Route.ActionArgs) {
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
const { params, request } = args;
|
const { params, request } = args;
|
||||||
const { leagueId } = params;
|
const { leagueId } = params;
|
||||||
|
|
||||||
|
|
@ -236,7 +237,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId: currentSeason.id,
|
seasonId: currentSeason.id,
|
||||||
leagueId,
|
leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "league_settings_changed",
|
action: "league_settings_changed",
|
||||||
details: {
|
details: {
|
||||||
changedFields,
|
changedFields,
|
||||||
|
|
@ -369,7 +370,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId: season.id,
|
seasonId: season.id,
|
||||||
leagueId,
|
leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "draft_order_set",
|
action: "draft_order_set",
|
||||||
affectedTeamIds: teamIds,
|
affectedTeamIds: teamIds,
|
||||||
details: {
|
details: {
|
||||||
|
|
@ -401,7 +402,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId: season.id,
|
seasonId: season.id,
|
||||||
leagueId,
|
leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "draft_order_randomized",
|
action: "draft_order_randomized",
|
||||||
affectedTeamIds: sortedSlots.map((s) => s.teamId),
|
affectedTeamIds: sortedSlots.map((s) => s.teamId),
|
||||||
details: {
|
details: {
|
||||||
|
|
@ -434,27 +435,27 @@ export async function action(args: Route.ActionArgs) {
|
||||||
|
|
||||||
if (intent === "assign-team-owner") {
|
if (intent === "assign-team-owner") {
|
||||||
const teamId = formData.get("teamId") as string;
|
const teamId = formData.get("teamId") as string;
|
||||||
const userClerkId = formData.get("userClerkId") as string;
|
const assignedUserId = formData.get("userId") as string;
|
||||||
|
|
||||||
if (!teamId || !userClerkId) {
|
if (!teamId || !assignedUserId) {
|
||||||
return { error: "Team ID and User ID are required" };
|
return { error: "Team ID and User ID are required" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is admin (only admins can assign owners)
|
// Check if user is admin (only admins can assign owners)
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return { error: "Only admins can assign team owners" };
|
return { error: "Only admins can assign team owners" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up user before assigning to fail fast and get their name
|
// Look up user before assigning to fail fast and get their name
|
||||||
const assignedUser = await findUserByClerkId(userClerkId);
|
const assignedUser = await findUserById(assignedUserId);
|
||||||
if (!assignedUser) {
|
if (!assignedUser) {
|
||||||
return { error: "User not found. Please try again." };
|
return { error: "User not found. Please try again." };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is already assigned to a team in this season
|
// Check if user is already assigned to a team in this season
|
||||||
const teams = await findTeamsBySeasonId(season.id);
|
const teams = await findTeamsBySeasonId(season.id);
|
||||||
const userAlreadyHasTeam = teams.some(team => team.ownerId === userClerkId);
|
const userAlreadyHasTeam = teams.some(team => team.ownerId === assignedUserId);
|
||||||
|
|
||||||
if (userAlreadyHasTeam) {
|
if (userAlreadyHasTeam) {
|
||||||
return { error: "This user is already assigned to a team in this league" };
|
return { error: "This user is already assigned to a team in this league" };
|
||||||
|
|
@ -468,7 +469,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const teamName = `Team ${getUserDisplayName(assignedUser) ?? "Member"}`;
|
const teamName = `Team ${getUserDisplayName(assignedUser) ?? "Member"}`;
|
||||||
await claimTeam(teamId, userClerkId, teamName);
|
await claimTeam(teamId, assignedUserId, teamName);
|
||||||
|
|
||||||
return { success: true, message: "Owner assigned successfully" };
|
return { success: true, message: "Owner assigned successfully" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -479,7 +480,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
|
|
||||||
if (intent === "reset-draft") {
|
if (intent === "reset-draft") {
|
||||||
// Check if user is admin (only admins can reset draft)
|
// Check if user is admin (only admins can reset draft)
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return { error: "Only admins can reset the draft" };
|
return { error: "Only admins can reset the draft" };
|
||||||
}
|
}
|
||||||
|
|
@ -507,7 +508,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId: season.id,
|
seasonId: season.id,
|
||||||
leagueId,
|
leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "draft_reset",
|
action: "draft_reset",
|
||||||
details: { previousPickNumber },
|
details: { previousPickNumber },
|
||||||
});
|
});
|
||||||
|
|
@ -625,7 +626,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId: season.id,
|
seasonId: season.id,
|
||||||
leagueId,
|
leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "draft_settings_changed",
|
action: "draft_settings_changed",
|
||||||
details: {
|
details: {
|
||||||
changedFields: draftFields,
|
changedFields: draftFields,
|
||||||
|
|
@ -639,7 +640,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId: season.id,
|
seasonId: season.id,
|
||||||
leagueId,
|
leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "scoring_rules_changed",
|
action: "scoring_rules_changed",
|
||||||
details: {
|
details: {
|
||||||
changedFields: scoringChangedFields,
|
changedFields: scoringChangedFields,
|
||||||
|
|
@ -690,7 +691,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
await logCommissionerAction({
|
await logCommissionerAction({
|
||||||
seasonId: season.id,
|
seasonId: season.id,
|
||||||
leagueId,
|
leagueId,
|
||||||
actorClerkId: userId,
|
actorUserId: userId,
|
||||||
action: "sports_changed",
|
action: "sports_changed",
|
||||||
details: {
|
details: {
|
||||||
added: addedNames,
|
added: addedNames,
|
||||||
|
|
@ -769,21 +770,21 @@ export async function action(args: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === "add-commissioner") {
|
if (intent === "add-commissioner") {
|
||||||
const userClerkId = formData.get("userClerkId") as string;
|
const newCommissionerUserId = formData.get("userId") as string;
|
||||||
|
|
||||||
if (!userClerkId) {
|
if (!newCommissionerUserId) {
|
||||||
return { error: "User is required" };
|
return { error: "User is required" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user already has a commissioner record (don't use isCommissioner — it
|
// Check if user already has a commissioner record (don't use isCommissioner — it
|
||||||
// returns true for site admins, causing a false "already a commissioner" error)
|
// returns true for site admins, causing a false "already a commissioner" error)
|
||||||
const alreadyCommissioner = await hasCommissionerRecord(leagueId, userClerkId);
|
const alreadyCommissioner = await hasCommissionerRecord(leagueId, newCommissionerUserId);
|
||||||
if (alreadyCommissioner) {
|
if (alreadyCommissioner) {
|
||||||
return { error: "This user is already a commissioner" };
|
return { error: "This user is already a commissioner" };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createCommissioner({ leagueId, userId: userClerkId });
|
await createCommissioner({ leagueId, userId: newCommissionerUserId });
|
||||||
return { success: true, message: "Commissioner added successfully" };
|
return { success: true, message: "Commissioner added successfully" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error adding commissioner:", error);
|
logger.error("Error adding commissioner:", error);
|
||||||
|
|
@ -1484,17 +1485,17 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
||||||
<Form method="post" className="flex gap-2">
|
<Form method="post" className="flex gap-2">
|
||||||
<input type="hidden" name="intent" value="assign-team-owner" />
|
<input type="hidden" name="intent" value="assign-team-owner" />
|
||||||
<input type="hidden" name="teamId" value={team.id} />
|
<input type="hidden" name="teamId" value={team.id} />
|
||||||
<Select name="userClerkId" required>
|
<Select name="userId" required>
|
||||||
<SelectTrigger className="w-[180px] h-9">
|
<SelectTrigger className="w-[180px] h-9">
|
||||||
<SelectValue placeholder="Select user" />
|
<SelectValue placeholder="Select user" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{allUsers.map((user) => {
|
{allUsers.map((user) => {
|
||||||
const userOwnsTeam = teams.some((t) => t.ownerId === user.clerkId);
|
const userOwnsTeam = teams.some((t) => t.ownerId === user.id);
|
||||||
return (
|
return (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={user.id}
|
key={user.id}
|
||||||
value={user.clerkId}
|
value={user.id}
|
||||||
disabled={userOwnsTeam}
|
disabled={userOwnsTeam}
|
||||||
>
|
>
|
||||||
{user.username || user.email}
|
{user.username || user.email}
|
||||||
|
|
@ -1581,19 +1582,19 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
||||||
<p className="text-sm font-medium mb-3">Add Commissioner</p>
|
<p className="text-sm font-medium mb-3">Add Commissioner</p>
|
||||||
<Form method="post" className="flex gap-2">
|
<Form method="post" className="flex gap-2">
|
||||||
<input type="hidden" name="intent" value="add-commissioner" />
|
<input type="hidden" name="intent" value="add-commissioner" />
|
||||||
<Select name="userClerkId" required>
|
<Select name="userId" required>
|
||||||
<SelectTrigger className="flex-1">
|
<SelectTrigger className="flex-1">
|
||||||
<SelectValue placeholder="Select a user" />
|
<SelectValue placeholder="Select a user" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{leagueMembers.map((member) => {
|
{leagueMembers.map((member) => {
|
||||||
const alreadyCommissioner = commissioners.some(
|
const alreadyCommissioner = commissioners.some(
|
||||||
(c) => c.userId === member.clerkId
|
(c) => c.userId === member.id
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={member.id}
|
key={member.id}
|
||||||
value={member.clerkId}
|
value={member.id}
|
||||||
disabled={alreadyCommissioner}
|
disabled={alreadyCommissioner}
|
||||||
>
|
>
|
||||||
{member.name || "Unknown"}
|
{member.name || "Unknown"}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
||||||
import {
|
import {
|
||||||
findLeagueById,
|
findLeagueById,
|
||||||
|
|
@ -29,7 +29,8 @@ import * as schema from "~/database/schema";
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
const { params } = args;
|
const { params } = args;
|
||||||
const { leagueId, sportsSeasonId } = params;
|
const { leagueId, sportsSeasonId } = params;
|
||||||
|
|
||||||
|
|
@ -102,12 +103,12 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter(Boolean))] as string[];
|
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter(Boolean))] as string[];
|
||||||
const ownerUserRows = ownerIds.length > 0
|
const ownerUserRows = ownerIds.length > 0
|
||||||
? await db.query.users.findMany({
|
? await db.query.users.findMany({
|
||||||
where: inArray(schema.users.clerkId, ownerIds),
|
where: inArray(schema.users.id, ownerIds),
|
||||||
columns: { clerkId: true, username: true, displayName: true },
|
columns: { id: true, username: true, displayName: true },
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const ownerMap = new Map(
|
const ownerMap = new Map(
|
||||||
ownerUserRows.map((u) => [u.clerkId, getUserDisplayName(u) ?? "Unknown"])
|
ownerUserRows.map((u) => [u.id, getUserDisplayName(u) ?? "Unknown"])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Map draft picks to ownership
|
// Map draft picks to ownership
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/
|
||||||
import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
|
import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
|
||||||
import { getRecentTeamScoreEvents } from "~/models/team-score-events";
|
import { getRecentTeamScoreEvents } from "~/models/team-score-events";
|
||||||
import { PointProgressionChart } from "~/components/standings/PointProgressionChart";
|
import { PointProgressionChart } from "~/components/standings/PointProgressionChart";
|
||||||
import { findUsersByClerkIds, getUserDisplayName } from "~/models/user";
|
import { findUsersByIds, getUserDisplayName } from "~/models/user";
|
||||||
import { StandingsPreview } from "~/components/league/StandingsPreview";
|
import { StandingsPreview } from "~/components/league/StandingsPreview";
|
||||||
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
||||||
import { RecentScoresCard } from "~/components/standings/RecentScoresCard";
|
import { RecentScoresCard } from "~/components/standings/RecentScoresCard";
|
||||||
|
|
@ -69,13 +69,13 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
|
|
||||||
// Build teamId -> ownerName map
|
// Build teamId -> ownerName map
|
||||||
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
|
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
|
||||||
const userRows = await findUsersByClerkIds(ownerIds);
|
const userRows = await findUsersByIds(ownerIds);
|
||||||
const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u]));
|
const userById = new Map(userRows.map((u) => [u.id, u]));
|
||||||
const ownerNameByTeamId = new Map(
|
const ownerNameByTeamId = new Map(
|
||||||
teams
|
teams
|
||||||
.filter((t): t is typeof t & { ownerId: string } => t.ownerId !== null)
|
.filter((t): t is typeof t & { ownerId: string } => t.ownerId !== null)
|
||||||
.map((t) => {
|
.map((t) => {
|
||||||
const user = userByClerkId.get(t.ownerId);
|
const user = userById.get(t.ownerId);
|
||||||
return [t.id, user ? getUserDisplayName(user) : null] as const;
|
return [t.id, user ? getUserDisplayName(user) : null] as const;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { addDays, subDays } from "date-fns";
|
import { addDays, subDays } from "date-fns";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
@ -23,7 +23,8 @@ export function meta({ data }: Route.MetaArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
const { leagueId } = args.params;
|
const { leagueId } = args.params;
|
||||||
|
|
||||||
const league = await findLeagueById(leagueId);
|
const league = await findLeagueById(leagueId);
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ vi.mock("~/database/context");
|
||||||
vi.mock("~/server/socket", () => ({
|
vi.mock("~/server/socket", () => ({
|
||||||
getSocketIO: vi.fn(),
|
getSocketIO: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@clerk/react-router/server", () => ({
|
vi.mock("~/lib/auth.server", () => ({
|
||||||
getAuth: vi.fn(),
|
auth: { api: { getSession: vi.fn() } },
|
||||||
}));
|
}));
|
||||||
vi.mock("~/models/user", () => ({
|
vi.mock("~/models/user", () => ({
|
||||||
isUserAdminByClerkId: vi.fn().mockResolvedValue(false),
|
isUserAdmin: vi.fn().mockResolvedValue(false),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Autodraft Settings API", () => {
|
describe("Autodraft Settings API", () => {
|
||||||
|
|
@ -23,9 +23,9 @@ describe("Autodraft Settings API", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Mock Clerk auth
|
// Mock auth session
|
||||||
const { getAuth } = await import("@clerk/react-router/server");
|
const { auth } = await import("~/lib/auth.server");
|
||||||
vi.mocked(getAuth).mockResolvedValue({ userId: "user-789" } as any);
|
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: "user-789" } } as any);
|
||||||
|
|
||||||
// Mock Socket.IO
|
// Mock Socket.IO
|
||||||
mockSocketIO = {
|
mockSocketIO = {
|
||||||
|
|
@ -389,8 +389,8 @@ describe("Autodraft Settings API", () => {
|
||||||
const teamId = "team-xyz";
|
const teamId = "team-xyz";
|
||||||
const userId = "user-123";
|
const userId = "user-123";
|
||||||
|
|
||||||
const { getAuth } = await import("@clerk/react-router/server");
|
const { auth } = await import("~/lib/auth.server");
|
||||||
vi.mocked(getAuth).mockResolvedValue({ userId } as any);
|
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: userId } } as any);
|
||||||
|
|
||||||
mockDb.query.teams.findFirst.mockResolvedValue({
|
mockDb.query.teams.findFirst.mockResolvedValue({
|
||||||
id: teamId,
|
id: teamId,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { deleteAllDraftPicks } from '~/models/draft-pick';
|
||||||
import { clearAllQueuesForSeason } from '~/models/draft-queue';
|
import { clearAllQueuesForSeason } from '~/models/draft-queue';
|
||||||
import { deleteSeasonTimers } from '~/models/draft-timer';
|
import { deleteSeasonTimers } from '~/models/draft-timer';
|
||||||
import { updateSeason } from '~/models/season';
|
import { updateSeason } from '~/models/season';
|
||||||
import { isUserAdminByClerkId } from '~/models/user';
|
import { isUserAdmin } from '~/models/user';
|
||||||
|
|
||||||
// Helper function to create mock season objects with all required properties
|
// Helper function to create mock season objects with all required properties
|
||||||
const createMockSeason = (overrides: {
|
const createMockSeason = (overrides: {
|
||||||
|
|
@ -58,7 +58,7 @@ vi.mock('~/models/season', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('~/models/user', () => ({
|
vi.mock('~/models/user', () => ({
|
||||||
isUserAdminByClerkId: vi.fn(),
|
isUserAdmin: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('Draft Reset - Authorization', () => {
|
describe('Draft Reset - Authorization', () => {
|
||||||
|
|
@ -69,20 +69,20 @@ describe('Draft Reset - Authorization', () => {
|
||||||
it('should only allow admins to reset draft', async () => {
|
it('should only allow admins to reset draft', async () => {
|
||||||
const userId = 'admin-user-id';
|
const userId = 'admin-user-id';
|
||||||
|
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||||
|
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
|
||||||
expect(isAdmin).toBe(true);
|
expect(isAdmin).toBe(true);
|
||||||
expect(isUserAdminByClerkId).toHaveBeenCalledWith(userId);
|
expect(isUserAdmin).toHaveBeenCalledWith(userId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject non-admin users from resetting draft', async () => {
|
it('should reject non-admin users from resetting draft', async () => {
|
||||||
const userId = 'regular-user-id';
|
const userId = 'regular-user-id';
|
||||||
|
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||||
|
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
|
||||||
expect(isAdmin).toBe(false);
|
expect(isAdmin).toBe(false);
|
||||||
|
|
||||||
|
|
@ -98,9 +98,9 @@ describe('Draft Reset - Authorization', () => {
|
||||||
const commissionerUserId = 'commissioner-user-id';
|
const commissionerUserId = 'commissioner-user-id';
|
||||||
|
|
||||||
// Commissioner but not admin
|
// Commissioner but not admin
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||||
|
|
||||||
const isAdmin = await isUserAdminByClerkId(commissionerUserId);
|
const isAdmin = await isUserAdmin(commissionerUserId);
|
||||||
|
|
||||||
expect(isAdmin).toBe(false);
|
expect(isAdmin).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
@ -243,8 +243,8 @@ describe('Draft Reset - Complete Workflow', () => {
|
||||||
const userId = 'admin-user-id';
|
const userId = 'admin-user-id';
|
||||||
|
|
||||||
// Step 1: Verify admin
|
// Step 1: Verify admin
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
expect(isAdmin).toBe(true);
|
expect(isAdmin).toBe(true);
|
||||||
|
|
||||||
// Step 2: Delete draft picks
|
// Step 2: Delete draft picks
|
||||||
|
|
@ -285,8 +285,8 @@ describe('Draft Reset - Complete Workflow', () => {
|
||||||
it('should not proceed with reset if admin check fails', async () => {
|
it('should not proceed with reset if admin check fails', async () => {
|
||||||
const userId = 'regular-user-id';
|
const userId = 'regular-user-id';
|
||||||
|
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
|
||||||
expect(isAdmin).toBe(false);
|
expect(isAdmin).toBe(false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { removeTeamOwner, assignTeamOwner } from '~/models/team';
|
import { removeTeamOwner, assignTeamOwner } from '~/models/team';
|
||||||
import { findAllUsers, isUserAdminByClerkId } from '~/models/user';
|
import { findAllUsers, isUserAdmin } from '~/models/user';
|
||||||
|
|
||||||
// Helper function to create mock team objects with all required properties
|
// Helper function to create mock team objects with all required properties
|
||||||
const createMockTeam = (overrides: {
|
const createMockTeam = (overrides: {
|
||||||
|
|
@ -31,8 +31,8 @@ vi.mock('~/models/team', () => ({
|
||||||
|
|
||||||
vi.mock('~/models/user', () => ({
|
vi.mock('~/models/user', () => ({
|
||||||
findAllUsers: vi.fn(),
|
findAllUsers: vi.fn(),
|
||||||
findUserByClerkId: vi.fn(),
|
findUserById: vi.fn(),
|
||||||
isUserAdminByClerkId: vi.fn(),
|
isUserAdmin: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('~/models/league', () => ({
|
vi.mock('~/models/league', () => ({
|
||||||
|
|
@ -138,21 +138,21 @@ describe('Team Management - Assign Owner', () => {
|
||||||
const userId = 'admin-user-id';
|
const userId = 'admin-user-id';
|
||||||
|
|
||||||
// Mock admin check
|
// Mock admin check
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||||
|
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
|
||||||
expect(isAdmin).toBe(true);
|
expect(isAdmin).toBe(true);
|
||||||
expect(isUserAdminByClerkId).toHaveBeenCalledWith(userId);
|
expect(isUserAdmin).toHaveBeenCalledWith(userId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should prevent non-admins from assigning owners', async () => {
|
it('should prevent non-admins from assigning owners', async () => {
|
||||||
const userId = 'regular-user-id';
|
const userId = 'regular-user-id';
|
||||||
|
|
||||||
// Mock non-admin check
|
// Mock non-admin check
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||||
|
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
|
||||||
expect(isAdmin).toBe(false);
|
expect(isAdmin).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
@ -182,8 +182,9 @@ describe('Team Management - Admin User List', () => {
|
||||||
const mockUsers = [
|
const mockUsers = [
|
||||||
{
|
{
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
clerkId: 'clerk-1',
|
clerkId: null,
|
||||||
email: 'user1@example.com',
|
email: 'user1@example.com',
|
||||||
|
emailVerified: true,
|
||||||
username: 'user1',
|
username: 'user1',
|
||||||
displayName: 'User One',
|
displayName: 'User One',
|
||||||
firstName: 'User',
|
firstName: 'User',
|
||||||
|
|
@ -195,8 +196,9 @@ describe('Team Management - Admin User List', () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'user-2',
|
id: 'user-2',
|
||||||
clerkId: 'clerk-2',
|
clerkId: null,
|
||||||
email: 'user2@example.com',
|
email: 'user2@example.com',
|
||||||
|
emailVerified: true,
|
||||||
username: 'user2',
|
username: 'user2',
|
||||||
displayName: 'User Two',
|
displayName: 'User Two',
|
||||||
firstName: 'User',
|
firstName: 'User',
|
||||||
|
|
@ -221,16 +223,17 @@ describe('Team Management - Admin User List', () => {
|
||||||
it('should only fetch users when user is admin', async () => {
|
it('should only fetch users when user is admin', async () => {
|
||||||
const userId = 'admin-user-id';
|
const userId = 'admin-user-id';
|
||||||
|
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||||
|
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
const mockUsers = [
|
const mockUsers = [
|
||||||
{
|
{
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
clerkId: 'clerk-1',
|
clerkId: null,
|
||||||
email: 'user1@example.com',
|
email: 'user1@example.com',
|
||||||
|
emailVerified: true,
|
||||||
username: 'user1',
|
username: 'user1',
|
||||||
displayName: 'User One',
|
displayName: 'User One',
|
||||||
firstName: 'User',
|
firstName: 'User',
|
||||||
|
|
@ -254,9 +257,9 @@ describe('Team Management - Admin User List', () => {
|
||||||
it('should return empty array for non-admin users', async () => {
|
it('should return empty array for non-admin users', async () => {
|
||||||
const userId = 'regular-user-id';
|
const userId = 'regular-user-id';
|
||||||
|
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||||
|
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
|
||||||
// Simulate loader behavior: only fetch users if admin
|
// Simulate loader behavior: only fetch users if admin
|
||||||
const users = isAdmin ? await findAllUsers() : [];
|
const users = isAdmin ? await findAllUsers() : [];
|
||||||
|
|
@ -275,20 +278,20 @@ describe('Team Management - Authorization', () => {
|
||||||
it('should verify admin status before allowing assignment', async () => {
|
it('should verify admin status before allowing assignment', async () => {
|
||||||
const userId = 'test-user-id';
|
const userId = 'test-user-id';
|
||||||
|
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(true);
|
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||||
|
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
|
||||||
expect(isAdmin).toBe(true);
|
expect(isAdmin).toBe(true);
|
||||||
expect(isUserAdminByClerkId).toHaveBeenCalledWith(userId);
|
expect(isUserAdmin).toHaveBeenCalledWith(userId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject assignment for non-admin users', async () => {
|
it('should reject assignment for non-admin users', async () => {
|
||||||
const userId = 'regular-user-id';
|
const userId = 'regular-user-id';
|
||||||
|
|
||||||
vi.mocked(isUserAdminByClerkId).mockResolvedValue(false);
|
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||||
|
|
||||||
const isAdmin = await isUserAdminByClerkId(userId);
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
|
||||||
expect(isAdmin).toBe(false);
|
expect(isAdmin).toBe(false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Form, redirect, Link } from "react-router";
|
import { Form, redirect, Link } from "react-router";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import type { Route } from "./+types/new";
|
import type { Route } from "./+types/new";
|
||||||
|
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
|
@ -71,7 +71,8 @@ export async function loader() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action(args: Route.ActionArgs) {
|
export async function action(args: Route.ActionArgs) {
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
|
|
|
||||||
120
app/routes/login.tsx
Normal file
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 { Form, redirect, useNavigate } from "react-router";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import type { Route } from "./+types/$teamId.settings";
|
import type { Route } from "./+types/$teamId.settings";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -37,7 +37,8 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
const { params } = args;
|
const { params } = args;
|
||||||
const { teamId } = params;
|
const { teamId } = params;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Response("You must be logged in", { status: 401 });
|
throw new Response("You must be logged in", { status: 401 });
|
||||||
|
|
@ -66,7 +67,8 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
export async function action(args: Route.ActionArgs) {
|
export async function action(args: Route.ActionArgs) {
|
||||||
const { params, request } = args;
|
const { params, request } = args;
|
||||||
const { teamId } = params;
|
const { teamId } = params;
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Response("You must be logged in", { status: 401 });
|
throw new Response("You must be logged in", { status: 401 });
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { addDays, subDays } from "date-fns";
|
import { addDays, subDays } from "date-fns";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
@ -18,7 +18,8 @@ export function meta() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return { isLoggedIn: false as const, upcomingCalendarEvents: [] };
|
return { isLoggedIn: false as const, upcomingCalendarEvents: [] };
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,106 @@
|
||||||
import { UserProfile } from "@clerk/react-router";
|
import { redirect, Form } from "react-router";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { redirect } from "react-router";
|
import { findUserById, updateUser } from "~/models/user";
|
||||||
import type { Route } from "./+types/user-profile";
|
import type { Route } from "./+types/user-profile";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
|
||||||
export function meta(): Route.MetaDescriptors {
|
export function meta(): Route.MetaDescriptors {
|
||||||
return [{ title: "Profile - Brackt" }];
|
return [{ title: "Profile - Brackt" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
const { userId } = await getAuth(args);
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
if (!session) {
|
||||||
if (!userId) {
|
return redirect("/login?redirectTo=/user-profile");
|
||||||
|
}
|
||||||
|
const user = await findUserById(session.user.id);
|
||||||
|
if (!user) {
|
||||||
return redirect("/");
|
return redirect("/");
|
||||||
}
|
}
|
||||||
|
return { user };
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserProfilePage() {
|
export async function action(args: Route.ActionArgs) {
|
||||||
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
if (!session) {
|
||||||
|
return redirect("/login?redirectTo=/user-profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await args.request.formData();
|
||||||
|
const displayName = formData.get("displayName") as string;
|
||||||
|
const username = formData.get("username") as string | null;
|
||||||
|
const firstName = formData.get("firstName") as string | null;
|
||||||
|
const lastName = formData.get("lastName") as string | null;
|
||||||
|
|
||||||
|
await updateUser(session.user.id, {
|
||||||
|
displayName: displayName || undefined,
|
||||||
|
username: username || undefined,
|
||||||
|
firstName: firstName || undefined,
|
||||||
|
lastName: lastName || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserProfilePage({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
|
const { user } = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 px-4 flex justify-center">
|
<div className="container mx-auto py-8 px-4 max-w-lg">
|
||||||
<UserProfile />
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Your Profile</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{actionData?.success && (
|
||||||
|
<p className="text-sm text-green-500 mb-4">Profile updated successfully.</p>
|
||||||
|
)}
|
||||||
|
<Form method="post" className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="displayName">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
id="displayName"
|
||||||
|
name="displayName"
|
||||||
|
defaultValue={user.displayName ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
defaultValue={user.username ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="firstName">First Name</Label>
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
defaultValue={user.firstName ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lastName">Last Name</Label>
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
defaultValue={user.lastName ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Email</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full">Save Changes</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
app/test/fixtures/user.ts
vendored
4
app/test/fixtures/user.ts
vendored
|
|
@ -2,6 +2,7 @@ export const mockUser = {
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
clerkId: 'clerk-user-1',
|
clerkId: 'clerk-user-1',
|
||||||
email: 'user1@example.com',
|
email: 'user1@example.com',
|
||||||
|
emailVerified: true,
|
||||||
username: 'user1',
|
username: 'user1',
|
||||||
displayName: 'User One',
|
displayName: 'User One',
|
||||||
firstName: 'User',
|
firstName: 'User',
|
||||||
|
|
@ -16,6 +17,7 @@ export const mockAdminUser = {
|
||||||
id: 'admin-1',
|
id: 'admin-1',
|
||||||
clerkId: 'clerk-admin-1',
|
clerkId: 'clerk-admin-1',
|
||||||
email: 'admin@example.com',
|
email: 'admin@example.com',
|
||||||
|
emailVerified: true,
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
displayName: 'Admin User',
|
displayName: 'Admin User',
|
||||||
firstName: 'Admin',
|
firstName: 'Admin',
|
||||||
|
|
@ -32,6 +34,7 @@ export const mockUsers = [
|
||||||
id: 'user-2',
|
id: 'user-2',
|
||||||
clerkId: 'clerk-user-2',
|
clerkId: 'clerk-user-2',
|
||||||
email: 'user2@example.com',
|
email: 'user2@example.com',
|
||||||
|
emailVerified: true,
|
||||||
username: 'user2',
|
username: 'user2',
|
||||||
displayName: 'User Two',
|
displayName: 'User Two',
|
||||||
firstName: 'User',
|
firstName: 'User',
|
||||||
|
|
@ -45,6 +48,7 @@ export const mockUsers = [
|
||||||
id: 'user-3',
|
id: 'user-3',
|
||||||
clerkId: 'clerk-user-3',
|
clerkId: 'clerk-user-3',
|
||||||
email: 'user3@example.com',
|
email: 'user3@example.com',
|
||||||
|
emailVerified: true,
|
||||||
username: 'user3',
|
username: 'user3',
|
||||||
displayName: 'User Three',
|
displayName: 'User Three',
|
||||||
firstName: 'User',
|
firstName: 'User',
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,16 @@ afterEach(() => {
|
||||||
// Mock environment variables
|
// Mock environment variables
|
||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
// Mock Clerk
|
// Mock BetterAuth
|
||||||
vi.mock('@clerk/react-router/server', () => ({
|
vi.mock('~/lib/auth.server', () => ({
|
||||||
getAuth: vi.fn(() => Promise.resolve({ userId: 'test-user-id' })),
|
auth: {
|
||||||
clerkMiddleware: vi.fn(() => (req: unknown, res: unknown, next: () => void) => next()),
|
api: {
|
||||||
rootAuthLoader: vi.fn(() => Promise.resolve({ userId: 'test-user-id' })),
|
getSession: vi.fn(() => Promise.resolve({
|
||||||
|
user: { id: 'test-user-id', email: 'test@example.com', name: 'Test User' },
|
||||||
|
session: { userId: 'test-user-id' },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock database context
|
// Mock database context
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,59 @@
|
||||||
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, index, jsonb } from "drizzle-orm/pg-core";
|
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, index, jsonb } from "drizzle-orm/pg-core";
|
||||||
import { relations, sql } from "drizzle-orm";
|
import { relations, sql } from "drizzle-orm";
|
||||||
|
|
||||||
// Users table - synced from Clerk
|
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable("users", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
clerkId: varchar("clerk_id", { length: 255 }).notNull().unique(),
|
clerkId: varchar("clerk_id", { length: 255 }).unique(), // nullable: new users won't have one
|
||||||
email: varchar("email", { length: 255 }).notNull(),
|
email: varchar("email", { length: 255 }).notNull(),
|
||||||
|
emailVerified: boolean("email_verified").notNull().default(false),
|
||||||
username: varchar("username", { length: 255 }),
|
username: varchar("username", { length: 255 }),
|
||||||
displayName: varchar("display_name", { length: 255 }),
|
displayName: varchar("display_name", { length: 255 }), // mapped as BetterAuth's "name" field
|
||||||
firstName: varchar("first_name", { length: 255 }),
|
firstName: varchar("first_name", { length: 255 }),
|
||||||
lastName: varchar("last_name", { length: 255 }),
|
lastName: varchar("last_name", { length: 255 }),
|
||||||
imageUrl: varchar("image_url", { length: 512 }),
|
imageUrl: varchar("image_url", { length: 512 }), // mapped as BetterAuth's "image" field
|
||||||
isAdmin: boolean("is_admin").notNull().default(false),
|
isAdmin: boolean("is_admin").notNull().default(false),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// BetterAuth session/account/verification tables
|
||||||
|
export const sessions = pgTable("sessions", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
ipAddress: text("ip_address"),
|
||||||
|
userAgent: text("user_agent"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const accounts = pgTable("accounts", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
accountId: text("account_id").notNull(),
|
||||||
|
providerId: text("provider_id").notNull(),
|
||||||
|
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
accessToken: text("access_token"),
|
||||||
|
refreshToken: text("refresh_token"),
|
||||||
|
idToken: text("id_token"),
|
||||||
|
expiresAt: timestamp("expires_at"),
|
||||||
|
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||||
|
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||||
|
scope: text("scope"),
|
||||||
|
password: text("password"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const verifications = pgTable("verifications", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
identifier: text("identifier").notNull(),
|
||||||
|
value: text("value").notNull(),
|
||||||
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
// Fantasy League Tables
|
// Fantasy League Tables
|
||||||
export const seasonStatusEnum = pgEnum("season_status", [
|
export const seasonStatusEnum = pgEnum("season_status", [
|
||||||
"pre_draft",
|
"pre_draft",
|
||||||
|
|
|
||||||
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,
|
"when": 1776318182740,
|
||||||
"tag": "0080_chemical_gorilla_man",
|
"tag": "0080_chemical_gorilla_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 81,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777067050669,
|
||||||
|
"tag": "0081_careless_magdalene",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 82,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777090754037,
|
||||||
|
"tag": "0082_stale_pride",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
535
package-lock.json
generated
535
package-lock.json
generated
|
|
@ -6,8 +6,6 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "brackt.com",
|
"name": "brackt.com",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/react-router": "^2.1.0",
|
|
||||||
"@clerk/themes": "^2.4.55",
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
|
@ -31,6 +29,8 @@
|
||||||
"@sentry/react-router": "^10.43.0",
|
"@sentry/react-router": "^10.43.0",
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"@types/nprogress": "^0.2.3",
|
"@types/nprogress": "^0.2.3",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"better-auth": "^1.6.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"compression": "^1.8.0",
|
"compression": "^1.8.0",
|
||||||
|
|
@ -52,7 +52,6 @@
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"svix": "^1.77.0",
|
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|
@ -67,6 +66,7 @@
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/express-serve-static-core": "^5.0.6",
|
"@types/express-serve-static-core": "^5.0.6",
|
||||||
|
|
@ -671,126 +671,150 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@clerk/backend": {
|
"node_modules/@better-auth/core": {
|
||||||
"version": "2.17.2",
|
"version": "1.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-2.17.2.tgz",
|
"resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.9.tgz",
|
||||||
"integrity": "sha512-zgKySfoOXySYOMEDc+S2vXLchCldwPVb85tyvP1NnmxvgyAm10yCA+xd4No4dNWm+lwkwHuNWX3rM9ro8khSmw==",
|
"integrity": "sha512-ADFk5pwmLybmc+LvYvXJ6M1x2oY/EyYLkwLuH0x28FUq12DfjL0wnE7g+WRDf3yozDO+qIxTpFGXDGwLKbfz0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/shared": "^3.27.3",
|
"@opentelemetry/semantic-conventions": "^1.39.0",
|
||||||
"@clerk/types": "^4.92.0",
|
"@standard-schema/spec": "^1.1.0",
|
||||||
"cookie": "1.0.2",
|
"zod": "^4.3.6"
|
||||||
"standardwebhooks": "^1.0.0",
|
|
||||||
"tslib": "2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.17.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@clerk/backend/node_modules/cookie": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@clerk/clerk-react": {
|
|
||||||
"version": "5.51.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.51.0.tgz",
|
|
||||||
"integrity": "sha512-jBreKiUS4DKm+JUIt59B1699aRr3wQmQ1O+DlWCEY3iEz+F6ETir1SJcRNG5mHVoA9EGn+m93USJSUWZBRG8yQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@clerk/shared": "^3.27.3",
|
|
||||||
"@clerk/types": "^4.92.0",
|
|
||||||
"tslib": "2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.17.0"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-0",
|
"@better-auth/utils": "0.4.0",
|
||||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0"
|
"@better-fetch/fetch": "1.1.21",
|
||||||
}
|
"@cloudflare/workers-types": ">=4",
|
||||||
},
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"node_modules/@clerk/react-router": {
|
"better-call": "1.3.5",
|
||||||
"version": "2.1.0",
|
"jose": "^6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/react-router/-/react-router-2.1.0.tgz",
|
"kysely": "^0.28.5",
|
||||||
"integrity": "sha512-qAfMxiiPmSs7Vv5oj1qXYedyCrGnL4c/susr2P74BW07Fyz13wT1/ZEJ5Y4g+u1GGSPxvbQtv8OWsNIsulclKw==",
|
"nanostores": "^1.0.1"
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@clerk/backend": "^2.17.2",
|
|
||||||
"@clerk/clerk-react": "^5.51.0",
|
|
||||||
"@clerk/shared": "^3.27.3",
|
|
||||||
"@clerk/types": "^4.92.0",
|
|
||||||
"cookie": "0.7.2",
|
|
||||||
"tslib": "2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-0",
|
|
||||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0",
|
|
||||||
"react-router": "^7.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@clerk/shared": {
|
|
||||||
"version": "3.47.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.47.0.tgz",
|
|
||||||
"integrity": "sha512-EDWFysptTc58X96MGQIZ3LlcMFKLG+rhIF9kf6n+wnyQDWnfuyA8I8ge7GbjfUXMf00c//A/CGSjg7t/oupUpw==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"csstype": "3.1.3",
|
|
||||||
"dequal": "2.0.3",
|
|
||||||
"glob-to-regexp": "0.4.1",
|
|
||||||
"js-cookie": "3.0.5",
|
|
||||||
"std-env": "^3.9.0",
|
|
||||||
"swr": "2.3.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.17.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0",
|
|
||||||
"react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0"
|
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"react": {
|
"@cloudflare/workers-types": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"react-dom": {
|
"@opentelemetry/api": {
|
||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@clerk/themes": {
|
"node_modules/@better-auth/drizzle-adapter": {
|
||||||
"version": "2.4.55",
|
"version": "1.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/themes/-/themes-2.4.55.tgz",
|
"resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.9.tgz",
|
||||||
"integrity": "sha512-j9q8NtAaI2f7vNBuO2RAUDmAebab2UoZCXshlTzEhsbB1UH+94fPs4KyUlsbrSNxIJNfTrM2IKxAZKos3gcCJw==",
|
"integrity": "sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"peerDependencies": {
|
||||||
"@clerk/shared": "^3.47.0",
|
"@better-auth/core": "^1.6.9",
|
||||||
"tslib": "2.8.1"
|
"@better-auth/utils": "0.4.0",
|
||||||
|
"drizzle-orm": "^0.45.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"peerDependenciesMeta": {
|
||||||
"node": ">=18.17.0"
|
"drizzle-orm": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@clerk/types": {
|
"node_modules/@better-auth/kysely-adapter": {
|
||||||
"version": "4.92.0",
|
"version": "1.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.92.0.tgz",
|
"resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.9.tgz",
|
||||||
"integrity": "sha512-+bUiHjqVXEHJIOOhshIy3uYDF/c4/yNc2BPfgPTXxxsbz/2wG0XUx0PL+mxUPiruPZOD+D63AtmORuFW3yBa2w==",
|
"integrity": "sha512-gyjuuxJtZ4o9G9z9q4kqn24X2kvMSp7F+KHogYxF03SnXY/2WleAcuj57iC4wP3e9mGDbjPOrnM5K6Kr3Ktdpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@better-auth/core": "^1.6.9",
|
||||||
|
"@better-auth/utils": "0.4.0",
|
||||||
|
"kysely": "^0.28.14"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"kysely": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@better-auth/memory-adapter": {
|
||||||
|
"version": "1.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.9.tgz",
|
||||||
|
"integrity": "sha512-XmIG4tUnOXZ+KEcWjHUjOI9Z5donD09dC2t/AQTXifAUIqx7cySg86w0KTM09ArzAxRx1fCqO36Wkt5nULnrkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@better-auth/core": "^1.6.9",
|
||||||
|
"@better-auth/utils": "0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@better-auth/mongo-adapter": {
|
||||||
|
"version": "1.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.9.tgz",
|
||||||
|
"integrity": "sha512-h+AiRJ/TsBSi+ZDjySASBpbJ/9QCXBre34PSKgCz7QmTHrFM9Cg2EM4AM7LjR5lPXipEE+2rWPBc9wfnUBjhcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@better-auth/core": "^1.6.9",
|
||||||
|
"@better-auth/utils": "0.4.0",
|
||||||
|
"mongodb": "^6.0.0 || ^7.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"mongodb": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@better-auth/prisma-adapter": {
|
||||||
|
"version": "1.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.9.tgz",
|
||||||
|
"integrity": "sha512-XHks01ntK20orqK/jICq8wmEbJ/zT6dct49Fk8zTQKN9QNGDc+Ix5+7z/Kvui0DXGFf790GfvRozquzaLtXa8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@better-auth/core": "^1.6.9",
|
||||||
|
"@better-auth/utils": "0.4.0",
|
||||||
|
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
||||||
|
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@prisma/client": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@better-auth/telemetry": {
|
||||||
|
"version": "1.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.9.tgz",
|
||||||
|
"integrity": "sha512-0u5zkhSCAQFoN3DHvUkLHOF6MBbVTDAa6mU8mhPwiysdz1x21vMzhzfaAKN/ZGWaQ09v91/F+2qu42G/bhUV4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@better-auth/core": "^1.6.9",
|
||||||
|
"@better-auth/utils": "0.4.0",
|
||||||
|
"@better-fetch/fetch": "1.1.21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@better-auth/utils": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "3.1.3"
|
"@noble/hashes": "^2.0.1"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.17.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@better-auth/utils/node_modules/@noble/hashes": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@better-fetch/fetch": {
|
||||||
|
"version": "1.1.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz",
|
||||||
|
"integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="
|
||||||
|
},
|
||||||
"node_modules/@csstools/color-helpers": {
|
"node_modules/@csstools/color-helpers": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||||
|
|
@ -4977,6 +5001,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -4990,6 +5015,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5003,6 +5029,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5016,6 +5043,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5029,6 +5057,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5042,6 +5071,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5055,6 +5085,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5068,6 +5099,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5081,6 +5113,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5094,6 +5127,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5107,6 +5141,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5120,6 +5155,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5133,6 +5169,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5146,6 +5183,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5159,6 +5197,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5172,6 +5211,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5185,6 +5225,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5198,6 +5239,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5211,6 +5253,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5224,6 +5267,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5237,6 +5281,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5250,6 +5295,7 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -5983,9 +6029,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/utils": {
|
"node_modules/@standard-schema/utils": {
|
||||||
|
|
@ -6706,6 +6752,16 @@
|
||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
|
|
@ -6837,6 +6893,7 @@
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/express": {
|
"node_modules/@types/express": {
|
||||||
|
|
@ -6957,7 +7014,7 @@
|
||||||
"version": "19.2.2",
|
"version": "19.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
|
|
@ -6967,7 +7024,7 @@
|
||||||
"version": "19.2.1",
|
"version": "19.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz",
|
||||||
"integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==",
|
"integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
|
|
@ -7772,6 +7829,20 @@
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^8.3.0",
|
||||||
|
"node-gyp-build": "^4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bcrypt-pbkdf": {
|
"node_modules/bcrypt-pbkdf": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||||
|
|
@ -7782,6 +7853,161 @@
|
||||||
"tweetnacl": "^0.14.3"
|
"tweetnacl": "^0.14.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/better-auth": {
|
||||||
|
"version": "1.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.9.tgz",
|
||||||
|
"integrity": "sha512-EBFURtglyiEZxbx4NJBoqUD8J65dX24yC+6I9AUbIXNgUkt76mshzGbHkxZ3n/lB7Dwq3kBC+hHt0hUQsnL7HA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@better-auth/core": "1.6.9",
|
||||||
|
"@better-auth/drizzle-adapter": "1.6.9",
|
||||||
|
"@better-auth/kysely-adapter": "1.6.9",
|
||||||
|
"@better-auth/memory-adapter": "1.6.9",
|
||||||
|
"@better-auth/mongo-adapter": "1.6.9",
|
||||||
|
"@better-auth/prisma-adapter": "1.6.9",
|
||||||
|
"@better-auth/telemetry": "1.6.9",
|
||||||
|
"@better-auth/utils": "0.4.0",
|
||||||
|
"@better-fetch/fetch": "1.1.21",
|
||||||
|
"@noble/ciphers": "^2.1.1",
|
||||||
|
"@noble/hashes": "^2.0.1",
|
||||||
|
"better-call": "1.3.5",
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"jose": "^6.1.3",
|
||||||
|
"kysely": "^0.28.14",
|
||||||
|
"nanostores": "^1.1.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@lynx-js/react": "*",
|
||||||
|
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
||||||
|
"@sveltejs/kit": "^2.0.0",
|
||||||
|
"@tanstack/react-start": "^1.0.0",
|
||||||
|
"@tanstack/solid-start": "^1.0.0",
|
||||||
|
"better-sqlite3": "^12.0.0",
|
||||||
|
"drizzle-kit": ">=0.31.4",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"mongodb": "^6.0.0 || ^7.0.0",
|
||||||
|
"mysql2": "^3.0.0",
|
||||||
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
||||||
|
"pg": "^8.0.0",
|
||||||
|
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0",
|
||||||
|
"solid-js": "^1.0.0",
|
||||||
|
"svelte": "^4.0.0 || ^5.0.0",
|
||||||
|
"vitest": "^2.0.0 || ^3.0.0 || ^4.0.0",
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@lynx-js/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@prisma/client": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@sveltejs/kit": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@tanstack/react-start": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@tanstack/solid-start": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"better-sqlite3": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"drizzle-kit": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"drizzle-orm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mongodb": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mysql2": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pg": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"solid-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"svelte": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vitest": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vue": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/better-auth/node_modules/@noble/ciphers": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/better-auth/node_modules/@noble/hashes": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/better-call": {
|
||||||
|
"version": "1.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.5.tgz",
|
||||||
|
"integrity": "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@better-auth/utils": "^0.4.0",
|
||||||
|
"@better-fetch/fetch": "^1.1.21",
|
||||||
|
"rou3": "^0.7.12",
|
||||||
|
"set-cookie-parser": "^3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/better-call/node_modules/set-cookie-parser": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/bidi-js": {
|
"node_modules/bidi-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
|
@ -8778,6 +9004,7 @@
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cypress": {
|
"node_modules/cypress": {
|
||||||
|
|
@ -9145,6 +9372,12 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/defu": {
|
||||||
|
"version": "6.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||||
|
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/delayed-stream": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
|
@ -9168,6 +9401,7 @@
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
|
|
@ -10834,6 +11068,7 @@
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|
@ -11034,12 +11269,6 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob-to-regexp": {
|
|
||||||
"version": "0.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
|
|
||||||
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
|
|
||||||
"license": "BSD-2-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/global-dirs": {
|
"node_modules/global-dirs": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz",
|
||||||
|
|
@ -11802,21 +12031,11 @@
|
||||||
"version": "6.2.1",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz",
|
||||||
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
|
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/js-cookie": {
|
|
||||||
"version": "3.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
|
||||||
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|
@ -12006,6 +12225,15 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kysely": {
|
||||||
|
"version": "0.28.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.16.tgz",
|
||||||
|
"integrity": "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lazy-ass": {
|
"node_modules/lazy-ass": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz",
|
||||||
|
|
@ -12952,6 +13180,21 @@
|
||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nanostores": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || >=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.4",
|
"version": "0.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||||
|
|
@ -12971,6 +13214,15 @@
|
||||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "8.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
|
||||||
|
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20 || >= 21"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-domexception": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
|
|
@ -13011,6 +13263,17 @@
|
||||||
"url": "https://opencollective.com/node-fetch"
|
"url": "https://opencollective.com/node-fetch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build": {
|
||||||
|
"version": "4.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build": "bin.js",
|
||||||
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
"node-gyp-build-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.23",
|
"version": "2.0.23",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
|
||||||
|
|
@ -14249,6 +14512,7 @@
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
|
|
@ -14645,6 +14909,7 @@
|
||||||
"version": "4.52.4",
|
"version": "4.52.4",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
|
||||||
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
|
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
|
|
@ -14682,6 +14947,12 @@
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rou3": {
|
||||||
|
"version": "0.7.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz",
|
||||||
|
"integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/router": {
|
"node_modules/router": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
|
|
@ -15557,6 +15828,7 @@
|
||||||
"version": "3.9.0",
|
"version": "3.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
|
||||||
"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
|
"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/stdin-discarder": {
|
"node_modules/stdin-discarder": {
|
||||||
|
|
@ -15910,19 +16182,6 @@
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/swr": {
|
|
||||||
"version": "2.3.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.4.tgz",
|
|
||||||
"integrity": "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dequal": "^2.0.3",
|
|
||||||
"use-sync-external-store": "^1.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/symbol-tree": {
|
"node_modules/symbol-tree": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
|
@ -16326,7 +16585,7 @@
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/react-router": "^2.1.0",
|
|
||||||
"@clerk/themes": "^2.4.55",
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
|
@ -53,6 +51,8 @@
|
||||||
"@sentry/react-router": "^10.43.0",
|
"@sentry/react-router": "^10.43.0",
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"@types/nprogress": "^0.2.3",
|
"@types/nprogress": "^0.2.3",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"better-auth": "^1.6.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"compression": "^1.8.0",
|
"compression": "^1.8.0",
|
||||||
|
|
@ -74,7 +74,6 @@
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"svix": "^1.77.0",
|
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|
@ -89,6 +88,7 @@
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/express-serve-static-core": "^5.0.6",
|
"@types/express-serve-static-core": "^5.0.6",
|
||||||
|
|
|
||||||
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Clerk → BetterAuth one-time data migration ────────────────────────────────
|
||||||
|
// Runs on every deploy but is fully idempotent. Skips automatically once
|
||||||
|
// CLERK_SECRET_KEY is removed from the environment.
|
||||||
|
|
||||||
|
const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY;
|
||||||
|
|
||||||
|
if (!CLERK_SECRET_KEY) {
|
||||||
|
console.log("CLERK_SECRET_KEY not set — skipping Clerk account migration");
|
||||||
|
} else {
|
||||||
|
console.log("Running Clerk account migration...");
|
||||||
|
try {
|
||||||
|
await runClerkMigration(client, CLERK_SECRET_KEY);
|
||||||
|
console.log("Clerk account migration complete");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Clerk account migration failed:", err);
|
||||||
|
await client.end().catch(() => {});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await client.end().catch(() => {});
|
await client.end().catch(() => {});
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runClerkMigration(sql, secretKey) {
|
||||||
|
// 1. Convert teams.owner_id from Clerk IDs to users.id UUIDs
|
||||||
|
// Safe to run repeatedly — WHERE clause only matches unconverted rows.
|
||||||
|
const teamsResult = await sql`
|
||||||
|
UPDATE teams t
|
||||||
|
SET owner_id = u.id::text
|
||||||
|
FROM users u
|
||||||
|
WHERE t.owner_id = u.clerk_id
|
||||||
|
AND u.clerk_id IS NOT NULL
|
||||||
|
`;
|
||||||
|
console.log(` teams.owner_id: ${teamsResult.count} rows converted`);
|
||||||
|
|
||||||
|
// 2. Convert commissioners.user_id from Clerk IDs to users.id UUIDs
|
||||||
|
const commissResult = await sql`
|
||||||
|
UPDATE commissioners c
|
||||||
|
SET user_id = u.id::text
|
||||||
|
FROM users u
|
||||||
|
WHERE c.user_id = u.clerk_id
|
||||||
|
AND u.clerk_id IS NOT NULL
|
||||||
|
`;
|
||||||
|
console.log(` commissioners.user_id: ${commissResult.count} rows converted`);
|
||||||
|
|
||||||
|
// 3. Mark all Clerk users as email-verified (they already verified via Clerk)
|
||||||
|
await sql`
|
||||||
|
UPDATE users
|
||||||
|
SET email_verified = true
|
||||||
|
WHERE clerk_id IS NOT NULL
|
||||||
|
AND email_verified = false
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 4. Migrate OAuth accounts from Clerk into BetterAuth's accounts table.
|
||||||
|
// Skip entirely if any OAuth accounts already exist to avoid duplicates.
|
||||||
|
// We check only OAuth providers (not 'credential') so running migrate-clerk-passwords.mjs
|
||||||
|
// first doesn't prevent OAuth accounts from being created.
|
||||||
|
const [{ count: existingCount }] = await sql`
|
||||||
|
SELECT COUNT(*)::int AS count FROM accounts WHERE provider_id IN ('google', 'discord')
|
||||||
|
`;
|
||||||
|
if (existingCount > 0) {
|
||||||
|
console.log(` ${existingCount} OAuth accounts already exist — skipping Clerk account import`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all users with Clerk IDs from our DB
|
||||||
|
const dbUsers = await sql`
|
||||||
|
SELECT id, clerk_id FROM users WHERE clerk_id IS NOT NULL
|
||||||
|
`;
|
||||||
|
if (dbUsers.length === 0) {
|
||||||
|
console.log(" No Clerk users found in DB — nothing to migrate");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(` Found ${dbUsers.length} Clerk users in DB, fetching from Clerk API...`);
|
||||||
|
|
||||||
|
const dbUserByClerkId = new Map(dbUsers.map((u) => [u.clerk_id, u.id]));
|
||||||
|
|
||||||
|
// Fetch all users from Clerk API (paginated)
|
||||||
|
const clerkUsers = [];
|
||||||
|
let offset = 0;
|
||||||
|
const limit = 100;
|
||||||
|
while (true) {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.clerk.com/v1/users?limit=${limit}&offset=${offset}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${secretKey}` } }
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Clerk API error ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
const batch = await res.json();
|
||||||
|
clerkUsers.push(...batch);
|
||||||
|
if (batch.length < limit) break;
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
console.log(` Fetched ${clerkUsers.length} users from Clerk API`);
|
||||||
|
|
||||||
|
let googleCount = 0;
|
||||||
|
let discordCount = 0;
|
||||||
|
let emailPasswordCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
|
||||||
|
for (const clerkUser of clerkUsers) {
|
||||||
|
const userId = dbUserByClerkId.get(clerkUser.id);
|
||||||
|
if (!userId) {
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ext of clerkUser.external_accounts ?? []) {
|
||||||
|
if (ext.provider === "google") {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid()::text,
|
||||||
|
${ext.provider_user_id},
|
||||||
|
'google',
|
||||||
|
${userId}::uuid,
|
||||||
|
now(),
|
||||||
|
now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM accounts
|
||||||
|
WHERE user_id = ${userId}::uuid AND provider_id = 'google'
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
googleCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ext.provider === "discord") {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid()::text,
|
||||||
|
${ext.provider_user_id},
|
||||||
|
'discord',
|
||||||
|
${userId}::uuid,
|
||||||
|
now(),
|
||||||
|
now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM accounts
|
||||||
|
WHERE user_id = ${userId}::uuid AND provider_id = 'discord'
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
discordCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clerkUser.password_enabled) {
|
||||||
|
emailPasswordCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` Accounts created: ${googleCount} Google, ${discordCount} Discord`
|
||||||
|
);
|
||||||
|
if (emailPasswordCount > 0) {
|
||||||
|
console.log(
|
||||||
|
` ${emailPasswordCount} email/password users — they must use "Forgot Password" on first login`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
console.log(` Skipped ${skippedCount} Clerk users with no matching DB row`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue