Polish login and signup UX: fix stale copy, add password toggles, strength meter, invite context, terms footer

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-17 09:56:06 -05:00
parent 2664f78b00
commit 70134b4967
2 changed files with 100 additions and 17 deletions

View file

@ -2,7 +2,7 @@
import { Suspense, useState } from "react"; import { Suspense, useState } from "react";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { ScanLine, Mail, Lock, Loader2 } from "lucide-react"; import { ScanLine, Mail, Lock, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -19,7 +19,6 @@ export default function LoginPage() {
} }
function LoginForm() { function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/"; const callbackUrl = searchParams.get("callbackUrl") || "/";
const error = searchParams.get("error"); const error = searchParams.get("error");
@ -47,8 +46,7 @@ function LoginForm() {
setFormError("Invalid email or password"); setFormError("Invalid email or password");
setLoading(false); setLoading(false);
} else { } else {
router.push(callbackUrl); window.location.href = callbackUrl;
router.refresh();
} }
} }
@ -60,7 +58,7 @@ function LoginForm() {
</div> </div>
<h1 className="text-2xl font-bold tracking-tight">Welcome back</h1> <h1 className="text-2xl font-bold tracking-tight">Welcome back</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Sign in to your Echo account Digitize your connect cards
</p> </p>
</div> </div>
@ -139,9 +137,15 @@ function LoginForm() {
<p className="mt-6 text-center text-xs text-muted-foreground"> <p className="mt-6 text-center text-xs text-muted-foreground">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link href="/signup" className="font-medium text-primary hover:underline"> <Link href="/signup" className="font-medium text-primary hover:underline">
Sign up with an invite Sign up
</Link> </Link>
</p> </p>
<p className="mt-3 text-center text-[10px] text-muted-foreground/60">
By signing in, 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>
</div> </div>
); );
} }

View file

@ -1,10 +1,10 @@
"use client"; "use client";
import { Suspense, useState } from "react"; 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 } from "lucide-react"; import { ScanLine, Mail, Lock, User, Loader2, Eye, EyeOff } 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";
@ -17,9 +17,23 @@ 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") || "";
const callbackUrl = searchParams.get("callbackUrl") || "/";
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
displayName: "", displayName: "",
@ -29,6 +43,26 @@ 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<{
orgName: string;
email: string;
role: string;
} | null>(null);
useEffect(() => {
if (!token) return;
fetch(`/api/invitations/verify?token=${token}`)
.then((r) => r.json())
.then((data) => {
if (data.valid) {
setInviteInfo({ orgName: data.orgName, email: data.email, role: data.role });
setFormData((prev) => ({ ...prev, email: data.email }));
}
})
.catch(() => {});
}, [token]);
function update(field: string, value: string) { function update(field: string, value: string) {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
@ -72,7 +106,7 @@ function SignupForm() {
await signIn("credentials", { await signIn("credentials", {
email: formData.email, email: formData.email,
password: formData.password, password: formData.password,
callbackUrl: "/", callbackUrl,
}); });
} catch { } catch {
setError("Something went wrong. Please try again."); setError("Something went wrong. Please try again.");
@ -80,6 +114,9 @@ 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">
@ -88,9 +125,11 @@ function SignupForm() {
</div> </div>
<h1 className="text-2xl font-bold tracking-tight">Create account</h1> <h1 className="text-2xl font-bold tracking-tight">Create account</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{token {inviteInfo
? "Complete your account setup" ? <>Join <strong>{inviteInfo.orgName}</strong></>
: "Get started with Echo"} : token
? "Complete your account setup"
: "Get started with Echo"}
</p> </p>
</div> </div>
@ -129,7 +168,8 @@ function SignupForm() {
onChange={(e) => update("email", e.target.value)} onChange={(e) => update("email", e.target.value)}
required required
autoComplete="email" autoComplete="email"
className="pl-10" readOnly={!!inviteInfo}
className={`pl-10 ${inviteInfo ? "bg-muted/50 cursor-not-allowed" : ""}`}
/> />
</div> </div>
</div> </div>
@ -140,16 +180,40 @@ function SignupForm() {
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> <Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
id="password" id="password"
type="password" type={showPassword ? "text" : "password"}
placeholder="Min 8 characters" placeholder="Min 8 characters"
value={formData.password} value={formData.password}
onChange={(e) => update("password", e.target.value)} onChange={(e) => update("password", e.target.value)}
required required
minLength={8} minLength={8}
autoComplete="new-password" autoComplete="new-password"
className="pl-10" 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> </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>
<div className="space-y-2"> <div className="space-y-2">
@ -158,15 +222,24 @@ function SignupForm() {
<Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> <Lock className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
id="confirmPassword" id="confirmPassword"
type="password" type={showConfirm ? "text" : "password"}
placeholder="Confirm your password" placeholder="Confirm your password"
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={(e) => update("confirmPassword", e.target.value)} onChange={(e) => update("confirmPassword", e.target.value)}
required required
minLength={8} minLength={8}
autoComplete="new-password" autoComplete="new-password"
className="pl-10" 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>
</div> </div>
@ -182,6 +255,12 @@ function SignupForm() {
Sign in Sign in
</Link> </Link>
</p> </p>
<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>
</div> </div>
); );
} }