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