mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 02:05:45 +00:00
* feat(auth): add keep me signed in login option * fix(auth): address remember-login review feedback
358 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|