Gate signup behind an invitation and revoke sessions on suspend

Registration was open to anyone who could reach the app. Signup now
requires an invite: /invite/<token> validates the link and parks the
token in an HttpOnly cookie, /join re-checks it and renders the form,
and the register route consumes a use inside the same transaction that
creates the user - so a duplicate email rolls the use back instead of
burning it. The token never reaches the client, so the form cannot forge
or replay one.

The jwt callback now revalidates the user on every call and returns null
when the account is missing or suspended, which clears the session
cookie. Every API route and server component already branches on
session?.user?.id, so this revokes access everywhere without editing any
of them. The try/catch around that lookup is load-bearing: Auth.js
treats a throw in this callback the same as a null return, so an
unguarded transient database error would sign out every user at once.

authorize() rejects non-ACTIVE users too, so a suspended account cannot
sign in again to mint a fresh token.

/register redirects to /join; it is linked from elsewhere and may be
bookmarked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JP
2026-08-08 20:59:52 +00:00
parent f0c745c50c
commit a6cabc5178
8 changed files with 381 additions and 180 deletions

View File

@@ -0,0 +1,99 @@
import { cookies } from "next/headers"
import Link from "next/link"
import { Beer, MailQuestion } from "lucide-react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { JoinForm } from "@/components/auth/join-form"
import { INVITE_COOKIE, inspectInvite, type InviteState } from "@/lib/invites"
export const dynamic = "force-dynamic"
const MESSAGES: Record<Exclude<InviteState, "valid">, string> = {
unknown: "That invitation link isn't valid. Ask whoever invited you for a new one.",
revoked: "That invitation has been revoked. Ask whoever invited you for a new one.",
expired: "That invitation has expired. Ask whoever invited you for a new one.",
exhausted: "That invitation has already been used. Ask whoever invited you for a new one.",
}
const REQUIRED_MESSAGE =
"DrinkTracker is invite only. Open the invitation link you were sent to create an account."
export default async function JoinPage({
searchParams,
}: {
searchParams: { error?: string }
}) {
// Re-checked here rather than trusting the redirect from /invite/<token>: the
// cookie may be stale, and the invite can be revoked or used up in between.
const token = cookies().get(INVITE_COOKIE)?.value
const { state, invite } = token
? await inspectInvite(token)
: { state: "missing" as const, invite: undefined }
if (state !== "valid") {
// /invite/<token> sends the reason it rejected a link, and sets no cookie in
// that case - so prefer it over the generic "you need an invitation".
const reported = searchParams.error
const fromLink =
reported && reported in MESSAGES
? MESSAGES[reported as Exclude<InviteState, "valid">]
: null
const message =
fromLink ??
(state === "missing"
? REQUIRED_MESSAGE
: MESSAGES[state as Exclude<InviteState, "valid">])
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<MailQuestion className="h-12 w-12 text-muted-foreground" />
</div>
<CardTitle className="text-2xl">Invitation needed</CardTitle>
<CardDescription>{message}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href="/login"
className="text-primary underline-offset-4 hover:underline"
>
Sign in
</Link>
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<Beer className="h-12 w-12 text-primary" />
</div>
<CardTitle className="text-2xl">DrinkTracker</CardTitle>
<CardDescription>
{invite?.inviterName
? `${invite.inviterName} invited you. Create an account to get started.`
: "You've been invited. Create an account to get started."}
</CardDescription>
</CardHeader>
<CardContent>
<JoinForm />
</CardContent>
</Card>
</div>
)
}

View File

@@ -94,7 +94,7 @@ export default function LoginPage() {
<p className="mt-4 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/register" className="text-primary underline-offset-4 hover:underline">
<Link href="/join" className="text-primary underline-offset-4 hover:underline">
Sign up
</Link>
</p>

View File

@@ -1,160 +1,9 @@
"use client"
import { useState } from "react"
import { signIn } from "next-auth/react"
import Link from "next/link"
import { Beer, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { redirect } from "next/navigation"
/**
* Signup moved to /join, which requires an invitation. Kept as a redirect because
* this path is linked from older pages and may be bookmarked.
*/
export default function RegisterPage() {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError("")
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password.length < 10) {
setError("Password must be at least 10 characters")
return
}
if (!/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/[0-9]/.test(password)) {
setError("Password must contain lowercase, uppercase, and a number")
return
}
setLoading(true)
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Registration failed")
setLoading(false)
return
}
// Auto sign in after successful registration
await signIn("credentials", {
email,
password,
callbackUrl: "/dashboard",
})
} catch {
setError("Something went wrong. Please try again.")
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<Beer className="h-12 w-12 text-primary" />
</div>
<CardTitle className="text-2xl">DrinkTracker</CardTitle>
<CardDescription>
Create an account to start tracking your drinks
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
required
maxLength={100}
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Min 10 chars, upper+lower+number"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={10}
maxLength={128}
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={10}
disabled={loading}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Create Account
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
Sign in
</Link>
</p>
</CardContent>
</Card>
</div>
)
redirect("/join")
}

View File

@@ -1,8 +1,10 @@
import { NextResponse } from "next/server"
import { cookies } from "next/headers"
import { z } from "zod"
import bcrypt from "bcryptjs"
import { prisma } from "@/lib/prisma"
import { rateLimit } from "@/lib/rate-limit"
import { INVITE_COOKIE, InviteError, claimInvite } from "@/lib/invites"
const registerSchema = z.object({
name: z
@@ -48,30 +50,69 @@ export async function POST(request: Request) {
const { name, email, password } = result.data
const existingUser = await prisma.user.findUnique({ where: { email } })
if (existingUser) {
// Signup is invite only. The token is read from the HttpOnly cookie set by
// /invite/<token>, never from the request body, so a caller cannot supply one.
const inviteToken = cookies().get(INVITE_COOKIE)?.value
if (!inviteToken) {
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 409 }
{ error: "An invitation is required to sign up." },
{ status: 403 }
)
}
const hashedPassword = await bcrypt.hash(password, 10)
const user = await prisma.user.create({
// One transaction so a duplicate email rolls back the consumed invite use
// rather than burning it on a failed signup.
const user = await prisma.$transaction(async (tx) => {
const invite = await claimInvite(tx, inviteToken)
if (!invite) {
throw new InviteError("This invitation link is no longer valid.", 403)
}
const existingUser = await tx.user.findUnique({ where: { email } })
if (existingUser) {
throw new InviteError("An account with this email already exists", 409)
}
const created = await tx.user.create({
data: {
name,
email,
password: hashedPassword,
// The invite is the verification - there is no email infrastructure to
// send a confirmation, and the link was delivered out of band.
emailVerified: new Date(),
role: "MEMBER",
status: "ACTIVE",
invitedById: invite.createdById,
},
})
return NextResponse.json(
await tx.inviteRedemption.create({
data: {
inviteId: invite.id,
userId: created.id,
email,
provider: "credentials",
},
})
return created
})
const response = NextResponse.json(
{ id: user.id, name: user.name, email: user.email },
{ status: 201 }
)
} catch {
// Spent - do not leave it lying around for a second signup attempt.
response.cookies.delete(INVITE_COOKIE)
return response
} catch (error) {
if (error instanceof InviteError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
console.error("Registration error:", error)
return NextResponse.json(
{ error: "Something went wrong. Please try again." },
{ status: 500 }

View File

@@ -0,0 +1,36 @@
import { NextResponse } from "next/server"
import { INVITE_COOKIE, INVITE_COOKIE_MAX_AGE, inspectInvite } from "@/lib/invites"
/**
* Entry point for an invite link or QR code.
*
* Deliberately a route handler rather than a page: a server component cannot set
* cookies in Next 14, and stashing the token in an HttpOnly cookie keeps it off the
* client entirely - the signup form never sees or submits it.
*
* This only parks the token. The use is consumed transactionally at registration,
* so an abandoned signup does not burn an invite.
*/
export const dynamic = "force-dynamic"
export async function GET(
request: Request,
{ params }: { params: { token: string } }
) {
const { state } = await inspectInvite(params.token)
const origin = new URL(request.url).origin
if (state !== "valid") {
return NextResponse.redirect(new URL(`/join?error=${state}`, origin))
}
const response = NextResponse.redirect(new URL("/join", origin))
response.cookies.set(INVITE_COOKIE, params.token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: INVITE_COOKIE_MAX_AGE,
})
return response
}

View File

@@ -0,0 +1,145 @@
"use client"
import { useState } from "react"
import { signIn } from "next-auth/react"
import Link from "next/link"
import { Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
/**
* Signup form for an invited user.
*
* The invite token is deliberately absent: it lives in an HttpOnly cookie set by
* /invite/<token>, and the register route reads it server-side. Nothing here can
* see or forge it.
*/
export function JoinForm() {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError("")
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password.length < 10) {
setError("Password must be at least 10 characters")
return
}
if (!/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/[0-9]/.test(password)) {
setError("Password must contain lowercase, uppercase, and a number")
return
}
setLoading(true)
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Registration failed")
setLoading(false)
return
}
await signIn("credentials", { email, password, callbackUrl: "/dashboard" })
} catch {
setError("Something went wrong. Please try again.")
setLoading(false)
}
}
return (
<>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
required
maxLength={100}
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Min 10 chars, upper+lower+number"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={10}
maxLength={128}
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={10}
disabled={loading}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Create Account
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
Sign in
</Link>
</p>
</>
)
}

View File

@@ -9,7 +9,7 @@ import { rateLimit } from "@/lib/rate-limit"
* prefix, so `/share` also covers `/share/<slug>`. Everything else is private -
* see the `authorized` callback and the denylist matcher in src/middleware.ts.
*/
const PUBLIC_ROUTES = ["/login", "/register", "/share"]
const PUBLIC_ROUTES = ["/login", "/register", "/join", "/invite", "/share"]
// Email and password only. Google and GitHub were configured but never had
// credentials set and no account ever linked to them, and dropping them keeps
@@ -38,6 +38,10 @@ const providers = [
const valid = await bcrypt.compare(password, user.password)
if (!valid) return null
// Checked here as well as in the jwt callback, so a suspended user cannot
// simply sign in again to mint a fresh token.
if (user.status !== "ACTIVE") return null
return { id: user.id, email: user.email, name: user.name, image: user.image }
},
}),
@@ -53,15 +57,45 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
signIn: "/login",
},
callbacks: {
jwt({ token, user }) {
/**
* Revalidates the user on every call, so suspending or deleting an account
* ends its session instead of waiting for the JWT to expire.
*
* Returning null clears the session cookie. Because every API route and server
* component already branches on `session?.user?.id`, that revokes access
* everywhere without touching any of them.
*
* The try/catch is load-bearing: Auth.js treats a throw here the same as a null
* return, so an unguarded transient database error would sign out every user at
* once. On failure we keep the existing token and let the next call retry.
*/
async jwt({ token, user }) {
if (user) {
token.id = user.id
}
const userId = token.id as string | undefined
if (!userId) return token
try {
const current = await prisma.user.findUnique({
where: { id: userId },
select: { role: true, status: true },
})
if (!current) return null
if (current.status !== "ACTIVE") return null
token.role = current.role
token.status = current.status
} catch (error) {
console.error("[auth] jwt revalidation failed, keeping session:", error)
}
return token
},
session({ session, token }) {
if (session.user && token.id) {
session.user.id = token.id as string
session.user.role = token.role as "OWNER" | "MEMBER" | undefined
}
return session
},

View File

@@ -14,10 +14,7 @@ declare module "next-auth" {
}
}
declare module "next-auth/jwt" {
interface JWT {
id?: string
role?: "OWNER" | "MEMBER"
status?: "ACTIVE" | "SUSPENDED"
}
}
// No JWT augmentation here on purpose. next-auth/jwt only re-exports
// @auth/core/jwt, and augmenting either did not take - the callback's `token`
// still resolves through JWT's `Record<string, unknown>` index signature. The
// jwt/session callbacks in lib/auth.ts cast the two fields they read instead.