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
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: 更新依赖和工具链配置
518 lines
18 KiB
TypeScript
518 lines
18 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|