web-production-saas-starter/next_b2b_starter/app/auth/page.tsx
2025-12-16 18:54:41 +04:00

455 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { useStytchMember } from "@stytch/nextjs/b2b";
import { ArrowRight, CheckCircle2, Home, Inbox, Mail } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
const highlights = [
"Single workspace to review invoices, approvals, and exports.",
"Ready-made controls that plug into your existing banking stack.",
"Sessions scoped to your organization with role-aware access.",
];
const emailProviders = [
{
label: "Open Gmail",
href: "https://mail.google.com/",
},
{
label: "Open Outlook",
href: "https://outlook.office.com/mail/",
},
{
label: "Open iCloud Mail",
href: "https://www.icloud.com/mail",
},
{
label: "Open Yahoo Mail",
href: "https://mail.yahoo.com/",
},
];
export default function AuthPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { member, isInitialized } = useStytchMember();
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [status, setStatus] = useState<{
type: "info" | "error" | "success";
message: string;
} | null>(null);
const [isRedirecting, setIsRedirecting] = useState(false);
const [view, setView] = useState<"form" | "success">("form");
const [lastSubmittedEmail, setLastSubmittedEmail] = useState("");
const hasRedirectedRef = useRef(false);
const redirectTimeoutRef = useRef<number | null>(null);
const targetAfterLogin = useMemo(() => {
const returnTo = searchParams.get("returnTo") || "/dashboard";
return returnTo.startsWith("/") && !returnTo.startsWith("//")
? returnTo
: "/dashboard";
}, [searchParams]);
const handleAuthSuccess = useCallback(() => {
if (hasRedirectedRef.current) return;
hasRedirectedRef.current = true;
setIsRedirecting(true);
setStatus({
type: "info",
message: "Youre signed in. Redirecting to your workspace…",
});
router.replace(targetAfterLogin);
setTimeout(() => {
router.refresh();
}, 150);
if (typeof window !== "undefined") {
if (redirectTimeoutRef.current !== null) {
window.clearTimeout(redirectTimeoutRef.current);
}
redirectTimeoutRef.current = window.setTimeout(() => {
window.location.assign(targetAfterLogin);
}, 1500);
}
}, [router, targetAfterLogin]);
useEffect(() => {
if (!isInitialized) return;
if (member) {
handleAuthSuccess();
}
}, [isInitialized, member, handleAuthSuccess]);
useEffect(() => {
const hasMagicLinkParams =
searchParams.has("stytch_token") ||
searchParams.has("token") ||
searchParams.has("stytch_token_type");
if (hasMagicLinkParams) {
setStatus({
type: "info",
message: "Were verifying your sign-in link. This usually takes just a moment.",
});
}
}, [searchParams]);
const submitEmail = useCallback(
async (
rawEmail: string,
options: { resetField?: boolean; stayOnSuccessView?: boolean } = {}
) => {
const { resetField = true, stayOnSuccessView = false } = options;
const trimmedEmail = rawEmail.trim().toLowerCase();
if (!trimmedEmail) {
setStatus({
type: "error",
message: "Please enter a valid email address.",
});
return;
}
setIsSubmitting(true);
setStatus({
type: "info",
message: "Checking your workspace access…",
});
if (!stayOnSuccessView) {
setView("form");
}
try {
const query = new URLSearchParams({ email: trimmedEmail }).toString();
const apiBaseUrl = (process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api").replace(/\/$/, "");
const emailCheckResponse = await fetch(`${apiBaseUrl}/auth/check-email?${query}`);
if (emailCheckResponse.status === 404) {
const errorBody = await emailCheckResponse.json().catch(() => null);
setStatus({
type: "error",
message:
(errorBody && errorBody.message) ||
"We couldn't find an account with that email. Try a different email or ask your admin to invite you.",
});
return;
}
if (!emailCheckResponse.ok) {
const errorBody = await emailCheckResponse.json().catch(() => null);
throw new Error(
(errorBody && errorBody.message) ||
"We couldn't verify that email right now. Please try again in a moment.",
);
}
setStatus({
type: "info",
message: "Sending your secure sign-in link…",
});
const response = await fetch("/api/auth/magic-link", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: trimmedEmail }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to send sign-in link.");
}
setLastSubmittedEmail(trimmedEmail);
setStatus({
type: "success",
message: "Check your email for a secure link to sign in.",
});
setView("success");
if (resetField) {
setEmail("");
}
} catch (error: any) {
console.error("Sign-in link send error:", error);
setStatus({
type: "error",
message:
error?.message ||
"Something went wrong while sending your sign-in link. Please try again.",
});
} finally {
setIsSubmitting(false);
}
},
[]
);
const handleSendMagicLink = async (e: React.FormEvent) => {
e.preventDefault();
await submitEmail(email, { resetField: true });
};
const handleResend = async () => {
if (!lastSubmittedEmail) return;
await submitEmail(lastSubmittedEmail, {
resetField: false,
stayOnSuccessView: true,
});
};
useEffect(() => {
router.prefetch(targetAfterLogin);
}, [router, targetAfterLogin]);
useEffect(() => {
return () => {
if (typeof window !== "undefined" && redirectTimeoutRef.current !== null) {
window.clearTimeout(redirectTimeoutRef.current);
redirectTimeoutRef.current = null;
}
};
}, []);
if (!isInitialized) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="flex flex-col items-center gap-3">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-gray-200 border-t-primary-500" />
<p className="text-sm text-gray-600">
Checking your workspace session
</p>
</div>
</div>
);
}
if (isRedirecting || member) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="flex flex-col items-center gap-3 rounded-xl border border-gray-200 bg-white px-8 py-10 shadow-lg">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-gray-200 border-t-primary-500" />
<div className="text-center">
<p className="text-sm font-medium text-gray-900">
Redirecting to your dashboard
</p>
<p className="text-sm text-gray-600">
{(status && status.message) ||
"Were setting up your workspace now."}
</p>
<p className="mt-4 text-xs text-gray-500">
Taking longer than expected?{" "}
<a
href={targetAfterLogin}
className="font-medium text-primary-600 hover:underline"
>
Open your workspace
</a>
.
</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-b from-gray-50 via-white to-gray-50 py-16">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 lg:flex-row lg:items-start">
<section className="flex-1 space-y-8">
<div className="space-y-4">
<div className="flex items-center justify-between">
<Badge
variant="outline"
className="w-fit items-center gap-2 border-primary/30 bg-primary/5 text-xs font-medium text-primary-700"
>
<Mail className="h-3.5 w-3.5" aria-hidden />
Secure email sign-in
</Badge>
<Link href="/" className="flex items-center gap-2 text-sm text-gray-600 hover:text-primary-600 transition-colors">
<Home className="h-4 w-4" />
<span>Home</span>
</Link>
</div>
<div className="space-y-4">
<h1 className="text-3xl font-semibold text-gray-900 sm:text-4xl">
Welcome back to Your App
</h1>
<p className="max-w-xl text-base text-gray-600">
Use your work email to receive a one-time, organization-aware
sign-in link. Well land you back where you left off as soon as
youre authenticated.
</p>
</div>
</div>
<dl className="space-y-3">
{highlights.map((item) => (
<div
key={item}
className="flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm"
>
<CheckCircle2
className="mt-1 h-5 w-5 flex-none text-primary-600"
aria-hidden
/>
<p className="text-sm text-gray-600">{item}</p>
</div>
))}
</dl>
<div className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
<h2 className="text-sm font-semibold text-gray-900">
Need a hand?
</h2>
<p className="mt-2 text-sm text-gray-600">
Reach out at{" "}
<a
className="font-medium text-primary-600 hover:underline"
href="mailto:support@yourapp.com"
>
support@yourapp.com
</a>{" "}
for support, or check the documentation in your workspace.
</p>
</div>
</section>
<aside className="w-full max-w-md lg:sticky lg:top-24">
<div className="rounded-2xl border border-gray-200 bg-white p-8 shadow-lg">
<div className="mb-6 space-y-2 text-center">
<h2 className="text-2xl font-semibold text-gray-900">
Sign in to Your App
</h2>
<p className="text-sm text-gray-600">
Enter your work email to receive a secure sign-in link. Well
handle redirects automatically.
</p>
</div>
{view === "form" ? (
<>
{status && status.type !== "success" && (
<Alert
variant={status.type === "error" ? "destructive" : "default"}
className="mb-6 text-left"
>
<AlertTitle>
{status.type === "error" ? "We hit a snag" : "Hang tight"}
</AlertTitle>
<AlertDescription>{status.message}</AlertDescription>
</Alert>
)}
<form onSubmit={handleSendMagicLink} className="space-y-4">
<div className="space-y-2 text-left">
<label htmlFor="email" className="text-sm font-medium text-gray-700">
Work email address
</label>
<Input
id="email"
type="email"
placeholder="you@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting}
required
className="w-full"
autoComplete="email"
inputMode="email"
/>
</div>
<Button
type="submit"
disabled={isSubmitting || !email.trim()}
className="w-full"
>
{isSubmitting ? "Working…" : "Email me a sign-in link"}
</Button>
</form>
<p className="mt-6 text-center text-xs text-gray-400">
By continuing you agree to the terms of service and
acknowledge the privacy notice.
</p>
<p className="mt-4 text-center text-sm text-gray-600">
Don't have an account?{" "}
<Link href="/signup" className="text-primary-600 hover:underline font-medium">
Sign up
</Link>
</p>
</>
) : (
<div className="space-y-6 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-primary-600">
<Inbox className="h-7 w-7" aria-hidden />
</div>
<div className="space-y-2">
<h3 className="text-xl font-semibold text-gray-900">
Check your email
</h3>
<p className="text-sm text-gray-600">
We sent a secure link to {" "}
<span className="font-medium text-gray-900">
{lastSubmittedEmail}
</span>
. Open it on any device to finish signing in.
</p>
</div>
<div className="space-y-2">
{emailProviders.map((provider) => (
<Button key={provider.href} variant="secondary" className="w-full justify-between" asChild>
<a href={provider.href} target="_blank" rel="noreferrer">
<span>{provider.label}</span>
<ArrowRight className="h-4 w-4" aria-hidden />
</a>
</Button>
))}
<p className="text-xs text-gray-500">
Prefer another inbox? Open your mail app and look for an
email from Your App security.
</p>
</div>
{status && status.type === "error" && (
<Alert variant="destructive" className="text-left">
<AlertTitle>We couldnt send the link</AlertTitle>
<AlertDescription>{status.message}</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
<Button
variant="outline"
onClick={() => {
setView("form");
setStatus(null);
setEmail("");
}}
disabled={isSubmitting}
>
Use a different email
</Button>
<Button onClick={handleResend} disabled={isSubmitting}>
Resend link
</Button>
</div>
{status && status.type !== "error" && status.message && (
<p className="text-xs text-gray-500">{status.message}</p>
)}
</div>
)}
<div className="mt-8 flex items-center justify-center gap-2 text-xs text-gray-400">
<span>Powered by</span>
<span className="font-semibold text-gray-600">Stytch</span>
</div>
</div>
</aside>
</div>
</div>
);
}