Files
zclaw_openfang/desktop/src/components/LoginPage.tsx
iven 7de294375b
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
feat(auth): 添加异步密码哈希和验证函数
refactor(relay): 复用HTTP客户端和请求体序列化结果

feat(kernel): 添加获取单个审批记录的方法

fix(store): 改进SaaS连接错误分类和降级处理

docs: 更新审计文档和系统架构文档

refactor(prompt): 优化SQL查询参数化绑定

refactor(migration): 使用静态SQL和COALESCE更新配置项

feat(commands): 添加审批执行状态追踪和事件通知

chore: 更新启动脚本以支持Admin后台

fix(auth-guard): 优化授权状态管理和错误处理

refactor(db): 使用异步密码哈希函数

refactor(totp): 使用异步密码验证函数

style: 清理无用文件和注释

docs: 更新功能全景和审计文档

refactor(service): 优化HTTP客户端重用和请求处理

fix(connection): 改进SaaS不可用时的降级处理

refactor(handlers): 使用异步密码验证函数

chore: 更新依赖和工具链配置
2026-03-29 21:45:29 +08:00

518 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* LoginPage - 全屏登录页面
*
* 强制 SaaS 登录门禁页面。用户必须登录后才能进入系统。
* 深色渐变背景 + 毛玻璃卡片设计。
* 复用 saasStore 的 login/register/loginWithTotp 逻辑。
*/
import { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import {
LogIn,
UserPlus,
Eye,
EyeOff,
Loader2,
AlertCircle,
Mail,
Shield,
ShieldCheck,
ArrowLeft,
User,
} from 'lucide-react';
import { useSaaSStore } from '../store/saasStore';
import { cn } from '../lib/utils';
import { isTauriRuntime } from '../lib/tauri-gateway';
// === Constants ===
const PRODUCTION_SAAS_URL = 'https://saas.zclaw.com';
const DEV_SAAS_URL = 'http://127.0.0.1:8080';
// === Animation Variants ===
const cardVariants = {
hidden: { opacity: 0, y: 20, scale: 0.96 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] as const },
},
};
const fieldVariants = {
hidden: { opacity: 0, y: 10 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: { delay: 0.15 + i * 0.05, duration: 0.35, ease: 'easeOut' as const },
}),
};
// === ZCLAW Logo SVG ===
function ZclawLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<defs>
<linearGradient id="logoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#10b981" />
<stop offset="100%" stopColor="#14b8a6" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="url(#logoGrad)" />
<text
x="32"
y="42"
textAnchor="middle"
fill="white"
fontSize="28"
fontWeight="bold"
fontFamily="system-ui, sans-serif"
>
Z
</text>
</svg>
);
}
// === Helpers ===
/** 根据运行环境自动选择 SaaS 服务器地址 */
function getSaasUrl(): string {
if (import.meta.env.DEV) return DEV_SAAS_URL;
return isTauriRuntime() ? PRODUCTION_SAAS_URL : DEV_SAAS_URL;
}
// === Component ===
export function LoginPage() {
const {
login,
loginWithTotp,
register,
isLoading,
error: storeError,
totpRequired,
clearError,
} = useSaaSStore();
// Form state
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isRegister, setIsRegister] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [totpCode, setTotpCode] = useState('');
const [showTotpStep, setShowTotpStep] = useState(false);
// Sync TOTP required from store & clear stale errors on mount
const mountedRef = useRef(false);
useEffect(() => {
clearError();
mountedRef.current = true;
}, [clearError]);
// Sync TOTP required from store
useEffect(() => {
if (totpRequired && !showTotpStep) {
setShowTotpStep(true);
}
}, [totpRequired, showTotpStep]);
// 屏蔽首次挂载时从 bootstrap/connectionStore 残留的 storeError
const displayError = mountedRef.current ? (storeError || localError) : localError;
const clearErrors = () => setLocalError(null);
// === Login Handler ===
const handleLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearErrors();
if (!username.trim()) {
setLocalError('请输入用户名');
return;
}
if (!password) {
setLocalError('请输入密码');
return;
}
try {
await login(getSaasUrl(), username.trim(), password);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setLocalError(message);
}
};
// === Register Handler ===
const handleRegisterSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearErrors();
if (!username.trim()) {
setLocalError('请输入用户名');
return;
}
if (!email.trim()) {
setLocalError('请输入邮箱地址');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
setLocalError('邮箱格式不正确');
return;
}
if (password.length < 6) {
setLocalError('密码长度至少 6 个字符');
return;
}
if (password !== confirmPassword) {
setLocalError('两次输入的密码不一致');
return;
}
try {
await register(
getSaasUrl(),
username.trim(),
email.trim(),
password,
displayName.trim() || undefined,
);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setLocalError(message);
}
};
// === TOTP Handler ===
const handleTotpSubmit = async () => {
if (totpCode.length !== 6) return;
clearErrors();
try {
await loginWithTotp(getSaasUrl(), username.trim(), password, totpCode);
setTotpCode('');
setShowTotpStep(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setLocalError(message);
}
};
// === Tab Switch ===
const handleTabSwitch = (registerMode: boolean) => {
setIsRegister(registerMode);
clearErrors();
setConfirmPassword('');
setEmail('');
setDisplayName('');
};
const handleBackToLogin = () => {
setShowTotpStep(false);
setTotpCode('');
clearErrors();
};
// === Input class helper ===
const inputClass = cn(
'w-full px-4 py-3 rounded-lg text-sm text-white placeholder-white/40',
'bg-white/5 border border-white/10',
'focus:outline-none focus:ring-2 focus:ring-emerald-400/30 focus:border-emerald-400/50',
'transition-all duration-200',
'disabled:opacity-50 disabled:cursor-not-allowed',
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden">
{/* Animated gradient background */}
<div
className="absolute inset-0 bg-gradient-to-br from-[#0c1445] via-[#1a1a5e] to-[#0d1b2a]"
style={{ backgroundSize: '200% 200%', animation: 'gradientShift 8s ease infinite' }}
/>
{/* Radial glow */}
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-[600px] h-[600px] rounded-full bg-emerald-500/8 blur-[120px] pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-[400px] h-[400px] rounded-full bg-blue-500/10 blur-[100px] pointer-events-none" />
{/* Card */}
<motion.div
variants={cardVariants}
initial="hidden"
animate="visible"
className="relative z-10 w-full max-w-md mx-4"
>
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-2xl p-8 md:p-10">
{/* TOTP Verification Step */}
{showTotpStep ? (
<div className="space-y-5">
<motion.div
custom={0}
variants={fieldVariants}
initial="hidden"
animate="visible"
className="text-center"
>
<div className="mx-auto w-12 h-12 rounded-full bg-emerald-500/20 flex items-center justify-center mb-4">
<Shield className="w-6 h-6 text-emerald-400" />
</div>
<h2 className="text-xl font-semibold text-white"></h2>
<p className="text-sm text-white/50 mt-1">
</p>
</motion.div>
<motion.div custom={1} variants={fieldVariants} initial="hidden" animate="visible">
<input
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
autoComplete="one-time-code"
autoFocus
disabled={isLoading}
className={cn(inputClass, 'text-center font-mono text-2xl tracking-[0.5em] py-4')}
onKeyDown={(e) => {
if (e.key === 'Enter' && totpCode.length === 6) handleTotpSubmit();
}}
/>
</motion.div>
{displayError && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-start gap-2 text-sm text-red-300 bg-red-500/10 border border-red-500/20 rounded-lg p-3"
>
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{displayError}</span>
</motion.div>
)}
<motion.div custom={2} variants={fieldVariants} initial="hidden" animate="visible" className="flex gap-3">
<button
type="button"
onClick={handleBackToLogin}
disabled={isLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm text-white/70 border border-white/15 rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
>
<ArrowLeft className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleTotpSubmit}
disabled={isLoading || totpCode.length !== 6}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-sm font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<ShieldCheck className="w-4 h-4" />
)}
</button>
</motion.div>
</div>
) : (
<>
{/* Logo & Brand */}
<motion.div custom={0} variants={fieldVariants} initial="hidden" animate="visible" className="text-center mb-8">
<ZclawLogo className="w-16 h-16 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-white tracking-wide">ZCLAW</h1>
<p className="text-sm text-white/50 mt-1">
{isRegister ? '创建账号,开始使用' : '登录以继续'}
</p>
</motion.div>
{/* Tab Switcher */}
<motion.div custom={1} variants={fieldVariants} initial="hidden" animate="visible" className="flex mb-6 bg-white/5 rounded-lg p-1">
<button
type="button"
onClick={() => handleTabSwitch(false)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 text-sm font-medium rounded-md transition-all cursor-pointer',
!isRegister
? 'bg-white/10 text-white shadow-sm'
: 'text-white/50 hover:text-white/70',
)}
>
<LogIn className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleTabSwitch(true)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 text-sm font-medium rounded-md transition-all cursor-pointer',
isRegister
? 'bg-white/10 text-white shadow-sm'
: 'text-white/50 hover:text-white/70',
)}
>
<UserPlus className="w-3.5 h-3.5" />
</button>
</motion.div>
{/* Form */}
<form
onSubmit={isRegister ? handleRegisterSubmit : handleLoginSubmit}
className="space-y-4"
>
{/* Username */}
<motion.div custom={2} variants={fieldVariants} initial="hidden" animate="visible">
<div className="relative">
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="用户名"
autoComplete="username"
disabled={isLoading}
className={cn(inputClass, 'pl-10')}
/>
</div>
</motion.div>
{/* Email (Register only) */}
{isRegister && (
<motion.div custom={3} variants={fieldVariants} initial="hidden" animate="visible">
<div className="relative">
<Mail className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="邮箱地址"
autoComplete="email"
disabled={isLoading}
className={cn(inputClass, 'pl-10')}
/>
</div>
</motion.div>
)}
{/* Display Name (Register only, optional) */}
{isRegister && (
<motion.div custom={4} variants={fieldVariants} initial="hidden" animate="visible">
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="显示名称(可选)"
autoComplete="name"
disabled={isLoading}
className={inputClass}
/>
</motion.div>
)}
{/* Password */}
<motion.div custom={isRegister ? 5 : 3} variants={fieldVariants} initial="hidden" animate="visible">
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={isRegister ? '密码(至少 6 位)' : '密码'}
autoComplete={isRegister ? 'new-password' : 'current-password'}
disabled={isLoading}
className={cn(inputClass, 'pr-10')}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 cursor-pointer"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</motion.div>
{/* Confirm Password (Register only) */}
{isRegister && (
<motion.div custom={6} variants={fieldVariants} initial="hidden" animate="visible">
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="确认密码"
autoComplete="new-password"
disabled={isLoading}
className={inputClass}
/>
</motion.div>
)}
{/* Error Display */}
{displayError && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-start gap-2 text-sm text-red-300 bg-red-500/10 border border-red-500/20 rounded-lg p-3"
>
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{displayError}</span>
</motion.div>
)}
{/* Submit Button */}
<motion.div custom={isRegister ? 7 : 4} variants={fieldVariants} initial="hidden" animate="visible">
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-sm font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer shadow-lg shadow-emerald-500/20 hover:shadow-emerald-500/30"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{isRegister ? '注册中...' : '登录中...'}
</>
) : (
<>
{isRegister ? (
<><UserPlus className="w-4 h-4" /></>
) : (
<><LogIn className="w-4 h-4" /></>
)}
</>
)}
</button>
</motion.div>
</form>
{/* Version footer */}
<motion.p
custom={isRegister ? 8 : 5}
variants={fieldVariants}
initial="hidden"
animate="visible"
className="text-center text-xs text-white/25 mt-6"
>
ZCLAW AI Agent Platform
</motion.p>
</>
)}
</div>
</motion.div>
</div>
);
}