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 <PasswordFields> 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
This commit is contained in:
parent
47da94fc95
commit
f2c7d13a3e
3 changed files with 673 additions and 186 deletions
|
|
@ -3,48 +3,216 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
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 { 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 { 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() {
|
export default function InvitePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { data: session, status: sessionStatus } = useSession();
|
const { data: session, status: sessionStatus } = useSession();
|
||||||
const token = params.token as string;
|
const token = params.token as string;
|
||||||
const [status, setStatus] = useState<"loading" | "valid" | "invalid" | "accepted">("loading");
|
const [stage, setStage] = useState<Stage>({ kind: "loading" });
|
||||||
const [invitation, setInvitation] = useState<{
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
orgName: string;
|
|
||||||
hasExistingAccount: boolean;
|
|
||||||
} | null>(null);
|
|
||||||
const [accepting, setAccepting] = useState(false);
|
|
||||||
const [acceptedOrg, setAcceptedOrg] = useState<string>("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/invitations/verify?token=${token}`)
|
fetch(`/api/invitations/verify?token=${token}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json() as Promise<VerifyResponse>)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.valid) {
|
if (data.valid) {
|
||||||
setInvitation({
|
setStage({
|
||||||
|
kind: "valid",
|
||||||
email: data.email,
|
email: data.email,
|
||||||
role: data.role,
|
role: data.role,
|
||||||
orgName: data.orgName,
|
orgName: data.orgName,
|
||||||
|
inviterName: data.inviterName,
|
||||||
hasExistingAccount: data.hasExistingAccount,
|
hasExistingAccount: data.hasExistingAccount,
|
||||||
});
|
});
|
||||||
setStatus("valid");
|
|
||||||
} else {
|
} 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]);
|
}, [token]);
|
||||||
|
|
||||||
const isLoggedIn = sessionStatus === "authenticated" && session?.user;
|
if (stage.kind === "loading" || sessionStatus === "loading") {
|
||||||
const emailMatch = isLoggedIn && session.user.email?.toLowerCase() === invitation?.email.toLowerCase();
|
return (
|
||||||
|
<div className="glass-card mx-auto flex w-full max-w-md flex-col items-center rounded-2xl p-8">
|
||||||
|
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">Verifying invitation...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAcceptInvite() {
|
if (stage.kind === "accepted") {
|
||||||
|
return (
|
||||||
|
<div className="glass-card mx-auto flex w-full max-w-md flex-col items-center rounded-2xl p-8 text-center">
|
||||||
|
<CheckCircle2 className="mb-4 size-12 text-emerald-500" />
|
||||||
|
<h1 className="text-xl font-bold">Welcome!</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
You've joined <strong>{stage.orgName}</strong>. Redirecting...
|
||||||
|
</p>
|
||||||
|
<Loader2 className="mt-4 size-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage.kind === "invalid") {
|
||||||
|
return <InvalidInvite reason={stage.reason} orgName={stage.orgName} inviterName={stage.inviterName} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="glass-card mx-auto flex w-full max-w-md flex-col rounded-2xl p-8">
|
||||||
|
<InviteHeader
|
||||||
|
orgName={stage.orgName}
|
||||||
|
role={stage.role}
|
||||||
|
inviterName={stage.inviterName}
|
||||||
|
email={stage.email}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-6 flex w-full flex-col gap-3">
|
||||||
|
{isLoggedIn && emailMatch ? (
|
||||||
|
<AcceptBlock token={token} onAccepted={onAccepted} />
|
||||||
|
) : isLoggedIn && !emailMatch ? (
|
||||||
|
<MismatchBlock
|
||||||
|
token={token}
|
||||||
|
currentEmail={session?.user?.email || ""}
|
||||||
|
inviteEmail={stage.email}
|
||||||
|
/>
|
||||||
|
) : stage.hasExistingAccount ? (
|
||||||
|
<LoginBlock
|
||||||
|
token={token}
|
||||||
|
email={stage.email}
|
||||||
|
orgName={stage.orgName}
|
||||||
|
onAccepted={onAccepted}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SignupBlock
|
||||||
|
token={token}
|
||||||
|
email={stage.email}
|
||||||
|
orgName={stage.orgName}
|
||||||
|
onAccepted={onAccepted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Header */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function InviteHeader({
|
||||||
|
orgName,
|
||||||
|
role,
|
||||||
|
inviterName,
|
||||||
|
email,
|
||||||
|
}: {
|
||||||
|
orgName: string;
|
||||||
|
role: string;
|
||||||
|
inviterName: string | null;
|
||||||
|
email: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg">
|
||||||
|
<ScanLine className="size-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold">You're invited to {orgName}</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{inviterName ? (
|
||||||
|
<>
|
||||||
|
<strong>{inviterName}</strong> invited you to join{" "}
|
||||||
|
<strong>{orgName}</strong> as a <strong>{role}</strong>.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
You've been invited to join <strong>{orgName}</strong> as a{" "}
|
||||||
|
<strong>{role}</strong>.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 inline-flex items-center gap-1.5 rounded-full bg-muted/60 px-3 py-1 text-xs text-muted-foreground">
|
||||||
|
<Mail className="size-3" />
|
||||||
|
{email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Branches */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function AcceptBlock({
|
||||||
|
token,
|
||||||
|
onAccepted,
|
||||||
|
}: {
|
||||||
|
token: string;
|
||||||
|
onAccepted: (orgName: string) => void;
|
||||||
|
}) {
|
||||||
|
const [accepting, setAccepting] = useState(false);
|
||||||
|
|
||||||
|
async function handleAccept() {
|
||||||
setAccepting(true);
|
setAccepting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/invitations/accept", {
|
const res = await fetch("/api/invitations/accept", {
|
||||||
|
|
@ -58,113 +226,371 @@ export default function InvitePage() {
|
||||||
setAccepting(false);
|
setAccepting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAcceptedOrg(data.orgName);
|
|
||||||
setStatus("accepted");
|
|
||||||
await fetch("/api/auth/session");
|
await fetch("/api/auth/session");
|
||||||
setTimeout(() => {
|
onAccepted(data.orgName);
|
||||||
window.location.href = "/";
|
|
||||||
}, 1500);
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to accept invitation");
|
toast.error("Failed to accept invitation");
|
||||||
setAccepting(false);
|
setAccepting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "loading" || sessionStatus === "loading") {
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card mx-auto flex w-full max-w-md flex-col items-center rounded-2xl p-8">
|
|
||||||
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
|
||||||
<p className="mt-4 text-sm text-muted-foreground">Verifying invitation...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "accepted") {
|
|
||||||
return (
|
|
||||||
<div className="glass-card mx-auto flex w-full max-w-md flex-col items-center rounded-2xl p-8 text-center">
|
|
||||||
<CheckCircle2 className="mb-4 size-12 text-emerald-500" />
|
|
||||||
<h1 className="text-xl font-bold">Welcome!</h1>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
You've joined <strong>{acceptedOrg}</strong>. Redirecting...
|
|
||||||
</p>
|
|
||||||
<Loader2 className="mt-4 size-5 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "invalid") {
|
|
||||||
return (
|
|
||||||
<div className="glass-card mx-auto flex w-full max-w-md flex-col items-center rounded-2xl p-8 text-center">
|
|
||||||
<XCircle className="mb-4 size-12 text-destructive" />
|
|
||||||
<h1 className="text-xl font-bold">Invalid Invitation</h1>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
This invitation link is invalid, expired, or has already been used.
|
|
||||||
</p>
|
|
||||||
<Link href="/login">
|
|
||||||
<Button variant="outline" className="mt-6 rounded-xl">Go to Login</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="glass-card mx-auto flex w-full max-w-md flex-col items-center rounded-2xl p-8 text-center">
|
|
||||||
<div className="mb-4 flex size-14 items-center justify-center rounded-2xl gradient-banner shadow-lg">
|
|
||||||
<ScanLine className="size-7 text-white" />
|
|
||||||
</div>
|
|
||||||
<CheckCircle2 className="mb-2 size-8 text-emerald-500" />
|
|
||||||
<h1 className="text-xl font-bold">You're Invited</h1>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
You've been invited to join <strong>{invitation?.orgName}</strong> as
|
|
||||||
a <strong>{invitation?.role}</strong>.
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
Invitation for: {invitation?.email}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-6 flex w-full flex-col gap-3">
|
|
||||||
{isLoggedIn && emailMatch ? (
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full rounded-xl"
|
className="w-full rounded-xl"
|
||||||
onClick={handleAcceptInvite}
|
onClick={handleAccept}
|
||||||
disabled={accepting}
|
disabled={accepting}
|
||||||
>
|
>
|
||||||
{accepting ? (
|
{accepting ? (
|
||||||
<><Loader2 className="mr-2 size-4 animate-spin" /> Joining...</>
|
<>
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" /> Joining...
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Accept Invitation"
|
"Accept Invitation"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : isLoggedIn && !emailMatch ? (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MismatchBlock({
|
||||||
|
token,
|
||||||
|
currentEmail,
|
||||||
|
inviteEmail,
|
||||||
|
}: {
|
||||||
|
token: string;
|
||||||
|
currentEmail: string;
|
||||||
|
inviteEmail: string;
|
||||||
|
}) {
|
||||||
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-left text-xs text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
You're signed in as <strong>{session?.user?.email}</strong>, but
|
<p className="flex items-start gap-2">
|
||||||
this invitation is for <strong>{invitation?.email}</strong>.
|
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||||
Please sign out and sign in with the correct account.
|
<span>
|
||||||
|
You're signed in as <strong>{currentEmail}</strong>, but this invitation
|
||||||
|
was sent to <strong>{inviteEmail}</strong>. Sign out to continue as the
|
||||||
|
invited user.
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<Link href="/login">
|
</div>
|
||||||
<Button variant="outline" className="w-full rounded-xl">
|
<Button
|
||||||
<LogIn className="mr-2 size-4" /> Switch Account
|
variant="outline"
|
||||||
|
className="w-full rounded-xl"
|
||||||
|
disabled={signingOut}
|
||||||
|
onClick={() => {
|
||||||
|
setSigningOut(true);
|
||||||
|
signOut({ callbackUrl: `/invite/${token}` });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{signingOut ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LogOut className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
Sign out and continue
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
|
||||||
</>
|
</>
|
||||||
) : invitation?.hasExistingAccount ? (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
<>
|
<>
|
||||||
<Link href={`/login?callbackUrl=/invite/${token}`}>
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
<Button className="w-full rounded-xl">
|
An account already exists for this email. Enter your password to sign in and
|
||||||
<LogIn className="mr-2 size-4" /> Sign In to Accept
|
join <strong>{orgName}</strong>.
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
An account with this email already exists. Sign in to accept the invitation.
|
|
||||||
</p>
|
</p>
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-destructive/10 p-3 text-center text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<PasswordFields
|
||||||
|
password={password}
|
||||||
|
onPasswordChange={setPassword}
|
||||||
|
passwordLabel="Password"
|
||||||
|
passwordPlaceholder="Enter your password"
|
||||||
|
passwordId="invite-login-password"
|
||||||
|
showStrengthMeter={false}
|
||||||
|
showConfirmField={false}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="w-full rounded-xl" disabled={loading || !password}>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LogIn className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
Sign in & Join
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
<Link
|
||||||
|
href={`/forgot-password?email=${encodeURIComponent(email)}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && (
|
||||||
|
<div className="rounded-lg bg-destructive/10 p-3 text-center text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-signup-name">Full name</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<UserIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="invite-signup-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="John Smith"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PasswordFields
|
||||||
|
password={password}
|
||||||
|
confirmPassword={confirmPassword}
|
||||||
|
onPasswordChange={setPassword}
|
||||||
|
onConfirmChange={setConfirmPassword}
|
||||||
|
passwordId="invite-signup-password"
|
||||||
|
confirmId="invite-signup-confirm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full rounded-xl" disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||||
|
Create account & Join {orgName}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="mt-3 text-center text-[10px] text-muted-foreground/60">
|
||||||
|
By creating an account, you agree to our{" "}
|
||||||
|
<Link href="/terms" className="underline hover:text-muted-foreground">
|
||||||
|
Terms
|
||||||
|
</Link>{" "}
|
||||||
|
and{" "}
|
||||||
|
<Link href="/privacy" className="underline hover:text-muted-foreground">
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Invalid states (expired / accepted / not found) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function InvalidInvite({
|
||||||
|
reason,
|
||||||
|
orgName,
|
||||||
|
inviterName,
|
||||||
|
}: {
|
||||||
|
reason: "not_found" | "expired" | "accepted";
|
||||||
|
orgName?: string;
|
||||||
|
inviterName?: string;
|
||||||
|
}) {
|
||||||
|
if (reason === "expired") {
|
||||||
|
return (
|
||||||
|
<div className="glass-card mx-auto flex w-full max-w-md flex-col items-center rounded-2xl p-8 text-center">
|
||||||
|
<Clock className="mb-4 size-12 text-amber-500" />
|
||||||
|
<h1 className="text-xl font-bold">Invitation expired</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{orgName ? (
|
||||||
|
<>
|
||||||
|
Your invitation to <strong>{orgName}</strong> has expired.
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Link href={`/signup?token=${token}`}>
|
<>This invitation has expired.</>
|
||||||
<Button className="w-full rounded-xl">Accept & Create Account</Button>
|
)}{" "}
|
||||||
</Link>
|
{inviterName ? (
|
||||||
|
<>
|
||||||
|
Ask <strong>{inviterName}</strong> to send you a new one.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Ask the person who invited you to send a new one.</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</p>
|
||||||
|
<Link href="/login">
|
||||||
|
<Button variant="outline" className="mt-6 rounded-xl">
|
||||||
|
Go to Login
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason === "accepted") {
|
||||||
|
return (
|
||||||
|
<div className="glass-card mx-auto flex w-full max-w-md flex-col items-center rounded-2xl p-8 text-center">
|
||||||
|
<CheckCircle2 className="mb-4 size-12 text-emerald-500" />
|
||||||
|
<h1 className="text-xl font-bold">Invitation already used</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{orgName ? (
|
||||||
|
<>
|
||||||
|
This invitation to <strong>{orgName}</strong> has already been accepted.
|
||||||
|
Sign in to access your workspace.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>This invitation has already been accepted. Sign in to continue.</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Link href="/login">
|
||||||
|
<Button className="mt-6 rounded-xl">
|
||||||
|
<LogIn className="mr-2 size-4" /> Go to Login
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card mx-auto flex w-full max-w-md flex-col items-center rounded-2xl p-8 text-center">
|
||||||
|
<XCircle className="mb-4 size-12 text-destructive" />
|
||||||
|
<h1 className="text-xl font-bold">Invalid invitation</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<Link href="/login">
|
||||||
|
<Button variant="outline" className="mt-6 rounded-xl">
|
||||||
|
Go to Login
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ import { Suspense, useEffect, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import Link from "next/link";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { PasswordFields } from "@/components/auth/password-fields";
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
return (
|
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() {
|
function SignupForm() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const token = searchParams.get("token") || "";
|
const token = searchParams.get("token") || "";
|
||||||
|
|
@ -43,8 +31,6 @@ function SignupForm() {
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
|
||||||
const [inviteInfo, setInviteInfo] = useState<{
|
const [inviteInfo, setInviteInfo] = useState<{
|
||||||
orgName: string;
|
orgName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -114,9 +100,6 @@ function SignupForm() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const strength = getPasswordStrength(formData.password);
|
|
||||||
const showStrength = formData.password.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card mx-auto w-full max-w-md rounded-2xl p-8">
|
<div className="glass-card mx-auto w-full max-w-md rounded-2xl p-8">
|
||||||
<div className="mb-8 flex flex-col items-center">
|
<div className="mb-8 flex flex-col items-center">
|
||||||
|
|
@ -174,74 +157,12 @@ function SignupForm() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<PasswordFields
|
||||||
<Label htmlFor="password">Password</Label>
|
password={formData.password}
|
||||||
<div className="relative">
|
confirmPassword={formData.confirmPassword}
|
||||||
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
onPasswordChange={(v) => update("password", v)}
|
||||||
<Input
|
onConfirmChange={(v) => update("confirmPassword", v)}
|
||||||
id="password"
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder="Min 8 characters"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={(e) => update("password", e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
autoComplete="new-password"
|
|
||||||
className="pl-10 pr-10"
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
onClick={() => setShowPassword((v) => !v)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showStrength && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex flex-1 gap-1">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`h-1 flex-1 rounded-full transition-colors ${
|
|
||||||
i <= strength.level ? strengthColors[strength.level] : "bg-muted"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] text-muted-foreground">{strength.label}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
id="confirmPassword"
|
|
||||||
type={showConfirm ? "text" : "password"}
|
|
||||||
placeholder="Confirm your password"
|
|
||||||
value={formData.confirmPassword}
|
|
||||||
onChange={(e) => update("confirmPassword", e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
autoComplete="new-password"
|
|
||||||
className="pl-10 pr-10"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
onClick={() => setShowConfirm((v) => !v)}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
aria-label={showConfirm ? "Hide password" : "Show password"}
|
|
||||||
>
|
|
||||||
{showConfirm ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full rounded-xl" disabled={loading}>
|
<Button type="submit" className="w-full rounded-xl" disabled={loading}>
|
||||||
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||||
|
|
|
||||||
140
src/components/auth/password-fields.tsx
Normal file
140
src/components/auth/password-fields.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Eye, EyeOff, Lock } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
type PasswordStrength = { level: 0 | 1 | 2; label: "Weak" | "Fair" | "Strong" };
|
||||||
|
|
||||||
|
export function getPasswordStrength(pw: string): PasswordStrength {
|
||||||
|
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"];
|
||||||
|
|
||||||
|
type PasswordFieldsProps = {
|
||||||
|
password: string;
|
||||||
|
onPasswordChange: (value: string) => void;
|
||||||
|
confirmPassword?: string;
|
||||||
|
onConfirmChange?: (value: string) => void;
|
||||||
|
passwordLabel?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
passwordPlaceholder?: string;
|
||||||
|
confirmPlaceholder?: string;
|
||||||
|
passwordId?: string;
|
||||||
|
confirmId?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
showStrengthMeter?: boolean;
|
||||||
|
showConfirmField?: boolean;
|
||||||
|
autoComplete?: "new-password" | "current-password";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PasswordFields({
|
||||||
|
password,
|
||||||
|
onPasswordChange,
|
||||||
|
confirmPassword = "",
|
||||||
|
onConfirmChange,
|
||||||
|
passwordLabel = "Password",
|
||||||
|
confirmLabel = "Confirm Password",
|
||||||
|
passwordPlaceholder = "Min 8 characters",
|
||||||
|
confirmPlaceholder = "Confirm your password",
|
||||||
|
passwordId = "password",
|
||||||
|
confirmId = "confirmPassword",
|
||||||
|
disabled = false,
|
||||||
|
required = true,
|
||||||
|
showStrengthMeter = true,
|
||||||
|
showConfirmField = true,
|
||||||
|
autoComplete = "new-password",
|
||||||
|
}: PasswordFieldsProps) {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
const strength = getPasswordStrength(password);
|
||||||
|
const showStrength = showStrengthMeter && password.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={passwordId}>{passwordLabel}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id={passwordId}
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder={passwordPlaceholder}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => onPasswordChange(e.target.value)}
|
||||||
|
required={required}
|
||||||
|
minLength={8}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
disabled={disabled}
|
||||||
|
className="pl-10 pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showStrength && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex flex-1 gap-1">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1 flex-1 rounded-full transition-colors ${
|
||||||
|
i <= strength.level ? strengthColors[strength.level] : "bg-muted"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{strength.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showConfirmField && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={confirmId}>{confirmLabel}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id={confirmId}
|
||||||
|
type={showConfirm ? "text" : "password"}
|
||||||
|
placeholder={confirmPlaceholder}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => onConfirmChange?.(e.target.value)}
|
||||||
|
required={required}
|
||||||
|
minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={disabled}
|
||||||
|
className="pl-10 pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => setShowConfirm((v) => !v)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={showConfirm ? "Hide password" : "Show password"}
|
||||||
|
>
|
||||||
|
{showConfirm ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue