P0 安全修复: - 修复 account update 自角色提升漏洞: 非 admin 用户更新自己时剥离 role 字段 - 添加 Admin 引导机制: accounts 表为空时自动从环境变量创建 super_admin P1 功能补全: - 所有 17 个 log_operation 调用点传入真实客户端 IP (ConnectInfo + X-Forwarded-For) - AuthContext 新增 client_ip 字段, middleware 层自动提取 - main.rs 使用 into_make_service_with_connect_info 启用 SocketAddr 注入 - 新增 PUT /api/v1/auth/password 密码修改端点 (验证旧密码 + argon2 哈希) - 桌面端 SaaS 设置页添加密码修改 UI (折叠式表单) - SaaSClient 添加 changePassword() 方法 - 集成测试修复: 注入模拟 ConnectInfo 适配 onshot 测试模式
281 lines
9.4 KiB
TypeScript
281 lines
9.4 KiB
TypeScript
import { useState } from 'react';
|
|
import { useSaaSStore } from '../../store/saasStore';
|
|
import { SaaSLogin } from './SaaSLogin';
|
|
import { SaaSStatus } from './SaaSStatus';
|
|
import { Cloud, Info, KeyRound } from 'lucide-react';
|
|
import { saasClient } from '../../lib/saas-client';
|
|
|
|
export function SaaSSettings() {
|
|
const isLoggedIn = useSaaSStore((s) => s.isLoggedIn);
|
|
const account = useSaaSStore((s) => s.account);
|
|
const saasUrl = useSaaSStore((s) => s.saasUrl);
|
|
const connectionMode = useSaaSStore((s) => s.connectionMode);
|
|
const login = useSaaSStore((s) => s.login);
|
|
const register = useSaaSStore((s) => s.register);
|
|
const logout = useSaaSStore((s) => s.logout);
|
|
|
|
const [showLogin, setShowLogin] = useState(!isLoggedIn);
|
|
const [loginError, setLoginError] = useState<string | null>(null);
|
|
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
|
|
|
const handleLogin = async (url: string, username: string, password: string) => {
|
|
setIsLoggingIn(true);
|
|
setLoginError(null);
|
|
try {
|
|
await login(url, username, password);
|
|
setShowLogin(false);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : '登录失败';
|
|
setLoginError(message);
|
|
} finally {
|
|
setIsLoggingIn(false);
|
|
}
|
|
};
|
|
|
|
const handleRegister = async (
|
|
url: string,
|
|
username: string,
|
|
email: string,
|
|
password: string,
|
|
displayName?: string,
|
|
) => {
|
|
setIsLoggingIn(true);
|
|
setLoginError(null);
|
|
try {
|
|
await register(url, username, email, password, displayName);
|
|
// register auto-logs in, no need for separate login call
|
|
setShowLogin(false);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : '注册失败';
|
|
setLoginError(message);
|
|
} finally {
|
|
setIsLoggingIn(false);
|
|
}
|
|
};
|
|
|
|
const handleLogout = () => {
|
|
logout();
|
|
setShowLogin(true);
|
|
setLoginError(null);
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-2xl">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<div className="w-9 h-9 rounded-lg bg-emerald-100 flex items-center justify-center">
|
|
<Cloud className="w-5 h-5 text-emerald-600" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-bold text-gray-900">SaaS 账号</h1>
|
|
<p className="text-sm text-gray-500">管理 ZCLAW 云端平台连接</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Connection mode info */}
|
|
<div className="flex items-start gap-2 text-sm text-gray-500 bg-blue-50 rounded-lg border border-blue-100 p-3 mb-5">
|
|
<Info className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" />
|
|
<span>
|
|
当前模式: <strong className="text-gray-700">{connectionMode === 'saas' ? 'SaaS 云端' : connectionMode === 'gateway' ? 'Gateway' : '本地 Tauri'}</strong>。
|
|
{connectionMode !== 'saas' && '连接 SaaS 平台可解锁云端同步、团队协作等高级功能。'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Login form or status display */}
|
|
{!showLogin ? (
|
|
<SaaSStatus
|
|
isLoggedIn={isLoggedIn}
|
|
account={account}
|
|
saasUrl={saasUrl}
|
|
onLogout={handleLogout}
|
|
onLogin={() => setShowLogin(true)}
|
|
/>
|
|
) : (
|
|
<SaaSLogin
|
|
onLogin={handleLogin}
|
|
onRegister={handleRegister}
|
|
initialUrl={saasUrl}
|
|
isLoggingIn={isLoggingIn}
|
|
error={loginError}
|
|
/>
|
|
)}
|
|
|
|
{/* Features list when logged in */}
|
|
{isLoggedIn && !showLogin && (
|
|
<div className="mt-6">
|
|
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">
|
|
云端功能
|
|
</h2>
|
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
|
|
<div className="space-y-3">
|
|
<CloudFeatureRow
|
|
name="云端同步"
|
|
description="对话记录和配置自动同步到云端"
|
|
status="active"
|
|
/>
|
|
<CloudFeatureRow
|
|
name="团队协作"
|
|
description="与团队成员共享 Agent 和技能"
|
|
status={account?.role === 'admin' || account?.role === 'pro' ? 'active' : 'inactive'}
|
|
/>
|
|
<CloudFeatureRow
|
|
name="高级分析"
|
|
description="使用统计和用量分析仪表板"
|
|
status={account?.role === 'admin' || account?.role === 'pro' ? 'active' : 'inactive'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Password change section */}
|
|
{isLoggedIn && !showLogin && <ChangePasswordSection />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CloudFeatureRow({
|
|
name,
|
|
description,
|
|
status,
|
|
}: {
|
|
name: string;
|
|
description: string;
|
|
status: 'active' | 'inactive';
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between py-1">
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900">{name}</div>
|
|
<div className="text-xs text-gray-500">{description}</div>
|
|
</div>
|
|
<span
|
|
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
|
status === 'active'
|
|
? 'bg-emerald-100 text-emerald-700'
|
|
: 'bg-gray-100 text-gray-500'
|
|
}`}
|
|
>
|
|
{status === 'active' ? '可用' : '需要订阅'}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChangePasswordSection() {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [oldPassword, setOldPassword] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setSuccess(false);
|
|
|
|
if (newPassword.length < 8) {
|
|
setError('新密码至少 8 个字符');
|
|
return;
|
|
}
|
|
if (newPassword !== confirmPassword) {
|
|
setError('两次输入的新密码不一致');
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
await saasClient.changePassword(oldPassword, newPassword);
|
|
setSuccess(true);
|
|
setOldPassword('');
|
|
setNewPassword('');
|
|
setConfirmPassword('');
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : '密码修改失败';
|
|
setError(message);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mt-6">
|
|
<div
|
|
className="flex items-center justify-between cursor-pointer"
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
>
|
|
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide">
|
|
账号安全
|
|
</h2>
|
|
<span className="text-xs text-gray-400">{isOpen ? '收起' : '展开'}</span>
|
|
</div>
|
|
|
|
{isOpen && (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm mt-3">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<KeyRound className="w-4 h-4 text-gray-400" />
|
|
<span className="text-sm font-medium text-gray-700">修改密码</span>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
|
当前密码
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={oldPassword}
|
|
onChange={(e) => setOldPassword(e.target.value)}
|
|
required
|
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
|
新密码
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
required
|
|
minLength={8}
|
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-500 mb-1">
|
|
确认新密码
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
required
|
|
minLength={8}
|
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="text-xs text-red-500">{error}</p>
|
|
)}
|
|
{success && (
|
|
<p className="text-xs text-emerald-600">密码修改成功</p>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="w-full py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{isSubmitting ? '修改中...' : '修改密码'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|