From f2c7d13a3e979d710ce619101522602ca8959fff Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Sun, 19 Apr 2026 10:45:51 -0500 Subject: [PATCH] Unify invite landing with inline signup/login flow Rewrite /invite/[token] so invited users can create an account and join a workspace in a single page instead of bouncing to /signup. The page branches on verify response + session state: - New user: inline signup form (name + password, email locked), then auto signs in and redirects to the dashboard. - Existing user, signed out: password-only login form that signs in and auto-calls /api/invitations/accept. - Existing user, signed in with matching email: single Accept button. - Existing user, signed in with mismatched email: amber notice plus one-click "Sign out and continue" that returns to the same invite URL logged out. Also: - Expired / already-accepted / not-found states now show the inviter's name and org context so users know who to contact for a new link. - Extract the password + confirm-password + strength meter into a reusable component consumed by both the signup and invite pages so the UI stays in lockstep. The component supports hiding the confirm field and overriding autoComplete for login use. Made-with: Cursor --- src/app/(auth)/invite/[token]/page.tsx | 624 ++++++++++++++++++++---- src/app/(auth)/signup/page.tsx | 95 +--- src/components/auth/password-fields.tsx | 140 ++++++ 3 files changed, 673 insertions(+), 186 deletions(-) create mode 100644 src/components/auth/password-fields.tsx diff --git a/src/app/(auth)/invite/[token]/page.tsx b/src/app/(auth)/invite/[token]/page.tsx index b61ed69..ffd4c75 100644 --- a/src/app/(auth)/invite/[token]/page.tsx +++ b/src/app/(auth)/invite/[token]/page.tsx @@ -3,48 +3,216 @@ import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import Link from "next/link"; -import { ScanLine, Loader2, CheckCircle2, XCircle, LogIn } from "lucide-react"; +import { + ScanLine, + Loader2, + CheckCircle2, + XCircle, + LogIn, + LogOut, + Mail, + User as UserIcon, + Clock, + AlertCircle, +} from "lucide-react"; import { Button } from "@/components/ui/button"; -import { useSession } from "next-auth/react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useSession, signIn, signOut } from "next-auth/react"; import { toast } from "sonner"; +import { PasswordFields } from "@/components/auth/password-fields"; + +type VerifyResponse = + | { + valid: true; + email: string; + role: string; + orgName: string; + inviterName: string | null; + hasExistingAccount: boolean; + } + | { + valid: false; + reason: "not_found" | "expired" | "accepted"; + orgName?: string; + inviterName?: string; + }; + +type Stage = + | { kind: "loading" } + | { kind: "invalid"; reason: "not_found" | "expired" | "accepted"; orgName?: string; inviterName?: string } + | { kind: "accepted"; orgName: string } + | { + kind: "valid"; + email: string; + role: string; + orgName: string; + inviterName: string | null; + hasExistingAccount: boolean; + }; export default function InvitePage() { const params = useParams(); const { data: session, status: sessionStatus } = useSession(); const token = params.token as string; - const [status, setStatus] = useState<"loading" | "valid" | "invalid" | "accepted">("loading"); - const [invitation, setInvitation] = useState<{ - email: string; - role: string; - orgName: string; - hasExistingAccount: boolean; - } | null>(null); - const [accepting, setAccepting] = useState(false); - const [acceptedOrg, setAcceptedOrg] = useState(""); + const [stage, setStage] = useState({ kind: "loading" }); useEffect(() => { fetch(`/api/invitations/verify?token=${token}`) - .then((r) => r.json()) + .then((r) => r.json() as Promise) .then((data) => { if (data.valid) { - setInvitation({ + setStage({ + kind: "valid", email: data.email, role: data.role, orgName: data.orgName, + inviterName: data.inviterName, hasExistingAccount: data.hasExistingAccount, }); - setStatus("valid"); } else { - setStatus("invalid"); + setStage({ + kind: "invalid", + reason: data.reason, + orgName: data.orgName, + inviterName: data.inviterName, + }); } }) - .catch(() => setStatus("invalid")); + .catch(() => setStage({ kind: "invalid", reason: "not_found" })); }, [token]); - const isLoggedIn = sessionStatus === "authenticated" && session?.user; - const emailMatch = isLoggedIn && session.user.email?.toLowerCase() === invitation?.email.toLowerCase(); + if (stage.kind === "loading" || sessionStatus === "loading") { + return ( +
+ +

Verifying invitation...

+
+ ); + } - async function handleAcceptInvite() { + if (stage.kind === "accepted") { + return ( +
+ +

Welcome!

+

+ You've joined {stage.orgName}. Redirecting... +

+ +
+ ); + } + + if (stage.kind === "invalid") { + return ; + } + + const isLoggedIn = sessionStatus === "authenticated" && session?.user; + const emailMatch = + !!isLoggedIn && session.user.email?.toLowerCase() === stage.email.toLowerCase(); + + const onAccepted = (orgName: string) => { + setStage({ kind: "accepted", orgName }); + setTimeout(() => { + window.location.href = "/"; + }, 1200); + }; + + return ( +
+ + +
+ {isLoggedIn && emailMatch ? ( + + ) : isLoggedIn && !emailMatch ? ( + + ) : stage.hasExistingAccount ? ( + + ) : ( + + )} +
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Header */ +/* ------------------------------------------------------------------ */ + +function InviteHeader({ + orgName, + role, + inviterName, + email, +}: { + orgName: string; + role: string; + inviterName: string | null; + email: string; +}) { + return ( +
+
+ +
+

You're invited to {orgName}

+

+ {inviterName ? ( + <> + {inviterName} invited you to join{" "} + {orgName} as a {role}. + + ) : ( + <> + You've been invited to join {orgName} as a{" "} + {role}. + + )} +

+

+ + {email} +

+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Branches */ +/* ------------------------------------------------------------------ */ + +function AcceptBlock({ + token, + onAccepted, +}: { + token: string; + onAccepted: (orgName: string) => void; +}) { + const [accepting, setAccepting] = useState(false); + + async function handleAccept() { setAccepting(true); try { const res = await fetch("/api/invitations/accept", { @@ -58,50 +226,353 @@ export default function InvitePage() { setAccepting(false); return; } - setAcceptedOrg(data.orgName); - setStatus("accepted"); await fetch("/api/auth/session"); - setTimeout(() => { - window.location.href = "/"; - }, 1500); + onAccepted(data.orgName); } catch { toast.error("Failed to accept invitation"); setAccepting(false); } } - if (status === "loading" || sessionStatus === "loading") { + return ( + + ); +} + +function MismatchBlock({ + token, + currentEmail, + inviteEmail, +}: { + token: string; + currentEmail: string; + inviteEmail: string; +}) { + const [signingOut, setSigningOut] = useState(false); + return ( + <> +
+

+ + + You're signed in as {currentEmail}, but this invitation + was sent to {inviteEmail}. Sign out to continue as the + invited user. + +

+
+ + + ); +} + +function LoginBlock({ + token, + email, + orgName, + onAccepted, +}: { + token: string; + email: string; + orgName: string; + onAccepted: (orgName: string) => void; +}) { + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + + const result = await signIn("credentials", { + email, + password, + redirect: false, + }); + + if (result?.error) { + setError("Invalid password. Please try again."); + setLoading(false); + return; + } + + try { + const res = await fetch("/api/invitations/accept", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || "Failed to accept invitation"); + setLoading(false); + return; + } + onAccepted(data.orgName || orgName); + } catch { + setError("Something went wrong. Please try again."); + setLoading(false); + } + } + + return ( + <> +

+ An account already exists for this email. Enter your password to sign in and + join {orgName}. +

+ {error && ( +
+ {error} +
+ )} +
+ + + +

+ + Forgot your password? + +

+ + ); +} + +function SignupBlock({ + token, + email, + orgName, + onAccepted, +}: { + token: string; + email: string; + orgName: string; + onAccepted: (orgName: string) => void; +}) { + const [displayName, setDisplayName] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (password !== confirmPassword) { + setError("Passwords do not match"); + return; + } + if (password.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + + setLoading(true); + + try { + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + displayName, + email, + password, + inviteToken: token, + }), + }); + + if (!res.ok) { + const data = await res.json(); + setError(data.error || "Registration failed"); + setLoading(false); + return; + } + + const signInResult = await signIn("credentials", { + email, + password, + redirect: false, + }); + + if (signInResult?.error) { + setError("Account created, but sign-in failed. Please try signing in."); + setLoading(false); + return; + } + + onAccepted(orgName); + } catch { + setError("Something went wrong. Please try again."); + setLoading(false); + } + } + + return ( + <> + {error && ( +
+ {error} +
+ )} +
+
+ +
+ + setDisplayName(e.target.value)} + required + className="pl-10" + /> +
+
+ + + + + +

+ By creating an account, you agree to our{" "} + + Terms + {" "} + and{" "} + + Privacy Policy + + . +

+ + ); +} + +/* ------------------------------------------------------------------ */ +/* Invalid states (expired / accepted / not found) */ +/* ------------------------------------------------------------------ */ + +function InvalidInvite({ + reason, + orgName, + inviterName, +}: { + reason: "not_found" | "expired" | "accepted"; + orgName?: string; + inviterName?: string; +}) { + if (reason === "expired") { return ( -
- -

Verifying invitation...

+
+ +

Invitation expired

+

+ {orgName ? ( + <> + Your invitation to {orgName} has expired. + + ) : ( + <>This invitation has expired. + )}{" "} + {inviterName ? ( + <> + Ask {inviterName} to send you a new one. + + ) : ( + <>Ask the person who invited you to send a new one. + )} +

+ + +
); } - if (status === "accepted") { + if (reason === "accepted") { return (
-

Welcome!

+

Invitation already used

- You've joined {acceptedOrg}. Redirecting... -

- -
- ); - } - - if (status === "invalid") { - return ( -
- -

Invalid Invitation

-

- This invitation link is invalid, expired, or has already been used. + {orgName ? ( + <> + This invitation to {orgName} has already been accepted. + Sign in to access your workspace. + + ) : ( + <>This invitation has already been accepted. Sign in to continue. + )}

- +
); @@ -109,62 +580,17 @@ export default function InvitePage() { return (
-
- -
- -

You're Invited

+ +

Invalid invitation

- You've been invited to join {invitation?.orgName} as - a {invitation?.role}. + This invitation link is invalid or no longer exists. If you believe this is a + mistake, ask the person who invited you to send a new link.

-

- Invitation for: {invitation?.email} -

- -
- {isLoggedIn && emailMatch ? ( - - ) : isLoggedIn && !emailMatch ? ( - <> -

- You're signed in as {session?.user?.email}, but - this invitation is for {invitation?.email}. - Please sign out and sign in with the correct account. -

- - - - - ) : invitation?.hasExistingAccount ? ( - <> - - - -

- An account with this email already exists. Sign in to accept the invitation. -

- - ) : ( - - - - )} -
+ + +
); } diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 93f8f14..f6b7716 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -4,10 +4,11 @@ import { Suspense, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; import { signIn } from "next-auth/react"; import Link from "next/link"; -import { ScanLine, Mail, Lock, User, Loader2, Eye, EyeOff } from "lucide-react"; +import { ScanLine, Mail, User, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { PasswordFields } from "@/components/auth/password-fields"; export default function SignupPage() { return ( @@ -17,19 +18,6 @@ export default function SignupPage() { ); } -function getPasswordStrength(pw: string): { level: number; label: string } { - if (!pw || pw.length < 8) return { level: 0, label: "Weak" }; - const hasUpper = /[A-Z]/.test(pw); - const hasLower = /[a-z]/.test(pw); - const hasNumber = /\d/.test(pw); - if (pw.length >= 12 && hasUpper && hasLower && hasNumber) { - return { level: 2, label: "Strong" }; - } - return { level: 1, label: "Fair" }; -} - -const strengthColors = ["bg-red-500", "bg-amber-500", "bg-emerald-500"]; - function SignupForm() { const searchParams = useSearchParams(); const token = searchParams.get("token") || ""; @@ -43,8 +31,6 @@ function SignupForm() { }); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - const [showPassword, setShowPassword] = useState(false); - const [showConfirm, setShowConfirm] = useState(false); const [inviteInfo, setInviteInfo] = useState<{ orgName: string; email: string; @@ -114,9 +100,6 @@ function SignupForm() { } } - const strength = getPasswordStrength(formData.password); - const showStrength = formData.password.length > 0; - return (
@@ -174,74 +157,12 @@ function SignupForm() {
-
- -
- - update("password", e.target.value)} - required - minLength={8} - autoComplete="new-password" - className="pl-10 pr-10" - /> - -
- {showStrength && ( -
-
- {[0, 1, 2].map((i) => ( -
- ))} -
- {strength.label} -
- )} -
- -
- -
- - update("confirmPassword", e.target.value)} - required - minLength={8} - autoComplete="new-password" - className="pl-10 pr-10" - /> - -
-
+ update("password", v)} + onConfirmChange={(v) => update("confirmPassword", v)} + /> +
+ {showStrength && ( +
+
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ {strength.label} +
+ )} +
+ + {showConfirmField && ( +
+ +
+ + onConfirmChange?.(e.target.value)} + required={required} + minLength={8} + autoComplete="new-password" + disabled={disabled} + className="pl-10 pr-10" + /> + +
+
+ )} + + ); +}