Files
deer-flow/frontend/src/app/(auth)/login/page.tsx
T
Ryker_FengandGitHub a028dfd5fb feat(auth): add keep me signed in login option (#4255)
* feat(auth): add keep me signed in login option

* fix(auth): address remember-login review feedback
2026-07-18 22:17:15 +08:00

358 lines
11 KiB
TypeScript

"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { RememberSessionOption } from "@/components/auth/remember-session-option";
import { Button } from "@/components/ui/button";
import { FlickeringGrid } from "@/components/ui/flickering-grid";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/core/auth/AuthProvider";
import {
loadRememberLoginPreference,
saveRememberLoginPreference,
} from "@/core/auth/remember-login";
import {
canCreateRegularAccount,
fetchSetupStatus,
type SetupStatusResponse,
} from "@/core/auth/setup";
import { parseAuthError } from "@/core/auth/types";
import { useI18n } from "@/core/i18n/hooks";
/**
* Validate next parameter
* Prevent open redirect attacks
* Per RFC-001: Only allow relative paths starting with /
*/
function validateNextParam(next: string | null): string | null {
if (!next) {
return null;
}
// Need start with / (relative path)
if (!next.startsWith("/")) {
return null;
}
// Disallow protocol-relative URLs
if (
next.startsWith("//") ||
next.startsWith("http://") ||
next.startsWith("https://")
) {
return null;
}
// Disallow URLs with different protocols (e.g., javascript:, data:, etc)
if (next.includes(":") && !next.startsWith("/")) {
return null;
}
// Valid relative path
return next;
}
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { isAuthenticated } = useAuth();
const { theme, resolvedTheme } = useTheme();
const { t } = useI18n();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(true);
const [isLogin, setIsLogin] = useState(true);
const [ssoProviders, setSsoProviders] = useState<
{ id: string; display_name: string; type: string }[]
>([]);
const [setupStatus, setSetupStatus] = useState<SetupStatusResponse | null>(
null,
);
const [setupStatusChecked, setSetupStatusChecked] = useState(false);
// Extract error from query params (e.g., ?error=sso_failed)
const errorParam = searchParams.get("error");
const [error, setError] = useState(
errorParam
? (t.login.errors[errorParam as keyof typeof t.login.errors] ??
t.login.authFailed)
: "",
);
// Soft hint shown after a failed login when SSO is configured: an SSO-only
// account has no local password, so the backend returns a generic
// "incorrect email or password" (deliberately, to avoid account enumeration).
// Nudge the user toward the SSO buttons without confirming the account exists.
const [showSsoHint, setShowSsoHint] = useState(false);
const [loading, setLoading] = useState(false);
// Get next parameter for validated redirect
const nextParam = searchParams.get("next");
const redirectPath = validateNextParam(nextParam) ?? "/workspace";
const regularSignupAllowed = canCreateRegularAccount({
checked: setupStatusChecked,
status: setupStatus,
});
const systemNeedsAdminSetup = setupStatus?.needs_setup === true;
// Redirect if already authenticated (client-side, post-login)
useEffect(() => {
if (isAuthenticated) {
router.push(redirectPath);
}
}, [isAuthenticated, redirectPath, router]);
useEffect(() => {
const preference = loadRememberLoginPreference();
setRememberMe(preference.rememberMe);
if (preference.email) {
setEmail(preference.email);
}
}, []);
// Fetch setup state and SSO providers
useEffect(() => {
let cancelled = false;
void fetchSetupStatus()
.then((data) => {
if (cancelled) return;
setSetupStatus(data);
if (data.needs_setup) {
setIsLogin(true);
}
})
.catch(() => {
if (!cancelled) {
setSetupStatus(null);
}
})
.finally(() => {
if (!cancelled) {
setSetupStatusChecked(true);
}
});
void fetch("/api/v1/auth/providers")
.then((r) => r.json())
.then(
(data: {
providers: { id: string; display_name: string; type: string }[];
}) => {
if (!cancelled) {
setSsoProviders(data.providers ?? []);
}
},
)
.catch(() => {
// Ignore errors; no SSO providers shown
});
return () => {
cancelled = true;
};
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setShowSsoHint(false);
setLoading(true);
if (!isLogin && !regularSignupAllowed) {
setError(t.login.adminSetupRequiredDescription);
setLoading(false);
return;
}
try {
const endpoint = isLogin
? "/api/v1/auth/login/local"
: "/api/v1/auth/register";
const body = isLogin
? new URLSearchParams({
password,
remember_me: String(rememberMe),
username: email,
})
: JSON.stringify({ email, password, remember_me: rememberMe });
const headers: HeadersInit = isLogin
? { "Content-Type": "application/x-www-form-urlencoded" }
: { "Content-Type": "application/json" };
const res = await fetch(endpoint, {
method: "POST",
headers,
body,
credentials: "include", // Important: include HttpOnly cookie
});
if (!res.ok) {
const data = await res.json();
const authError = parseAuthError(data);
setError(authError.message);
// On a failed login with SSO configured, surface a hint pointing at the
// SSO buttons — the "wrong password" may really mean "this is an SSO account".
if (isLogin && ssoProviders.length > 0) {
setShowSsoHint(true);
}
return;
}
saveRememberLoginPreference({ email, rememberMe });
// Both login and register set a cookie — redirect to workspace
router.push(redirectPath);
} catch {
setError(t.login.networkError);
} finally {
setLoading(false);
}
};
const actualTheme = theme === "system" ? resolvedTheme : theme;
return (
<div className="bg-background relative flex min-h-screen items-center justify-center overflow-x-hidden overflow-y-auto">
<FlickeringGrid
className="absolute inset-0 z-0 mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"
squareSize={4}
gridGap={4}
color={actualTheme === "dark" ? "white" : "black"}
maxOpacity={0.3}
flickerChance={0.25}
/>
<div className="border-border/20 bg-background/5 w-full max-w-md space-y-6 rounded-3xl border p-8 backdrop-blur-sm">
<div className="text-center">
<h1 className="text-foreground font-serif text-3xl">DeerFlow</h1>
<p className="text-muted-foreground mt-2">
{isLogin ? t.login.signInTitle : t.login.createAccountTitle}
</p>
</div>
{systemNeedsAdminSetup && (
<div className="border-l-2 border-blue-500 ps-3 text-sm">
<p className="font-medium">{t.login.adminSetupRequiredTitle}</p>
<p className="text-muted-foreground mt-1">
{t.login.adminSetupRequiredDescription}
</p>
<Link
href="/setup"
className="mt-2 inline-block font-medium text-blue-500 hover:underline"
>
{t.login.createAdminAccount}
</Link>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-2">
<div className="flex flex-col space-y-1">
<label htmlFor="email" className="text-sm font-medium">
{t.login.email}
</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t.login.emailPlaceholder}
required
/>
</div>
<div className="flex flex-col space-y-1">
<label htmlFor="password" className="text-sm font-medium">
{t.login.password}
</label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t.login.passwordPlaceholder}
required
minLength={isLogin ? 6 : 8}
/>
</div>
<RememberSessionOption
checked={rememberMe}
onCheckedChange={setRememberMe}
/>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading
? t.login.pleaseWait
: isLogin
? t.login.signIn
: t.login.createAccount}
</Button>
</form>
{ssoProviders.length > 0 && (
<div className="space-y-2">
{isLogin && (
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
{t.login.orContinueWith}
</span>
</div>
</div>
)}
{showSsoHint && (
<p className="text-muted-foreground text-center text-sm">
{t.login.ssoHint}
</p>
)}
{ssoProviders.map((provider) => (
<Button
key={provider.id}
type="button"
variant="outline"
className="w-full"
disabled={loading}
onClick={() => {
window.location.href = `/api/v1/auth/oauth/${provider.id}?next=${encodeURIComponent(redirectPath)}&remember_me=${String(rememberMe)}`;
}}
>
{t.login.continueWith(provider.display_name)}
</Button>
))}
</div>
)}
{regularSignupAllowed && (
<div className="text-center text-sm">
<button
type="button"
onClick={() => {
setIsLogin(!isLogin);
setError("");
setShowSsoHint(false);
}}
className="text-blue-500 hover:underline"
>
{isLogin ? t.login.noAccountSignUp : t.login.haveAccountSignIn}
</button>
</div>
)}
<div className="text-muted-foreground text-center text-xs">
<Link href="/" className="hover:underline">
{t.login.backToHome}
</Link>
</div>
</div>
</div>
);
}