feat(01-02): build branded login page with password toggle and error handling
- Split-screen layout: agent photo left (lg+), login card right - loginAction server action: signIn credentials, redirects on AuthError - PasswordField client component: show/hide toggle with eye/eye-off SVG - Signed-out confirmation banner on ?signed_out=1 - Error banner on ?error=invalid with generic message - Brand colors: gold #C9A84C accent, navy #1B2B4B text, off-white #FAF9F7 bg - Copied /Users/ccopeland/Downloads/red.jpg to public/red.jpg
This commit is contained in:
BIN
teressa-copeland-homes/public/red.jpg
Normal file
BIN
teressa-copeland-homes/public/red.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
69
teressa-copeland-homes/src/app/agent/login/PasswordField.tsx
Normal file
69
teressa-copeland-homes/src/app/agent/login/PasswordField.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function PasswordField() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: "#1B2B4B" }}
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="block w-full rounded-lg border border-gray-300 px-3.5 py-2.5 pr-11 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:border-transparent transition"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
// Eye-off icon (password visible — click to hide)
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
) : (
|
||||
// Eye icon (password hidden — click to show)
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
teressa-copeland-homes/src/app/agent/login/page.tsx
Normal file
162
teressa-copeland-homes/src/app/agent/login/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthError } from "next-auth";
|
||||
import { signIn } from "@/lib/auth";
|
||||
import { PasswordField } from "./PasswordField";
|
||||
|
||||
async function loginAction(formData: FormData) {
|
||||
"use server";
|
||||
try {
|
||||
await signIn("credentials", {
|
||||
email: formData.get("email") as string,
|
||||
password: formData.get("password") as string,
|
||||
redirectTo: "/agent/dashboard",
|
||||
});
|
||||
} catch (error) {
|
||||
// CRITICAL: Only catch AuthError. Everything else (including NEXT_REDIRECT) must re-throw.
|
||||
if (error instanceof AuthError) {
|
||||
redirect("/agent/login?error=invalid");
|
||||
}
|
||||
throw error; // Re-throws NEXT_REDIRECT — allows the redirect to fire
|
||||
}
|
||||
}
|
||||
|
||||
interface LoginPageProps {
|
||||
searchParams: Promise<{ error?: string; signed_out?: string }>;
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const params = await searchParams;
|
||||
const hasError = params.error === "invalid";
|
||||
const signedOut = params.signed_out === "1";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left side: branded photo panel — hidden on mobile */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative">
|
||||
<Image
|
||||
src="/red.jpg"
|
||||
alt="Teressa Copeland"
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
{/* Dark gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#1B2B4B]/80 via-[#1B2B4B]/30 to-transparent" />
|
||||
{/* Brand name text */}
|
||||
<div className="absolute bottom-10 left-8 right-8">
|
||||
<p className="text-[#C9A84C] text-sm font-semibold tracking-widest uppercase mb-2">
|
||||
Agent Portal
|
||||
</p>
|
||||
<h2 className="text-white text-3xl font-bold leading-tight">
|
||||
Teressa Copeland Homes
|
||||
</h2>
|
||||
<p className="text-white/70 text-sm mt-2">
|
||||
Utah real estate expertise
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side: login form */}
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center px-6 py-12"
|
||||
style={{ backgroundColor: "#FAF9F7" }}
|
||||
>
|
||||
<div className="w-full max-w-md">
|
||||
{/* Mobile brand header */}
|
||||
<div className="lg:hidden text-center mb-8">
|
||||
<p
|
||||
className="text-sm font-semibold tracking-widest uppercase mb-1"
|
||||
style={{ color: "#C9A84C" }}
|
||||
>
|
||||
Teressa Copeland Homes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-white rounded-2xl shadow-lg px-8 py-10">
|
||||
<h1
|
||||
className="text-2xl font-bold mb-6"
|
||||
style={{ color: "#1B2B4B" }}
|
||||
>
|
||||
Agent Portal
|
||||
</h1>
|
||||
|
||||
{/* Signed-out confirmation banner */}
|
||||
{signedOut && (
|
||||
<div
|
||||
className="mb-5 rounded-lg px-4 py-3 text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: "#F0FDF4",
|
||||
color: "#166534",
|
||||
border: "1px solid #BBF7D0",
|
||||
}}
|
||||
role="alert"
|
||||
>
|
||||
You've been signed out.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error banner */}
|
||||
{hasError && (
|
||||
<div
|
||||
className="mb-5 rounded-lg px-4 py-3 text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: "#FEF2F2",
|
||||
color: "#991B1B",
|
||||
border: "1px solid #FECACA",
|
||||
}}
|
||||
role="alert"
|
||||
>
|
||||
Invalid email or password.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={loginAction} className="space-y-5">
|
||||
{/* Email field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: "#1B2B4B" }}
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="block w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:border-transparent transition"
|
||||
style={
|
||||
{
|
||||
"--tw-ring-color": "#C9A84C",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password field — client component for show/hide toggle */}
|
||||
<PasswordField />
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-lg px-4 py-2.5 text-sm font-semibold text-white transition hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||
style={{
|
||||
backgroundColor: "#C9A84C",
|
||||
// @ts-expect-error CSS custom property for focus ring
|
||||
"--tw-ring-color": "#C9A84C",
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user