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:
99
src/app/(auth)/join/page.tsx
Normal file
99
src/app/(auth)/join/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export default function LoginPage() {
|
||||
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Don'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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
36
src/app/invite/[token]/route.ts
Normal file
36
src/app/invite/[token]/route.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user