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
```

View File

@@ -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
View File

@@ -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):

View 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")
);

View File

@@ -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.

View 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
View 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&apos;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
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminIndex() {
redirect("/admin/bookings");
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@@ -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({

View 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
View 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&apos;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>
);
}

View File

@@ -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
View 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,
});