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