gitdataai/src/app/auth/login-page.tsx
ZhenYi 63c75ad453
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions
feat(room): add category creation and drag-to-assign for channels
- 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
2026-04-19 16:44:31 +08:00

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>
);
}