Not authorized
++ Your account doesn't have admin access. +
+ +diff --git a/docs/progress/2026-05-02-auth-and-admin-shell.md b/docs/progress/2026-05-02-auth-and-admin-shell.md new file mode 100644 index 0000000..1983ba9 --- /dev/null +++ b/docs/progress/2026-05-02-auth-and-admin-shell.md @@ -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 +``` diff --git a/package.json b/package.json index 7a68d7d..980674e 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,13 @@ "db:seed": "tsx prisma/seed.ts" }, "dependencies": { + "@auth/prisma-adapter": "^2.11.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "next": "16.2.4", + "next-auth": "5.0.0-beta.31", "nodemailer": "^8.0.7", "pg": "^8.20.0", "react": "19.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76362c5..e109e71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@auth/prisma-adapter': + specifier: ^2.11.2 + version: 2.11.2(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(nodemailer@8.0.7) '@prisma/adapter-pg': specifier: ^7.8.0 version: 7.8.0 @@ -23,6 +26,9 @@ importers: next: specifier: 16.2.4 version: 16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-auth: + specifier: 5.0.0-beta.31 + version: 5.0.0-beta.31(next@16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.7)(react@19.2.4) nodemailer: specifier: ^8.0.7 version: 8.0.7 @@ -94,6 +100,25 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@auth/core@0.41.2': + resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^7.0.7 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + + '@auth/prisma-adapter@2.11.2': + resolution: {integrity: sha512-GyNEUNtrPgDPs0M4xX6F5i7jTsCKwU6BXV9zutctcoo6K1Ud+juckrmQS11uyNgeWsw6sliextHbU/e+8lsizQ==} + peerDependencies: + '@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5 || >=6' + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -642,6 +667,9 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1995,6 +2023,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2190,6 +2221,22 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-auth@5.0.0-beta.31: + resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + next: ^14.0.0-0 || ^15.0.0 || ^16.0.0 + nodemailer: ^7.0.7 + react: ^18.2.0 || ^19.0.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + next@16.2.4: resolution: {integrity: sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==} engines: {node: '>=20.9.0'} @@ -2222,6 +2269,9 @@ packages: resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==} engines: {node: '>=6.0.0'} + oauth4webapi@3.8.6: + resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2381,6 +2431,14 @@ packages: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} + preact-render-to-string@6.5.11: + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} + peerDependencies: + preact: '>=10' + + preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2887,6 +2945,25 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@auth/core@0.41.2(nodemailer@8.0.7)': + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.2.3 + oauth4webapi: 3.8.6 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + optionalDependencies: + nodemailer: 8.0.7 + + '@auth/prisma-adapter@2.11.2(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(nodemailer@8.0.7)': + dependencies: + '@auth/core': 0.41.2(nodemailer@8.0.7) + '@prisma/client': 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) + transitivePeerDependencies: + - '@simplewebauthn/browser' + - '@simplewebauthn/server' + - nodemailer + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -3335,6 +3412,8 @@ snapshots: '@oxc-project/types@0.127.0': {} + '@panva/hkdf@1.2.1': {} + '@polka/url@1.0.0-next.29': {} '@prisma/adapter-pg@7.8.0': @@ -4843,6 +4922,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.3: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -5003,6 +5084,14 @@ snapshots: natural-compare@1.4.0: {} + next-auth@5.0.0-beta.31(next@16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.7)(react@19.2.4): + dependencies: + '@auth/core': 0.41.2(nodemailer@8.0.7) + next: 16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + optionalDependencies: + nodemailer: 8.0.7 + next@16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.4 @@ -5038,6 +5127,8 @@ snapshots: nodemailer@8.0.7: {} + oauth4webapi@3.8.6: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -5196,6 +5287,12 @@ snapshots: postgres@3.4.7: {} + preact-render-to-string@6.5.11(preact@10.24.3): + dependencies: + preact: 10.24.3 + + preact@10.24.3: {} + prelude-ls@1.2.1: {} prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): diff --git a/prisma/migrations/20260501231153_auth/migration.sql b/prisma/migrations/20260501231153_auth/migration.sql new file mode 100644 index 0000000..56c1335 --- /dev/null +++ b/prisma/migrations/20260501231153_auth/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "image" TEXT; + +-- CreateTable +CREATE TABLE "VerificationToken" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "VerificationToken_pkey" PRIMARY KEY ("identifier","token") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8a5ea29..fc0534b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,6 +25,7 @@ model User { email String @unique emailVerified DateTime? @db.Timestamptz(3) name String + image String? // Auth.js convention; unused for now but adapter expects it phone String? role Role @default(CUSTOMER) createdAt DateTime @default(now()) @db.Timestamptz(3) @@ -40,6 +41,15 @@ model User { @@index([deletedAt]) } +// Auth.js magic-link state. JWT sessions, so no Account or Session tables needed. +model VerificationToken { + identifier String + token String + expires DateTime @db.Timestamptz(3) + + @@id([identifier, token]) +} + model Customer { userId String @id notes String? // Front-desk notes. Sensitive — column-level encrypt before prod. diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx new file mode 100644 index 0000000..df2db84 --- /dev/null +++ b/src/app/admin/bookings/page.tsx @@ -0,0 +1,96 @@ +import { db } from "@/lib/db"; + +export const metadata = { title: "Bookings — TouchBase" }; +export const dynamic = "force-dynamic"; + +const TZ = process.env.APP_TZ ?? "America/Detroit"; + +function formatLocal(d: Date): string { + return new Intl.DateTimeFormat("en-US", { + timeZone: TZ, + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(d); +} + +export default async function BookingsPage() { + const now = new Date(); + const bookings = await db.booking.findMany({ + where: { + status: { in: ["HOLD", "CONFIRMED"] }, + startsAt: { gte: now }, + }, + include: { + customer: { select: { name: true, email: true } }, + service: { select: { name: true, durationMin: true } }, + therapist: { include: { user: { select: { name: true } } } }, + room: { select: { name: true } }, + }, + orderBy: { startsAt: "asc" }, + take: 100, + }); + + return ( +
+ No upcoming bookings. +
+ ) : ( +| When | +Customer | +Service | +Therapist | +Room | +Status | +
|---|---|---|---|---|---|
| + {formatLocal(b.startsAt)} + | +
+ {b.customer.name}
+ {b.customer.email}
+ |
+
+ {b.service.name}
+
+ {b.service.durationMin}m
+
+ |
+ {b.therapist.user.name} | +{b.room.name} | ++ + {b.status} + + | +
+ Your account doesn't have admin access. +
+ ++ We sent you a sign-in link. Click it to continue. +
++ The link expires in 24 hours. +
++ Enter your email and we'll send you a sign-in link. +
+ +- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -
-+ Scheduling for the practice. +
+ + Staff sign in + +