brackt/docs/agents/auth.md
Chris Parsons 5268e07365
Migrate auth from Clerk to BetterAuth (#354)
* 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>
2026-04-29 10:03:50 -07:00

94 lines
3.1 KiB
Markdown

# Authentication
Auth is handled by [BetterAuth](https://better-auth.com) (`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:
```ts
import { auth } from "~/lib/auth.server";
const session = await auth.api.getSession({ headers: request.headers });
const userId = session?.user.id ?? null; // UUID string
```
`session` is `null` when the user is not logged in.
## User Fields
BetterAuth's `user` object on the session exposes these fields:
| Session field | DB column |
|---|---|
| `session.user.id` | `users.id` (UUID) |
| `session.user.name` | `users.display_name` (mapped as `displayName`) |
| `session.user.email` | `users.email` |
| `session.user.image` | `users.image_url` (mapped as `imageUrl`) |
Additional fields (`firstName`, `lastName`, `username`, `isAdmin`) are stored in `users` and accessible via DB lookup but are NOT on the session object by default.
## Admin Access
`isAdmin` is a boolean on the `users` table. All `/admin` routes check it:
```ts
const isAdmin = session ? await isUserAdmin(session.user.id) : false;
```
Do **not** rely on `session.user.isAdmin` — always re-read from the DB to pick up changes without requiring a new login.
## Ownership Validation
Ownership is stored as a user UUID in FK columns (`teams.owner_id`, `leagues.created_by`, etc.). Compare against `session.user.id`:
```ts
if (team.ownerId !== userId) throw new Response("Forbidden", { status: 403 });
```
## League Commissioner Access
Commissioners are stored in the `commissioners` table. Use `requireLeagueAccess()` from `app/lib/auth.ts` to enforce that the current user is either a commissioner or a team owner:
```ts
await requireLeagueAccess(args, { leagueId, seasonId, db });
```
## Auth Routes
| Route | Purpose |
|---|---|
| `/login` | Email/password sign-in + Google/Discord OAuth |
| `/register` | Email/password sign-up + OAuth |
| `/forgot-password` | Request a password reset email |
| `/reset-password` | Enter new password via reset token (link sent by email via Resend) |
Password reset emails are sent via Resend from `noreply@brackt.com`. The reset link points to `/reset-password?token=…` using `BETTER_AUTH_URL` as the base.
## OAuth Providers
Google and Discord are conditionally enabled when env vars are present:
```
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET
```
Account linking is enabled — users who sign in with OAuth get their accounts linked if the email matches an existing email/password account (`trustedProviders: ["google", "discord"]`).
## Environment Variables
```
BETTER_AUTH_SECRET # required — random secret for signing sessions
BETTER_AUTH_URL # required — app base URL, e.g. https://brackt.com
RESEND_API_KEY # required for password reset emails
```