diff --git a/src/app/(auth)/join/page.tsx b/src/app/(auth)/join/page.tsx new file mode 100644 index 0000000..3cea61a --- /dev/null +++ b/src/app/(auth)/join/page.tsx @@ -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, 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/: 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/ 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] + : null + + const message = + fromLink ?? + (state === "missing" + ? REQUIRED_MESSAGE + : MESSAGES[state as Exclude]) + + return ( +
+ + +
+ +
+ Invitation needed + {message} +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+
+
+ ) + } + + return ( +
+ + +
+ +
+ DrinkTracker + + {invite?.inviterName + ? `${invite.inviterName} invited you. Create an account to get started.` + : "You've been invited. Create an account to get started."} + +
+ + + +
+
+ ) +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 680b4e1..088933a 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -94,7 +94,7 @@ export default function LoginPage() {

Don't have an account?{" "} - + Sign up

diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index b019fc2..818f02e 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -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 ( -
- - -
- -
- DrinkTracker - - Create an account to start tracking your drinks - -
- -
- {error && ( -
- {error} -
- )} - -
- - setName(e.target.value)} - required - maxLength={100} - disabled={loading} - /> -
- -
- - setEmail(e.target.value)} - required - disabled={loading} - /> -
- -
- - setPassword(e.target.value)} - required - minLength={10} - maxLength={128} - disabled={loading} - /> -
- -
- - setConfirmPassword(e.target.value)} - required - minLength={10} - disabled={loading} - /> -
- - -
- -

- Already have an account?{" "} - - Sign in - -

-
-
-
- ) + redirect("/join") } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index c8c30b0..3c343c2 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -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/, 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 } diff --git a/src/app/invite/[token]/route.ts b/src/app/invite/[token]/route.ts new file mode 100644 index 0000000..f731a00 --- /dev/null +++ b/src/app/invite/[token]/route.ts @@ -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 +} diff --git a/src/components/auth/join-form.tsx b/src/components/auth/join-form.tsx new file mode 100644 index 0000000..c4f566e --- /dev/null +++ b/src/components/auth/join-form.tsx @@ -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/, 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 ( + <> +
+ {error && ( +
+ {error} +
+ )} + +
+ + setName(e.target.value)} + required + maxLength={100} + disabled={loading} + /> +
+ +
+ + setEmail(e.target.value)} + required + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={10} + maxLength={128} + disabled={loading} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + minLength={10} + disabled={loading} + /> +
+ + +
+ +

+ Already have an account?{" "} + + Sign in + +

+ + ) +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 3222a71..044190a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -9,7 +9,7 @@ import { rateLimit } from "@/lib/rate-limit" * prefix, so `/share` also covers `/share/`. 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 }, diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index c5bd398..344caf5 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -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` index signature. The +// jwt/session callbacks in lib/auth.ts cast the two fields they read instead.