mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-24 08:55:59 +00:00
c0233cae26
* fix(frontend): resolve login page flickering and resize observer loop. * fix(frontend): allow vertical scrolling on login page Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
214 lines
6.2 KiB
TypeScript
214 lines
6.2 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 { 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 { parseAuthError } from "@/core/auth/types";
|
|
|
|
/**
|
|
* 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 [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [isLogin, setIsLogin] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// Get next parameter for validated redirect
|
|
const nextParam = searchParams.get("next");
|
|
const redirectPath = validateNextParam(nextParam) ?? "/workspace";
|
|
|
|
// Redirect if already authenticated (client-side, post-login)
|
|
useEffect(() => {
|
|
if (isAuthenticated) {
|
|
router.push(redirectPath);
|
|
}
|
|
}, [isAuthenticated, redirectPath, router]);
|
|
|
|
// Redirect to setup if the system has no users yet
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
void fetch("/api/v1/auth/setup-status")
|
|
.then((r) => r.json())
|
|
.then((data: { needs_setup?: boolean }) => {
|
|
if (!cancelled && data.needs_setup) {
|
|
router.push("/setup");
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Ignore errors; user stays on login page
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [router]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError("");
|
|
setLoading(true);
|
|
|
|
try {
|
|
const endpoint = isLogin
|
|
? "/api/v1/auth/login/local"
|
|
: "/api/v1/auth/register";
|
|
const body = isLogin
|
|
? `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`
|
|
: JSON.stringify({ email, password });
|
|
|
|
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);
|
|
return;
|
|
}
|
|
|
|
// Both login and register set a cookie — redirect to workspace
|
|
router.push(redirectPath);
|
|
} catch {
|
|
setError("Network error. Please try again.");
|
|
} 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 ? "Sign in to your account" : "Create a new account"}
|
|
</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-2">
|
|
<div className="flex flex-col space-y-1">
|
|
<label htmlFor="email" className="text-sm font-medium">
|
|
Email
|
|
</label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="you@example.com"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col space-y-1">
|
|
<label htmlFor="password" className="text-sm font-medium">
|
|
Password
|
|
</label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="•••••••"
|
|
required
|
|
minLength={isLogin ? 6 : 8}
|
|
/>
|
|
</div>
|
|
|
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
|
|
|
<Button type="submit" className="w-full" disabled={loading}>
|
|
{loading
|
|
? "Please wait..."
|
|
: isLogin
|
|
? "Sign In"
|
|
: "Create Account"}
|
|
</Button>
|
|
</form>
|
|
|
|
<div className="text-center text-sm">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsLogin(!isLogin);
|
|
setError("");
|
|
}}
|
|
className="text-blue-500 hover:underline"
|
|
>
|
|
{isLogin
|
|
? "Don't have an account? Sign up"
|
|
: "Already have an account? Sign in"}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="text-muted-foreground text-center text-xs">
|
|
<Link href="/" className="hover:underline">
|
|
← Back to home
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|