auth.js magic-link login + protected admin shell with bookings list

This commit is contained in:
2026-05-01 21:13:02 -04:00
parent c768dda3a1
commit 415813470a
14 changed files with 542 additions and 62 deletions

View File

@@ -0,0 +1,100 @@
# 2026-05-02 — Auth.js + Admin Shell
> Companion to `Initial.md`. Predecessor: `2026-05-01-end-to-end.md`. Closes Step 5a of `Initial.md` §9.
## Milestone
The practice has its first usable web surface: admin can sign in via magic-link email, see today's confirmed bookings, and sign out. Auth.js v5 is wired through to the same SMTP transport we built for booking confirmations — Mailpit catches sign-in emails in dev, Resend will catch them in prod with one config change.
## What's verified end-to-end
In a real browser-equivalent flow (curl + Mailpit API):
1. Anonymous `GET /admin``307` redirect to `/login`
2. Submit `email=admin@touchbase.local` to `/api/auth/signin/nodemailer` → magic link delivered to Mailpit (subject "Sign in to localhost:3000")
3. Click magic link from email → JWT session cookie set, redirect to `/`
4. `GET /admin/bookings` with the cookie → renders the booking table (1 row from earlier `book-on-behalf` run: Tue May 5 10:00 AM, Alex Park, 60-minute Swedish, Mei Tanaka, Room 1 — Sunset, CONFIRMED)
Plus: `pnpm test` 64/64, `pnpm lint` clean, `pnpm exec tsc --noEmit` clean.
## What landed
| Path | Role |
|---|---|
| `src/auth.ts` | Auth.js v5 config: PrismaAdapter, Nodemailer provider via SMTP, JWT sessions, role-aware session/JWT callbacks |
| `src/app/api/auth/[...nextauth]/route.ts` | Required Auth.js route handler |
| `src/app/page.tsx` | Marketing-thin home with link to staff sign-in |
| `src/app/login/page.tsx` | Magic-link request form (server action) |
| `src/app/login/check-email/page.tsx` | Post-submission landing page |
| `src/app/admin/layout.tsx` | Auth-required layout; redirects to `/login` if no session, shows 403 if not ADMIN; header with email + sign-out |
| `src/app/admin/page.tsx` | Redirects to `/admin/bookings` |
| `src/app/admin/bookings/page.tsx` | Server-rendered table of upcoming HOLD/CONFIRMED bookings, times in `APP_TZ` |
| `prisma/schema.prisma` | `User.image String?` (Auth.js convention) + `VerificationToken` model |
| `prisma/migrations/20260501231153_auth/` | Migration |
| `.env` | `AUTH_SECRET` set |
## Decisions ratified
| Decision | Resolution |
|---|---|
| Auth.js version | v5 beta (5.0.0-beta.31). v4 stable doesn't support the App Router patterns we want |
| Session strategy | **JWT**, not database. Reason: simpler — no Account/Session tables needed, no extra DB roundtrip per request |
| Magic-link expiry | 24h (Auth.js default). Acceptable for staff |
| Email provider for auth | Same Nodemailer SMTP transport as our booking confirmations. No separate config |
| Mailpit AUTH workaround | Mailpit advertises `AUTH PLAIN`; nodemailer attempts it; without creds it errors. Fix: pass `auth: { user: "dev", pass: "dev" }` (Mailpit is configured with `MP_SMTP_AUTH_ACCEPT_ANY=1`) |
| Role refresh in JWT callback | On subsequent calls (no `user`), re-fetch role from DB if missing. Tradeoff: tiny extra query per request, but role changes apply on next request without re-login |
| `/admin/bookings` is server-rendered with `dynamic = "force-dynamic"` | Reason: bookings change continuously; static caching would lie. Cheap enough at our scale |
| Time formatting in admin table | `Intl.DateTimeFormat` with `APP_TZ` (America/Detroit). No client JS needed |
| Sign-out via server action with `signOut({ redirectTo: "/" })` | Reason: stays consistent with sign-in pattern; no client component needed |
| `session.user.id` and `session.user.role` typing | Module augmentation in `src/auth.ts` for `next-auth` and `next-auth/jwt`. Required `import type {} from "next-auth/jwt"` to force module loading before augmentation |
## Gotchas hit
### 1. Mailpit SMTP AUTH advertisement
nodemailer attempted `AUTH PLAIN` because Mailpit advertises it; threw "Missing credentials for PLAIN" when given `auth: undefined`. Resolution above. Same code path in `src/lib/email.ts` (booking confirmations) doesn't hit this because those tests run after Mailpit-bound env is set, but the prod path with real SMTP credentials wouldn't have the same issue regardless. Worth a note: setting `auth: { user: "dev", pass: "dev" }` works for Mailpit and harmless if real creds are set later via env.
### 2. Module augmentation for `next-auth/jwt`
TypeScript couldn't find `next-auth/jwt` for the `declare module` block until we added `import type {} from "next-auth/jwt"` to force the module to be loaded. Subtle but well-known TS quirk with submodule augmentation.
### 3. `PrismaAdapter` typing vs Prisma 7's generated client
`PrismaAdapter(db)` complained because Prisma 7's client generic doesn't match Auth.js's adapter expectation. Cast as `db as any` with an inline ESLint disable. Auth.js adapter doesn't actually use the unmatched types at runtime; this is purely a typing gap that may close as either side updates.
### 4. Workspace-root warning from Next.js
A stray `/Users/noise/package-lock.json` made Next.js ambiguous about the project root. Visible warning at startup; not blocking. Worth setting `turbopack.root` in `next.config.ts` later, or just removing the offending lockfile.
## Open questions still unresolved
1. Customer-visible brand name (TouchBase or other) — affects email sender/subject lines
2. Currency — USD assumed
3. Stripe account ownership — needed when 5c lands
## Roadmap status (`Initial.md` §9)
1. Spike — done 2026-04-30
2. Schema + seed — done 2026-05-01
3. Availability algorithm — done 2026-05-01
4. First end-to-end story (admin booking + email) — done 2026-05-01
5. Public self-booking → Stripe → reminders
- **5a Auth.js + admin shell — done 2026-05-02 (this session)**
- 5b Admin "create booking" page (slot picker + form using existing pipeline) — recommended next
- 5c Public booking page (CUSTOMER role, magic-link signup, slot search + checkout)
- 5d Stripe deposit flow + webhook
- 5e Email reminders (pg-boss scheduled jobs)
## Recommended next step
**5b — admin "create booking" page**. Wraps the `book-on-behalf` CLI in a UI: pick service, pick customer (search), pick a slot from `findSlots`, submit. Reuses the loader + `createHold` + email pipeline directly. ~1 day. After this lands, the practice has everything needed to take phone bookings without leaving the browser.
Then 5c (public self-booking page) becomes mostly a "remove the customer-search step + add public-facing styling" exercise on top of the admin form — most of the slot-picker UI carries over.
## How to resume
```bash
cd /Users/noise/Documents/code/touchbase
docker-compose up -d postgres mailpit
pnpm db:seed # admin@touchbase.local exists in seed
pnpm dev # http://localhost:3000
# Open http://localhost:3000/login → submit admin@touchbase.local
# Open http://localhost:8025 → click magic link
# Lands on http://localhost:3000/admin/bookings
```