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({
data: {
name,
email,
password: hashedPassword,
emailVerified: new Date(),
},
// 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,
},
})
await tx.inviteRedemption.create({
data: {
inviteId: invite.id,
userId: created.id,
email,
provider: "credentials",
},
})
return created
})
return NextResponse.json(
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
}