auth.js magic-link login + protected admin shell with bookings list
This commit is contained in:
100
docs/progress/2026-05-02-auth-and-admin-shell.md
Normal file
100
docs/progress/2026-05-02-auth-and-admin-shell.md
Normal 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
|
||||
```
|
||||
@@ -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",
|
||||
|
||||
97
pnpm-lock.yaml
generated
97
pnpm-lock.yaml
generated
@@ -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):
|
||||
|
||||
11
prisma/migrations/20260501231153_auth/migration.sql
Normal file
11
prisma/migrations/20260501231153_auth/migration.sql
Normal file
@@ -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")
|
||||
);
|
||||
@@ -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.
|
||||
|
||||
96
src/app/admin/bookings/page.tsx
Normal file
96
src/app/admin/bookings/page.tsx
Normal file
@@ -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 (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-4 flex items-baseline justify-between">
|
||||
<h1 className="text-xl font-semibold tracking-tight">Upcoming bookings</h1>
|
||||
<span className="text-sm text-zinc-500">{bookings.length} shown</span>
|
||||
</div>
|
||||
{bookings.length === 0 ? (
|
||||
<p className="rounded-md border border-zinc-200 bg-white p-6 text-sm text-zinc-600 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400">
|
||||
No upcoming bookings.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-900">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">When</th>
|
||||
<th className="px-4 py-2 font-medium">Customer</th>
|
||||
<th className="px-4 py-2 font-medium">Service</th>
|
||||
<th className="px-4 py-2 font-medium">Therapist</th>
|
||||
<th className="px-4 py-2 font-medium">Room</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
{bookings.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td className="px-4 py-2 whitespace-nowrap font-mono text-xs">
|
||||
{formatLocal(b.startsAt)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div>{b.customer.name}</div>
|
||||
<div className="text-xs text-zinc-500">{b.customer.email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{b.service.name}
|
||||
<div className="text-xs text-zinc-500">
|
||||
{b.service.durationMin}m
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">{b.therapist.user.name}</td>
|
||||
<td className="px-4 py-2">{b.room.name}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={
|
||||
b.status === "CONFIRMED"
|
||||
? "rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800 dark:bg-green-900/40 dark:text-green-200"
|
||||
: "rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
|
||||
}
|
||||
>
|
||||
{b.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/app/admin/layout.tsx
Normal file
66
src/app/admin/layout.tsx
Normal file
@@ -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 (
|
||||
<main className="mx-auto flex min-h-full max-w-md flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Not authorized</h1>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Your account doesn't have admin access.
|
||||
</p>
|
||||
<form action={logout}>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm dark:border-zinc-700"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-6 py-3 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<div className="flex items-center gap-6">
|
||||
<Link href="/admin" className="text-sm font-semibold tracking-tight">
|
||||
TouchBase
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<Link href="/admin/bookings" className="hover:text-zinc-900 dark:hover:text-zinc-100">
|
||||
Bookings
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<span>{session.user.email}</span>
|
||||
<form action={logout}>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md border border-zinc-300 px-2.5 py-1 text-xs hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-900"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/app/admin/page.tsx
Normal file
5
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminIndex() {
|
||||
redirect("/admin/bookings");
|
||||
}
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -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({
|
||||
|
||||
15
src/app/login/check-email/page.tsx
Normal file
15
src/app/login/check-email/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export const metadata = { title: "Check your email — TouchBase" };
|
||||
|
||||
export default function CheckEmailPage() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-full max-w-sm flex-col justify-center gap-4 p-8 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Check your email</h1>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
We sent you a sign-in link. Click it to continue.
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-500">
|
||||
The link expires in 24 hours.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
43
src/app/login/page.tsx
Normal file
43
src/app/login/page.tsx
Normal file
@@ -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 (
|
||||
<main className="mx-auto flex min-h-full max-w-sm flex-col justify-center gap-6 p-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Enter your email and we'll send you a sign-in link.
|
||||
</p>
|
||||
<form action={requestMagicLink} className="flex flex-col gap-3">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="you@touchbase.local"
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm shadow-sm placeholder:text-zinc-400 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500 dark:border-zinc-700 dark:bg-zinc-900"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-2 rounded-md bg-zinc-900 px-3 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900"
|
||||
>
|
||||
Send sign-in link
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,18 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<main className="mx-auto flex min-h-full max-w-md flex-col items-center justify-center gap-6 p-8 text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">TouchBase</h1>
|
||||
<p className="text-zinc-600 dark:text-zinc-400">
|
||||
Scheduling for the practice.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-full bg-zinc-900 px-5 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900"
|
||||
>
|
||||
Staff sign in
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
79
src/auth.ts
Normal file
79
src/auth.ts
Normal file
@@ -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 <noreply@touchbase.local>",
|
||||
}),
|
||||
],
|
||||
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,
|
||||
});
|
||||
Reference in New Issue
Block a user