edcde8b34a
signIn.email() resolves with error: null and data.twoFactorRedirect: true
for 2FA-enabled accounts — no full session exists yet. The login page's
handleSubmit treated any non-error response as fully signed in and called
router.push("/recipes"), racing better-auth's own window.location.href
redirect to /verify-2fa (twoFactorClient's onSuccess hook). When the app's
push won that race, middleware bounced the unauthenticated /recipes
request straight back to /login with no error surfaced — looked like the
page just reloaded.
Now explicitly skips the /recipes push when twoFactorRedirect is set,
leaving the SDK's own redirect to run uncontested.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
183 lines
9.1 KiB
TypeScript
183 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { toast } from "sonner";
|
|
import { useTranslations } from "next-intl";
|
|
import { authClient } from "@/lib/auth/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Separator } from "@/components/ui/separator";
|
|
|
|
export default function LoginPage() {
|
|
const router = useRouter();
|
|
const t = useTranslations("auth");
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [unverified, setUnverified] = useState(false);
|
|
const [resending, setResending] = useState(false);
|
|
|
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
setUnverified(false);
|
|
setLoading(true);
|
|
const { data, error } = await authClient.signIn.email({ email, password, callbackURL: "/recipes" });
|
|
setLoading(false);
|
|
if (error) {
|
|
if (error.code === "EMAIL_NOT_VERIFIED") {
|
|
setUnverified(true);
|
|
} else {
|
|
toast.error(error.message ?? "Sign in failed");
|
|
}
|
|
} else if (!(data as { twoFactorRedirect?: boolean } | null)?.twoFactorRedirect) {
|
|
// 2FA-enabled accounts resolve with no `error` but `twoFactorRedirect: true` —
|
|
// no full session exists yet, so pushing to /recipes here races the SDK's own
|
|
// window.location.href to /verify-2fa (see twoFactorClient's onSuccess hook)
|
|
// and can lose, bouncing off middleware back to /login with no visible error.
|
|
router.push("/recipes");
|
|
}
|
|
}
|
|
|
|
async function resendVerification() {
|
|
setResending(true);
|
|
const { error } = await authClient.sendVerificationEmail({
|
|
email,
|
|
callbackURL: "/recipes",
|
|
});
|
|
setResending(false);
|
|
if (error) {
|
|
toast.error(error.message ?? "Failed to resend");
|
|
} else {
|
|
toast.success("Verification email sent — check your inbox");
|
|
setUnverified(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="space-y-1">
|
|
<CardTitle className="text-2xl font-semibold tracking-tight">Epicure</CardTitle>
|
|
<CardDescription>{t("signInTitle")}</CardDescription>
|
|
</CardHeader>
|
|
<form onSubmit={handleSubmit}>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => authClient.signIn.social({ provider: "google", callbackURL: "/recipes" })}>
|
|
<GoogleIcon />
|
|
{t("continueWithGoogle")}
|
|
</Button>
|
|
<SocialButton provider="github" label={t("continueWithGithub")} icon={<GithubIcon />} />
|
|
<SocialButton provider="discord" label={t("continueWithDiscord")} icon={<DiscordIcon />} />
|
|
<AuthentikButton label={t("continueWithAuthentik")} />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Separator className="flex-1" />
|
|
<span className="text-xs text-muted-foreground">{t("or")}</span>
|
|
<Separator className="flex-1" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">{t("email")}</Label>
|
|
<Input id="email" type="email" placeholder={t("emailPlaceholder")} value={email} onChange={(e) => { setEmail(e.target.value); setUnverified(false); }} required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="password">{t("password")}</Label>
|
|
<Link href="/forgot-password" className="text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground">
|
|
{t("forgotPassword")}
|
|
</Link>
|
|
</div>
|
|
<Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
|
</div>
|
|
|
|
{unverified && (
|
|
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30 px-4 py-3 space-y-2">
|
|
<p className="text-sm text-amber-800 dark:text-amber-300 font-medium">{t("emailNotVerified")}</p>
|
|
<p className="text-xs text-amber-700 dark:text-amber-400">{t("checkInboxVerification")}</p>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={resendVerification}
|
|
disabled={resending}
|
|
className="border-amber-300 dark:border-amber-700"
|
|
>
|
|
{resending ? t("resendingSending") : t("resendVerification")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<Button className="w-full" type="submit" disabled={loading}>
|
|
{loading ? t("signInLoading") : t("signIn")}
|
|
</Button>
|
|
</CardContent>
|
|
</form>
|
|
<CardFooter className="flex justify-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("noAccount")}{" "}
|
|
<Link href="/signup" className="underline underline-offset-4 hover:text-foreground">{t("signUp")}</Link>
|
|
</p>
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function SocialButton({ provider, label, icon }: { provider: "github" | "discord"; label: string; icon: React.ReactNode }) {
|
|
const envKey = provider === "github" ? process.env["NEXT_PUBLIC_GITHUB_ENABLED"] : process.env["NEXT_PUBLIC_DISCORD_ENABLED"];
|
|
if (!envKey) return null;
|
|
return (
|
|
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => authClient.signIn.social({ provider, callbackURL: "/recipes" })}>
|
|
{icon}
|
|
{label}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function AuthentikButton({ label }: { label: string }) {
|
|
if (!process.env["NEXT_PUBLIC_AUTHENTIK_ENABLED"]) return null;
|
|
return (
|
|
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => (authClient as unknown as { signIn: { genericOAuth: (opts: { providerId: string; callbackURL: string }) => Promise<void> } }).signIn.genericOAuth({ providerId: "authentik", callbackURL: "/recipes" })}>
|
|
<AuthentikIcon />
|
|
{label}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function GoogleIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
|
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function GithubIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-current" aria-hidden="true">
|
|
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function DiscordIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-current" aria-hidden="true">
|
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.11 18.1.128 18.11a19.9 19.9 0 0 0 5.993 3.03.077.077 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function AuthentikIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
|
</svg>
|
|
);
|
|
}
|