auth.js magic-link login + protected admin shell with bookings list
This commit is contained in:
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