Files
vibn-agent-runner/vibn-frontend/app/components/auth/AuthScreen.tsx

357 lines
10 KiB
TypeScript

"use client";
import { useState } from "react";
import Link from "next/link";
import { signIn } from "next-auth/react";
/**
* Vibn sign-in / sign-up screen.
*
* Ported from design-templates/VIBN (2) (auth.css + signin/signup.jsx). Email +
* password is the primary action (custom endpoints at /api/auth/register and
* /api/auth/login that create real NextAuth database sessions); Google OAuth is
* offered below. Mode is driven by the `?new=1` query param.
*/
export default function AuthScreen({
mode = "signin",
}: {
mode?: "signin" | "signup";
}) {
const isSignup = mode === "signup";
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const emailValid = /\S+@\S+\.\S+/.test(email);
const canSubmit = isSignup
? emailValid &&
password.length >= 8 &&
name.trim().length > 0 &&
!submitting
: emailValid && password.length >= 8 && !submitting;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
setError(null);
setSubmitting(true);
try {
const endpoint = isSignup ? "/api/auth/register" : "/api/auth/login";
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
isSignup
? { email, password, name: name.trim() || undefined }
: { email, password },
),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error || "Something went wrong. Please try again.");
setSubmitting(false);
return;
}
// Full reload so the session cookie is picked up and AuthFlow routes the
// user onward (new account -> /onboarding, returning -> dashboard).
window.location.href = "/signin";
} catch {
setError("Network error. Please try again.");
setSubmitting(false);
}
};
const handleGoogle = () => {
if (googleLoading) return;
setGoogleLoading(true);
const cb = typeof window !== "undefined" ? window.location.href : "/signin";
signIn("google", { callbackUrl: cb });
};
return (
<div className="vibn-auth">
<div className="page">
<header className="topbar">
<Link href="/" className="logo" aria-label="Vibn home">
<span className="logo-mark">
<svg
viewBox="0 0 36 32"
width="74%"
height="74%"
fill="currentColor"
stroke="currentColor"
strokeWidth={1.2}
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 5 L10 5 L12 18 L14 5 L20 5 L12 27 Z" />
<rect
x="22.5"
y="23"
width="9.5"
height="3.8"
rx="0.7"
className="logo-caret"
/>
</svg>
</span>
<span>vibn</span>
</Link>
<Link href="/" className="topbar-back">
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M13 8H3M7 4 3 8l4 4"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Back to home
</Link>
</header>
<main className="auth-main">
<Glows />
<div className="auth-card">
<div className="auth-eye">
{isSignup ? "Get started" : "Welcome back"}
</div>
<h1 className="auth-title">
{isSignup ? (
<>
Create your <em>account</em>.
</>
) : (
<>
Sign in and <em>keep building</em>.
</>
)}
</h1>
<p className="auth-sub">
{isSignup ? null : "Pick up right where you left off."}
</p>
<form className="auth-form" onSubmit={handleSubmit} noValidate>
{isSignup && (
<div className="auth-field">
<label className="auth-label" htmlFor="name">
Name
</label>
<input
id="name"
type="text"
autoComplete="name"
required
className="auth-input"
placeholder="First name or handle"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
)}
<div className="auth-field">
<label className="auth-label" htmlFor="email">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
autoFocus={!isSignup}
className="auth-input"
placeholder="you@somewhere.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="password">
Password
</label>
<input
id="password"
type="password"
autoComplete={isSignup ? "new-password" : "current-password"}
required
minLength={8}
className="auth-input"
placeholder={
isSignup ? "At least 8 characters" : "Your password"
}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <div className="auth-error">{error}</div>}
<button
type="submit"
className="auth-btn auth-btn-primary"
disabled={!canSubmit}
style={{ marginTop: 4 }}
>
{submitting ? (
<>
<span className="auth-spinner" />{" "}
{isSignup ? "Creating your account…" : "Signing in…"}
</>
) : isSignup ? (
<>
Create my account <Arrow size={13} />
</>
) : (
<>Sign in</>
)}
</button>
</form>
<div className="auth-divider">or continue with</div>
<div className="auth-oauth">
<button
type="button"
className="auth-btn auth-btn-ghost"
onClick={handleGoogle}
disabled={googleLoading}
>
{googleLoading ? (
<>
<span className="auth-spinner ghost" /> Connecting
</>
) : (
<>
<GoogleIcon />{" "}
{isSignup ? "Sign up with Google" : "Continue with Google"}
</>
)}
</button>
</div>
{isSignup && (
<p className="auth-fine">
By continuing you agree to our{" "}
<Link href="/mission">Terms</Link> and{" "}
<Link href="/mission">Privacy Policy</Link>.
</p>
)}
<div className="auth-foot">
{isSignup ? (
<>
Already have an account? <Link href="/signin">Sign in </Link>
</>
) : (
<>
New to Vibn? <Link href="/signup">Create an account </Link>
</>
)}
</div>
</div>
<div className="auth-built-canada">🇨🇦 Built in Canada</div>
</main>
</div>
</div>
);
}
function Glows() {
return (
<>
<div
className="auth-glow"
style={{
width: 700,
height: 700,
top: -150,
left: "50%",
transform: "translateX(-50%)",
background:
"radial-gradient(circle at center, oklch(0.74 0.175 35 / 0.22) 0%, transparent 62%)",
}}
/>
<div
className="auth-glow"
style={{
width: 500,
height: 500,
bottom: -100,
left: 0,
background:
"radial-gradient(circle at center, oklch(0.45 0.10 35 / 0.20) 0%, transparent 62%)",
}}
/>
<div
className="auth-glow"
style={{
width: 450,
height: 450,
top: "50%",
right: -150,
background:
"radial-gradient(circle at center, oklch(0.45 0.10 35 / 0.15) 0%, transparent 62%)",
}}
/>
</>
);
}
function Arrow({ size = 14 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M3 8h10M9 4l4 4-4 4"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function GoogleIcon({ size = 17 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 18 18" aria-hidden="true">
<path
fill="#EA4335"
d="M9 3.6c1.3 0 2.5.5 3.4 1.3l2.5-2.5C13.4 1 11.3.1 9 .1 5.5.1 2.4 2.1.9 5.1l2.9 2.3C4.5 5.2 6.6 3.6 9 3.6Z"
/>
<path
fill="#34A853"
d="M17.6 9.2c0-.6-.1-1.2-.2-1.8H9v3.4h4.9c-.2 1.1-.9 2-1.9 2.6l2.9 2.3c1.7-1.6 2.7-3.9 2.7-6.5Z"
/>
<path
fill="#FBBC05"
d="M3.8 10.7c-.2-.6-.3-1.1-.3-1.7s.1-1.2.3-1.7L.9 5C.3 6.2 0 7.5 0 9s.3 2.8.9 4l2.9-2.3Z"
/>
<path
fill="#4285F4"
d="M9 17.9c2.4 0 4.4-.8 5.9-2.2l-2.9-2.3c-.8.5-1.8.9-3 .9-2.3 0-4.3-1.6-5-3.7L1.1 12.9C2.6 15.9 5.6 17.9 9 17.9Z"
/>
</svg>
);
}