- Rewrite DiscordChannelSidebar with @dnd-kit drag-and-drop: rooms are sortable within categories; dragging onto a different category header assigns the room to that category - Add inline 'Add Category' button: Enter/Esc to confirm/cancel - Wire category create/move handlers in room.tsx via RoomContext - Fix onAiStreamChunk to accumulate content properly and avoid redundant re-renders during AI streaming (dedup guard) - No backend changes needed: category CRUD and room category update endpoints were already wired
270 lines
14 KiB
TypeScript
270 lines
14 KiB
TypeScript
import {useCallback, useEffect, useRef, useState} from "react";
|
|
import {Link, useLocation, useNavigate} from "react-router-dom";
|
|
import {ArrowRight, Command, Eye, EyeOff, Loader2, ShieldAlert} from "lucide-react";
|
|
import {apiAuthCaptcha, type ApiResponseCaptchaResponse} from "@/client";
|
|
import {getApiErrorMessage /*, isTotpRequiredError*/} from "@/lib/api-error"; // 2FA disabled
|
|
import {useUser} from "@/contexts";
|
|
import {AuthLayout} from "@/components/auth/auth-layout";
|
|
import {Button} from "@/components/ui/button";
|
|
import {Input} from "@/components/ui/input";
|
|
import {rsaEncrypt} from "@/lib/rsa";
|
|
import {AnimatePresence, motion} from "framer-motion";
|
|
|
|
export function LoginPage() {
|
|
const {login} = useUser();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const from = (location.state as { from?: string })?.from || "/w/me";
|
|
const usernameRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [form, setForm] = useState({username: "", password: "", captcha: ""});
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
// const [needsTotp, setNeedsTotp] = useState(false); // 2FA disabled
|
|
const [captcha, setCaptcha] = useState<ApiResponseCaptchaResponse['data'] | null>(null);
|
|
const [captchaLoading, setCaptchaLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const loadCaptcha = useCallback(async () => {
|
|
setCaptchaLoading(true);
|
|
try {
|
|
// 使用 dark: false 保持验证码背景干净,用 CSS 滤镜适配暗黑模式
|
|
const resp = await apiAuthCaptcha({body: {w: 100, h: 32, dark: false, rsa: true}});
|
|
if (resp.data?.data) setCaptcha(resp.data.data);
|
|
} catch { /* ignored */
|
|
} finally {
|
|
setCaptchaLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
usernameRef.current?.focus();
|
|
loadCaptcha();
|
|
}, [loadCaptcha]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
|
|
if (!form.username.trim() || !form.password) {
|
|
setError("Please fill in all required fields.");
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const encryptedPassword = captcha?.rsa
|
|
? rsaEncrypt(form.password, captcha.rsa.public_key)
|
|
: form.password;
|
|
|
|
await login({
|
|
username: form.username,
|
|
password: encryptedPassword,
|
|
captcha: form.captcha,
|
|
// totp_code: undefined, // 2FA disabled
|
|
});
|
|
navigate(from, {replace: true});
|
|
} catch (err: unknown) {
|
|
// if (isTotpRequiredError(err)) { // 2FA disabled
|
|
// setNeedsTotp(true);
|
|
// } else {
|
|
setError(getApiErrorMessage(err, "Invalid credentials. Please try again."));
|
|
await loadCaptcha();
|
|
setForm((p) => ({...p, captcha: ""}));
|
|
// }
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<AuthLayout>
|
|
<motion.div
|
|
initial={{opacity: 0, y: 10}}
|
|
animate={{opacity: 1, y: 0}}
|
|
transition={{duration: 0.4, ease: "easeOut"}}
|
|
className="w-full max-w-[380px] mx-auto"
|
|
>
|
|
{/* 头部区域 */}
|
|
<div className="flex flex-col items-center text-center mb-8 space-y-4">
|
|
<div
|
|
className="h-12 w-12 bg-zinc-900 dark:bg-zinc-100 rounded-2xl flex items-center justify-center shadow-sm">
|
|
<Command className="h-6 w-6 text-white dark:text-zinc-900"/>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
|
|
Sign in
|
|
</h1>
|
|
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
|
{from && from !== "/"
|
|
? <>Continue to <span
|
|
className="font-medium text-zinc-900 dark:text-zinc-200">{from}</span></>
|
|
: "Welcome back to GitDataAI"
|
|
}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 核心表单卡片 */}
|
|
<div
|
|
className="bg-white dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800/80 rounded-2xl p-6 md:p-8 shadow-[0_0_40px_-10px_rgba(0,0,0,0.03)] dark:shadow-none">
|
|
<form onSubmit={handleSubmit} className="space-y-5">
|
|
|
|
{/* Username */}
|
|
<div className="space-y-2">
|
|
<label htmlFor="username" className="text-sm font-medium text-zinc-900 dark:text-zinc-200">
|
|
Username
|
|
</label>
|
|
<Input
|
|
ref={usernameRef}
|
|
id="username"
|
|
type="text"
|
|
placeholder="Enter your username"
|
|
value={form.username}
|
|
onChange={(e) => setForm((p) => ({...p, username: e.target.value}))}
|
|
className="h-10 px-3 bg-zinc-50 dark:bg-zinc-900/50 border-zinc-200 dark:border-zinc-800 rounded-lg focus-visible:ring-1 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-600 transition-shadow"
|
|
disabled={isLoading}
|
|
/>
|
|
</div>
|
|
|
|
{/* Password */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="password"
|
|
className="text-sm font-medium text-zinc-900 dark:text-zinc-200">
|
|
Password
|
|
</label>
|
|
<Link to="/auth/password/reset"
|
|
className="text-xs font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-300 transition-colors">
|
|
Forgot?
|
|
</Link>
|
|
</div>
|
|
<div className="relative">
|
|
<Input
|
|
id="password"
|
|
type={showPassword ? "text" : "password"}
|
|
placeholder="••••••••"
|
|
value={form.password}
|
|
onChange={(e) => setForm((p) => ({...p, password: e.target.value}))}
|
|
className="h-10 pl-3 pr-10 bg-zinc-50 dark:bg-zinc-900/50 border-zinc-200 dark:border-zinc-800 rounded-lg focus-visible:ring-1 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-600 transition-shadow"
|
|
disabled={isLoading}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword((v) => !v)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors"
|
|
>
|
|
{showPassword ? <EyeOff className="size-4"/> : <Eye className="size-4"/>}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 2FA disabled {
|
|
<AnimatePresence>
|
|
{needsTotp && (
|
|
<motion.div
|
|
initial={{height: 0, opacity: 0}}
|
|
animate={{height: "auto", opacity: 1}}
|
|
className="space-y-2 overflow-hidden"
|
|
>
|
|
<label htmlFor="totp_code"
|
|
className="text-sm font-medium text-zinc-900 dark:text-zinc-200">
|
|
Authenticator Code
|
|
</label>
|
|
<Input
|
|
id="totp_code"
|
|
placeholder="000 000"
|
|
maxLength={6}
|
|
value={form.totp_code}
|
|
onChange={(e) => setForm((p) => ({
|
|
...p,
|
|
totp_code: e.target.value.replace(/\D/g, "")
|
|
}))}
|
|
className="h-10 text-center tracking-[0.5em] font-mono text-lg bg-zinc-50 dark:bg-zinc-900/50 border-zinc-200 dark:border-zinc-800 rounded-lg focus-visible:ring-1 focus-visible:ring-zinc-400"
|
|
disabled={isLoading}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
} */}
|
|
|
|
{/* 优雅的内嵌验证码设计 */}
|
|
{/* {needsTotp && (} */}
|
|
<div className="space-y-2">
|
|
<label htmlFor="captcha"
|
|
className="text-sm font-medium text-zinc-900 dark:text-zinc-200">
|
|
Verification
|
|
</label>
|
|
<div className="relative flex items-center">
|
|
<Input
|
|
id="captcha"
|
|
placeholder="Enter code"
|
|
value={form.captcha}
|
|
onChange={(e) => setForm((p) => ({...p, captcha: e.target.value}))}
|
|
className="h-10 pl-3 pr-[110px] bg-zinc-50 dark:bg-zinc-900/50 border-zinc-200 dark:border-zinc-800 rounded-lg focus-visible:ring-1 focus-visible:ring-zinc-400 dark:focus-visible:ring-zinc-600 transition-shadow"
|
|
disabled={isLoading || captchaLoading}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={loadCaptcha}
|
|
disabled={captchaLoading}
|
|
className="absolute right-1 top-1 bottom-1 w-[100px] rounded-md overflow-hidden bg-white dark:bg-zinc-800 border border-zinc-100 dark:border-zinc-700 hover:opacity-80 transition-opacity flex items-center justify-center cursor-pointer"
|
|
>
|
|
{captchaLoading ? (
|
|
<Loader2 className="size-4 animate-spin text-zinc-400"/>
|
|
) : captcha ? (
|
|
// 暗黑模式下直接通过滤镜反转图片颜色,极其优雅
|
|
<img src={captcha.base64} alt="captcha"
|
|
className="h-full w-full object-cover dark:invert dark:hue-rotate-180"/>
|
|
) : null}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/* )} */}
|
|
|
|
{/* 错误提示 - 使用非常克制的柔和边框风格 */}
|
|
<AnimatePresence>
|
|
{error && (
|
|
<motion.div
|
|
initial={{opacity: 0, scale: 0.98}}
|
|
animate={{opacity: 1, scale: 1}}
|
|
className="flex items-start gap-2 p-3 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50 rounded-lg"
|
|
>
|
|
<ShieldAlert className="size-4 mt-0.5 shrink-0"/>
|
|
<p>{error}</p>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<Button
|
|
type="submit"
|
|
disabled={isLoading}
|
|
className="w-full h-10 mt-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 hover:bg-zinc-800 dark:hover:bg-white rounded-lg font-medium transition-all group"
|
|
>
|
|
{isLoading ? (
|
|
<Loader2 className="size-4 animate-spin"/>
|
|
) : (
|
|
<>
|
|
Sign in
|
|
<ArrowRight
|
|
className="size-4 ml-1.5 opacity-70 group-hover:translate-x-0.5 transition-transform"/>
|
|
</>
|
|
)}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="mt-8 text-center">
|
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
|
Don't have an account?{" "}
|
|
<Link to="/auth/register"
|
|
className="font-medium text-zinc-900 dark:text-zinc-200 hover:underline underline-offset-4">
|
|
Create one
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
</motion.div>
|
|
</AuthLayout>
|
|
);
|
|
}
|