From 415813470a38143b15e321b50880615066e99129 Mon Sep 17 00:00:00 2001 From: noisedestroyers Date: Fri, 1 May 2026 21:13:02 -0400 Subject: [PATCH] auth.js magic-link login + protected admin shell with bookings list --- .../2026-05-02-auth-and-admin-shell.md | 100 ++++++++++++++++++ package.json | 2 + pnpm-lock.yaml | 97 +++++++++++++++++ .../20260501231153_auth/migration.sql | 11 ++ prisma/schema.prisma | 10 ++ src/app/admin/bookings/page.tsx | 96 +++++++++++++++++ src/app/admin/layout.tsx | 66 ++++++++++++ src/app/admin/page.tsx | 5 + src/app/api/auth/[...nextauth]/route.ts | 3 + src/app/layout.tsx | 4 +- src/app/login/check-email/page.tsx | 15 +++ src/app/login/page.tsx | 43 ++++++++ src/app/page.tsx | 73 +++---------- src/auth.ts | 79 ++++++++++++++ 14 files changed, 542 insertions(+), 62 deletions(-) create mode 100644 docs/progress/2026-05-02-auth-and-admin-shell.md create mode 100644 prisma/migrations/20260501231153_auth/migration.sql create mode 100644 src/app/admin/bookings/page.tsx create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/login/check-email/page.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/auth.ts 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 ( +
+
+

Upcoming bookings

+ {bookings.length} shown +
+ {bookings.length === 0 ? ( +

+ No upcoming bookings. +

+ ) : ( +
+ + + + + + + + + + + + + {bookings.map((b) => ( + + + + + + + + + ))} + +
WhenCustomerServiceTherapistRoomStatus
+ {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} + +
+
+ )} +
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..d4b3b4f --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,66 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { auth, signOut } from "@/auth"; + +export const dynamic = "force-dynamic"; + +async function logout() { + "use server"; + await signOut({ redirectTo: "/" }); +} + +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await auth(); + if (!session?.user) redirect("/login"); + if (session.user.role !== "ADMIN") { + return ( +
+

Not authorized

+

+ Your account doesn't have admin access. +

+
+ +
+
+ ); + } + + return ( +
+
+
+ + TouchBase + + +
+
+ {session.user.email} +
+ +
+
+
+
{children}
+
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..5c62599 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AdminIndex() { + redirect("/admin/bookings"); +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..86c9f3d --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/auth"; + +export const { GET, POST } = handlers; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 976eb90..f2cf875 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "TouchBase", + description: "Massage practice scheduling", }; export default function RootLayout({ diff --git a/src/app/login/check-email/page.tsx b/src/app/login/check-email/page.tsx new file mode 100644 index 0000000..d933458 --- /dev/null +++ b/src/app/login/check-email/page.tsx @@ -0,0 +1,15 @@ +export const metadata = { title: "Check your email — TouchBase" }; + +export default function CheckEmailPage() { + return ( +
+

Check your email

+

+ We sent you a sign-in link. Click it to continue. +

+

+ The link expires in 24 hours. +

+
+ ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..ff8f20d --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,43 @@ +import { signIn } from "@/auth"; + +export const metadata = { title: "Sign in — TouchBase" }; + +async function requestMagicLink(formData: FormData) { + "use server"; + const email = String(formData.get("email") ?? "").trim(); + if (!email) return; + // signIn with redirectTo handles the redirect to /login/check-email itself + // (configured as `pages.verifyRequest` in src/auth.ts). + await signIn("nodemailer", { email, redirectTo: "/admin" }); +} + +export default function LoginPage() { + return ( +
+

Sign in

+

+ Enter your email and we'll send you a sign-in link. +

+
+ + + +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f36f7c..4b98e26 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,18 @@ -import Image from "next/image"; +import Link from "next/link"; export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
+
+

TouchBase

+

+ Scheduling for the practice. +

+ + Staff sign in + +
); } diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..a91807b --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,79 @@ +// Auth.js v5 (next-auth@beta) configuration. +// Magic-link via SMTP (Mailpit dev / Resend prod), JWT sessions, role aware. + +import NextAuth, { type DefaultSession } from "next-auth"; +import type {} from "next-auth/jwt"; +import Nodemailer from "next-auth/providers/nodemailer"; +import { PrismaAdapter } from "@auth/prisma-adapter"; +import { db } from "@/lib/db"; +import type { Role } from "@/generated/prisma/enums"; + +declare module "next-auth" { + interface Session { + user: { + id: string; + role: Role; + } & DefaultSession["user"]; + } + + interface User { + role?: Role; + } +} + +declare module "next-auth/jwt" { + interface JWT { + id?: string; + role?: Role; + } +} + +const port = Number(process.env.SMTP_PORT ?? 1025); + +export const { handlers, signIn, signOut, auth } = NextAuth({ + // PrismaAdapter typing lags PrismaClient v7's generic; cast is safe. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + adapter: PrismaAdapter(db as any), + providers: [ + Nodemailer({ + server: { + host: process.env.SMTP_HOST ?? "localhost", + port, + secure: port === 465, + // Mailpit advertises AUTH PLAIN; nodemailer attempts it. Send dummy + // creds (accepted via MP_SMTP_AUTH_ACCEPT_ANY) when no real user set. + auth: { + user: process.env.SMTP_USER || "dev", + pass: process.env.SMTP_PASS || "dev", + }, + }, + from: process.env.SMTP_FROM ?? "TouchBase ", + }), + ], + session: { strategy: "jwt" }, + pages: { signIn: "/login", verifyRequest: "/login/check-email" }, + callbacks: { + async jwt({ token, user }) { + if (user) { + token.id = user.id; + token.role = user.role; + } + // On subsequent calls only `token` is populated; refresh role from DB + // so role changes take effect on next request without re-login. + if (token.id && !token.role) { + const fresh = await db.user.findUnique({ + where: { id: token.id }, + select: { role: true }, + }); + if (fresh) token.role = fresh.role; + } + return token; + }, + async session({ session, token }) { + if (token.id) session.user.id = token.id; + if (token.role) session.user.role = token.role; + return session; + }, + }, + trustHost: true, +});