* Fix BetterAuth field mapping and add owner-prefixed team names - Fix auth.server.ts: use camelCase Drizzle field names for BetterAuth adapter (was snake_case, causing user inserts to fail); add generateId: "uuid" so PostgreSQL UUID columns accept generated IDs - Add prependOwnerToTeamName / stripOwnerFromTeamName utilities - Invite join, league creation, and settings assign/remove all now manage the owner-prefix on team names atomically Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Complete BetterAuth migration: auth flows, rename, and cleanup - Add /forgot-password and /reset-password pages (full password reset flow) - Add 'Forgot password?' link on login page - Fix register.tsx: add explicit window.location fallback after signUp - Rename commissionerAuditLog.actorClerkId → actorUserId (migration 0084) and update all references in model, routes, components, and tests - Rewrite docs/agents/auth.md to document BetterAuth (removes all Clerk refs) - Update test fixtures and schema comments to remove Clerk ID references; use UUID-format IDs throughout test data - Update docs/agents/domain-models.md ownerId description Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix BetterAuth ID generation, clean up review issues - Set generateId: false so BetterAuth omits id from inserts; add $defaultFn(crypto.randomUUID) to accounts/sessions/verifications so Drizzle fills it in (fixes null id constraint violation on signup) - Move renameTeam to static import; rename before removeTeamOwner so a failed rename leaves owner intact rather than leaving a stale prefix - Clarify stripOwnerFromTeamName toTitleCase assumption in comment - Annotate reset-password token fallback to explain loader guarantee Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
3.1 KiB
Authentication
Auth is handled by BetterAuth (better-auth package).
Key Files
| File | Purpose |
|---|---|
app/lib/auth.server.ts |
BetterAuth instance — providers, DB adapter, field mapping |
app/lib/auth-client.ts |
Browser-side auth client (exported as authClient) |
app/routes/api.auth.$.ts |
Catches all /api/auth/* requests and delegates to BetterAuth |
app/lib/auth.ts |
Server helper: requireLeagueAccess() |
Reading the Session
In any route loader or action:
import { auth } from "~/lib/auth.server";
const session = await auth.api.getSession({ headers: request.headers });
const userId = session?.user.id ?? null; // UUID string
session is null when the user is not logged in.
User Fields
BetterAuth's user object on the session exposes these fields:
| Session field | DB column |
|---|---|
session.user.id |
users.id (UUID) |
session.user.name |
users.display_name (mapped as displayName) |
session.user.email |
users.email |
session.user.image |
users.image_url (mapped as imageUrl) |
Additional fields (firstName, lastName, username, isAdmin) are stored in users and accessible via DB lookup but are NOT on the session object by default.
Admin Access
isAdmin is a boolean on the users table. All /admin routes check it:
const isAdmin = session ? await isUserAdmin(session.user.id) : false;
Do not rely on session.user.isAdmin — always re-read from the DB to pick up changes without requiring a new login.
Ownership Validation
Ownership is stored as a user UUID in FK columns (teams.owner_id, leagues.created_by, etc.). Compare against session.user.id:
if (team.ownerId !== userId) throw new Response("Forbidden", { status: 403 });
League Commissioner Access
Commissioners are stored in the commissioners table. Use requireLeagueAccess() from app/lib/auth.ts to enforce that the current user is either a commissioner or a team owner:
await requireLeagueAccess(args, { leagueId, seasonId, db });
Auth Routes
| Route | Purpose |
|---|---|
/login |
Email/password sign-in + Google/Discord OAuth |
/register |
Email/password sign-up + OAuth |
/forgot-password |
Request a password reset email |
/reset-password |
Enter new password via reset token (link sent by email via Resend) |
Password reset emails are sent via Resend from noreply@brackt.com. The reset link points to /reset-password?token=… using BETTER_AUTH_URL as the base.
OAuth Providers
Google and Discord are conditionally enabled when env vars are present:
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET
Account linking is enabled — users who sign in with OAuth get their accounts linked if the email matches an existing email/password account (trustedProviders: ["google", "discord"]).
Environment Variables
BETTER_AUTH_SECRET # required — random secret for signing sessions
BETTER_AUTH_URL # required — app base URL, e.g. https://brackt.com
RESEND_API_KEY # required for password reset emails