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>
146 lines
4.1 KiB
TypeScript
146 lines
4.1 KiB
TypeScript
"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>
|
|
</>
|
|
)
|
|
}
|