feat(saas): 桌面端 P2 客户端补齐 — TOTP 2FA、Relay 任务、Config 同步
- saas-client: 添加 TOTP/Relay/Config 类型和 typed 方法,login 支持 totp_code - saasStore: TOTP 感知登录 (检测 TOTP_ERROR → 两步登录),TOTP 管理动作 - SaaSLogin: TOTP 验证码输入步骤 (6 位数字,Enter 提交) - TOTPSettings (新): 启用流程 (QR 码 + secret + 验证码),禁用 (密码确认) - RelayTasksPanel (新): 状态过滤、任务列表、Admin 重试按钮 - SaaSSettings: 集成 TOTP 和 Relay 面板到设置页
This commit is contained in:
190
desktop/src/components/SaaS/RelayTasksPanel.tsx
Normal file
190
desktop/src/components/SaaS/RelayTasksPanel.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { saasClient, type RelayTaskInfo } from '../../lib/saas-client';
|
||||
import { useSaaSStore } from '../../store/saasStore';
|
||||
import {
|
||||
RefreshCw, RotateCw, Loader2, AlertCircle,
|
||||
CheckCircle, XCircle, Clock, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'completed', label: '成功' },
|
||||
{ key: 'failed', label: '失败' },
|
||||
{ key: 'processing', label: '处理中' },
|
||||
{ key: 'queued', label: '排队中' },
|
||||
] as const;
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config: Record<string, { bg: string; text: string; icon: typeof CheckCircle }> = {
|
||||
completed: { bg: 'bg-emerald-100 text-emerald-700', text: '成功', icon: CheckCircle },
|
||||
failed: { bg: 'bg-red-100 text-red-700', text: '失败', icon: XCircle },
|
||||
processing: { bg: 'bg-amber-100 text-amber-700', text: '处理中', icon: Zap },
|
||||
queued: { bg: 'bg-gray-100 text-gray-500', text: '排队中', icon: Clock },
|
||||
};
|
||||
const c = config[status] ?? config.queued;
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium ${c.bg}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{c.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '-';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function RelayTasksPanel() {
|
||||
const account = useSaaSStore((s) => s.account);
|
||||
const isAdmin = account?.role === 'admin';
|
||||
|
||||
const [tasks, setTasks] = useState<RelayTaskInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [retryingId, setRetryingId] = useState<string | null>(null);
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const query = statusFilter ? { status: statusFilter } : undefined;
|
||||
const data = await saasClient.listRelayTasks(query);
|
||||
setTasks(data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
setTasks([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, [fetchTasks]);
|
||||
|
||||
const handleRetry = async (taskId: string) => {
|
||||
setRetryingId(taskId);
|
||||
try {
|
||||
await saasClient.retryRelayTask(taskId);
|
||||
await fetchTasks();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '重试失败');
|
||||
} finally {
|
||||
setRetryingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">中转任务</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchTasks}
|
||||
disabled={isLoading}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status filter tabs */}
|
||||
<div className="flex gap-1 border-b border-gray-200">
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(tab.key)}
|
||||
className={`px-3 py-1.5 text-xs font-medium cursor-pointer transition-colors border-b-2 ${
|
||||
statusFilter === tab.key
|
||||
? 'border-emerald-500 text-emerald-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-600 bg-red-50 rounded-lg p-3">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && tasks.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-gray-400">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
加载中...
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-gray-400">
|
||||
暂无中转任务
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg border border-gray-100 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{/* Status */}
|
||||
<StatusBadge status={task.status} />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 truncate">
|
||||
{task.model_id}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{task.input_tokens > 0 || task.output_tokens > 0
|
||||
? `(${task.input_tokens}in / ${task.output_tokens}out)`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
{task.error_message && (
|
||||
<p className="text-xs text-red-500 truncate mt-0.5" title={task.error_message}>
|
||||
{task.error_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">
|
||||
{formatTime(task.created_at)}
|
||||
</span>
|
||||
|
||||
{/* Retry button (admin only, failed tasks only) */}
|
||||
{isAdmin && task.status === 'failed' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRetry(task.id)}
|
||||
disabled={retryingId === task.id}
|
||||
className="flex-shrink-0 p-1 text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 rounded transition-colors cursor-pointer disabled:opacity-50"
|
||||
title="重试"
|
||||
>
|
||||
{retryingId === task.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { LogIn, UserPlus, Globe, Eye, EyeOff, Loader2, AlertCircle, Mail } from 'lucide-react';
|
||||
import { LogIn, UserPlus, Globe, Eye, EyeOff, Loader2, AlertCircle, Mail, Shield, ShieldCheck, ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface SaaSLoginProps {
|
||||
onLogin: (saasUrl: string, username: string, password: string) => Promise<void>;
|
||||
onLoginWithTotp?: (saasUrl: string, username: string, password: string, totpCode: string) => Promise<void>;
|
||||
onRegister?: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
|
||||
initialUrl?: string;
|
||||
isLoggingIn?: boolean;
|
||||
totpRequired?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function SaaSLogin({ onLogin, onRegister, initialUrl, isLoggingIn, error }: SaaSLoginProps) {
|
||||
export function SaaSLogin({ onLogin, onLoginWithTotp, onRegister, initialUrl, isLoggingIn, totpRequired, error }: SaaSLoginProps) {
|
||||
const [serverUrl, setServerUrl] = useState(initialUrl || '');
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -19,6 +21,13 @@ export function SaaSLogin({ onLogin, onRegister, initialUrl, isLoggingIn, error
|
||||
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 with parent prop
|
||||
if (totpRequired && !showTotpStep) {
|
||||
setShowTotpStep(true);
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -74,12 +83,33 @@ export function SaaSLogin({ onLogin, onRegister, initialUrl, isLoggingIn, error
|
||||
|
||||
try {
|
||||
await onLogin(serverUrl.trim(), username.trim(), password);
|
||||
// If TOTP required, login() won't throw but store sets totpRequired
|
||||
// The effect above will switch to TOTP step
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setLocalError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTotpSubmit = async () => {
|
||||
if (!onLoginWithTotp || totpCode.length !== 6) return;
|
||||
setLocalError(null);
|
||||
try {
|
||||
await onLoginWithTotp(serverUrl.trim(), username.trim(), password, totpCode);
|
||||
setTotpCode('');
|
||||
setShowTotpStep(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setLocalError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
setShowTotpStep(false);
|
||||
setTotpCode('');
|
||||
setLocalError(null);
|
||||
};
|
||||
|
||||
const displayError = error || localError;
|
||||
|
||||
const handleTabSwitch = (register: boolean) => {
|
||||
@@ -92,204 +122,273 @@ export function SaaSLogin({ onLogin, onRegister, initialUrl, isLoggingIn, error
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">
|
||||
{isRegister ? '注册 SaaS 账号' : '登录 SaaS 平台'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-5">
|
||||
{isRegister
|
||||
? '创建账号以使用 ZCLAW 云端服务'
|
||||
: '连接到 ZCLAW SaaS 平台,解锁云端能力'}
|
||||
</p>
|
||||
|
||||
{/* Tab Switcher */}
|
||||
<div className="flex mb-5 border-b border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabSwitch(false)}
|
||||
className={`px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors border-b-2 ${
|
||||
!isRegister
|
||||
? 'border-emerald-500 text-emerald-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<LogIn className="w-3.5 h-3.5" />
|
||||
登录
|
||||
</span>
|
||||
</button>
|
||||
{onRegister && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabSwitch(true)}
|
||||
className={`px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors border-b-2 ${
|
||||
isRegister
|
||||
? 'border-emerald-500 text-emerald-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
注册
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Server URL */}
|
||||
<div>
|
||||
<label htmlFor="saas-url" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
服务器地址
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
id="saas-url"
|
||||
type="url"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
placeholder="https://saas.zclaw.com"
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
{/* TOTP Verification Step */}
|
||||
{showTotpStep ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Shield className="w-5 h-5 text-emerald-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">双因素认证</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
此账号已启用双因素认证,请输入 TOTP 验证码。
|
||||
</p>
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label htmlFor="saas-username" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id="saas-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="your-username"
|
||||
autoComplete="username"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email (Register only) */}
|
||||
{isRegister && (
|
||||
<div>
|
||||
<label htmlFor="saas-email" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
邮箱
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
id="saas-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display Name (Register only, optional) */}
|
||||
{isRegister && (
|
||||
<div>
|
||||
<label htmlFor="saas-display-name" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
显示名称 <span className="text-gray-400 font-normal">(可选)</span>
|
||||
<label htmlFor="totp-code" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
TOTP 验证码
|
||||
</label>
|
||||
<input
|
||||
id="saas-display-name"
|
||||
id="totp-code"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="ZCLAW User"
|
||||
autoComplete="name"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="000000"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono tracking-widest text-center focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && totpCode.length === 6) handleTotpSubmit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="saas-password" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="saas-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={isRegister ? '至少 6 个字符' : 'Enter password'}
|
||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||
className="w-full px-3 pr-10 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
{displayError && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-600 bg-red-50 rounded-lg p-3">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 cursor-pointer"
|
||||
tabIndex={-1}
|
||||
onClick={handleBackToLogin}
|
||||
disabled={isLoggingIn}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTotpSubmit}
|
||||
disabled={isLoggingIn || totpCode.length !== 6}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
)}
|
||||
验证
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">
|
||||
{isRegister ? '注册 SaaS 账号' : '登录 SaaS 平台'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-5">
|
||||
{isRegister
|
||||
? '创建账号以使用 ZCLAW 云端服务'
|
||||
: '连接到 ZCLAW SaaS 平台,解锁云端能力'}
|
||||
</p>
|
||||
|
||||
{/* Confirm Password (Register only) */}
|
||||
{isRegister && (
|
||||
<div>
|
||||
<label htmlFor="saas-confirm-password" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
确认密码
|
||||
</label>
|
||||
<input
|
||||
id="saas-confirm-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Re-enter password"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
{/* Tab Switcher */}
|
||||
<div className="flex mb-5 border-b border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabSwitch(false)}
|
||||
className={`px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors border-b-2 ${
|
||||
!isRegister
|
||||
? 'border-emerald-500 text-emerald-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<LogIn className="w-3.5 h-3.5" />
|
||||
登录
|
||||
</span>
|
||||
</button>
|
||||
{onRegister && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabSwitch(true)}
|
||||
className={`px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors border-b-2 ${
|
||||
isRegister
|
||||
? 'border-emerald-500 text-emerald-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
注册
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Server URL */}
|
||||
<div>
|
||||
<label htmlFor="saas-url" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
服务器地址
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
id="saas-url"
|
||||
type="url"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
placeholder="https://saas.zclaw.com"
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label htmlFor="saas-username" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id="saas-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="your-username"
|
||||
autoComplete="username"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email (Register only) */}
|
||||
{isRegister && (
|
||||
<div>
|
||||
<label htmlFor="saas-email" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
邮箱
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
id="saas-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display Name (Register only, optional) */}
|
||||
{isRegister && (
|
||||
<div>
|
||||
<label htmlFor="saas-display-name" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
显示名称 <span className="text-gray-400 font-normal">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
id="saas-display-name"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="ZCLAW User"
|
||||
autoComplete="name"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="saas-password" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="saas-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={isRegister ? '至少 6 个字符' : 'Enter password'}
|
||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||
className="w-full px-3 pr-10 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 cursor-pointer"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password (Register only) */}
|
||||
{isRegister && (
|
||||
<div>
|
||||
<label htmlFor="saas-confirm-password" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
确认密码
|
||||
</label>
|
||||
<input
|
||||
id="saas-confirm-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Re-enter password"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{displayError && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-600 bg-red-50 rounded-lg p-3">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{displayError && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-600 bg-red-50 rounded-lg p-3">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoggingIn}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{isRegister ? '注册中...' : '登录中...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isRegister ? (
|
||||
<><UserPlus className="w-4 h-4" />注册</>
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{isRegister ? '注册中...' : '登录中...'}
|
||||
</>
|
||||
) : (
|
||||
<><LogIn className="w-4 h-4" />登录</>
|
||||
<>
|
||||
{isRegister ? (
|
||||
<><UserPlus className="w-4 h-4" />注册</>
|
||||
) : (
|
||||
<><LogIn className="w-4 h-4" />登录</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useSaaSStore } from '../../store/saasStore';
|
||||
import { SaaSLogin } from './SaaSLogin';
|
||||
import { SaaSStatus } from './SaaSStatus';
|
||||
import { ConfigMigrationWizard } from './ConfigMigrationWizard';
|
||||
import { TOTPSettings } from './TOTPSettings';
|
||||
import { RelayTasksPanel } from './RelayTasksPanel';
|
||||
import { Cloud, Info, KeyRound } from 'lucide-react';
|
||||
import { saasClient } from '../../lib/saas-client';
|
||||
|
||||
@@ -12,8 +14,10 @@ export function SaaSSettings() {
|
||||
const saasUrl = useSaaSStore((s) => s.saasUrl);
|
||||
const connectionMode = useSaaSStore((s) => s.connectionMode);
|
||||
const login = useSaaSStore((s) => s.login);
|
||||
const loginWithTotp = useSaaSStore((s) => s.loginWithTotp);
|
||||
const register = useSaaSStore((s) => s.register);
|
||||
const logout = useSaaSStore((s) => s.logout);
|
||||
const totpRequired = useSaaSStore((s) => s.totpRequired);
|
||||
|
||||
const [showLogin, setShowLogin] = useState(!isLoggedIn);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
@@ -24,6 +28,9 @@ export function SaaSSettings() {
|
||||
setLoginError(null);
|
||||
try {
|
||||
await login(url, username, password);
|
||||
if (useSaaSStore.getState().totpRequired) {
|
||||
return;
|
||||
}
|
||||
setShowLogin(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '登录失败';
|
||||
@@ -33,6 +40,20 @@ export function SaaSSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginWithTotp = async (url: string, username: string, password: string, totpCode: string) => {
|
||||
setIsLoggingIn(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
await loginWithTotp(url, username, password, totpCode);
|
||||
setShowLogin(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'TOTP 验证失败';
|
||||
setLoginError(message);
|
||||
} finally {
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (
|
||||
url: string,
|
||||
username: string,
|
||||
@@ -93,9 +114,11 @@ export function SaaSSettings() {
|
||||
) : (
|
||||
<SaaSLogin
|
||||
onLogin={handleLogin}
|
||||
onLoginWithTotp={handleLoginWithTotp}
|
||||
onRegister={handleRegister}
|
||||
initialUrl={saasUrl}
|
||||
isLoggingIn={isLoggingIn}
|
||||
totpRequired={totpRequired}
|
||||
error={loginError}
|
||||
/>
|
||||
)}
|
||||
@@ -131,6 +154,26 @@ export function SaaSSettings() {
|
||||
{/* Password change section */}
|
||||
{isLoggedIn && !showLogin && <ChangePasswordSection />}
|
||||
|
||||
{/* TOTP 2FA */}
|
||||
{isLoggedIn && !showLogin && (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
双因素认证
|
||||
</h2>
|
||||
<TOTPSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Relay tasks */}
|
||||
{isLoggedIn && !showLogin && (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
中转任务
|
||||
</h2>
|
||||
<RelayTasksPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config migration wizard */}
|
||||
{isLoggedIn && !showLogin && (
|
||||
<div className="mt-6">
|
||||
|
||||
285
desktop/src/components/SaaS/TOTPSettings.tsx
Normal file
285
desktop/src/components/SaaS/TOTPSettings.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useState } from 'react';
|
||||
import { useSaaSStore } from '../../store/saasStore';
|
||||
import { Shield, ShieldCheck, ShieldOff, Copy, Check, Loader2, AlertCircle, X } from 'lucide-react';
|
||||
|
||||
export function TOTPSettings() {
|
||||
const account = useSaaSStore((s) => s.account);
|
||||
const totpSetupData = useSaaSStore((s) => s.totpSetupData);
|
||||
const isLoading = useSaaSStore((s) => s.isLoading);
|
||||
const storeError = useSaaSStore((s) => s.error);
|
||||
const setupTotp = useSaaSStore((s) => s.setupTotp);
|
||||
const verifyTotp = useSaaSStore((s) => s.verifyTotp);
|
||||
const disableTotp = useSaaSStore((s) => s.disableTotp);
|
||||
const cancelTotpSetup = useSaaSStore((s) => s.cancelTotpSetup);
|
||||
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
const [disablePassword, setDisablePassword] = useState('');
|
||||
const [showDisable, setShowDisable] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [copiedSecret, setCopiedSecret] = useState(false);
|
||||
|
||||
const displayError = storeError || localError;
|
||||
const isEnabled = account?.totp_enabled ?? false;
|
||||
const isSettingUp = !!totpSetupData;
|
||||
|
||||
const handleSetup = async () => {
|
||||
setLocalError(null);
|
||||
setSuccess(null);
|
||||
setVerifyCode('');
|
||||
try {
|
||||
await setupTotp();
|
||||
} catch {
|
||||
// error already in store
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (verifyCode.length !== 6) return;
|
||||
setLocalError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
await verifyTotp(verifyCode);
|
||||
setVerifyCode('');
|
||||
setSuccess('TOTP 已成功启用');
|
||||
} catch {
|
||||
// error already in store
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
if (!disablePassword) {
|
||||
setLocalError('请输入密码确认');
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
await disableTotp(disablePassword);
|
||||
setDisablePassword('');
|
||||
setShowDisable(false);
|
||||
setSuccess('TOTP 已成功禁用');
|
||||
} catch {
|
||||
// error already in store
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySecret = async () => {
|
||||
if (!totpSetupData) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(totpSetupData.secret);
|
||||
setCopiedSecret(true);
|
||||
setTimeout(() => setCopiedSecret(false), 2000);
|
||||
} catch {
|
||||
// clipboard API not available
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cancelTotpSetup();
|
||||
setVerifyCode('');
|
||||
setLocalError(null);
|
||||
};
|
||||
|
||||
// Setup flow: QR code + verify code input
|
||||
if (isSettingUp) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">设置双因素认证</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="text-gray-400 hover:text-gray-600 cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
使用 Google Authenticator / Authy 扫描下方二维码,然后输入验证码完成绑定。
|
||||
</p>
|
||||
|
||||
{/* QR Code */}
|
||||
<div className="flex flex-col items-center gap-3 py-2">
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(totpSetupData.otpauth_uri)}&size=200x200`}
|
||||
alt="TOTP QR Code"
|
||||
className="w-48 h-48 border border-gray-200 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Manual secret */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">手动输入密钥:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-2 py-1 bg-gray-50 rounded text-xs font-mono text-gray-700 break-all">
|
||||
{totpSetupData.secret}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopySecret}
|
||||
className="flex-shrink-0 p-1 text-gray-400 hover:text-emerald-600 cursor-pointer"
|
||||
title="复制密钥"
|
||||
>
|
||||
{copiedSecret ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verify code input */}
|
||||
<div>
|
||||
<label htmlFor="totp-verify-code" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
验证码
|
||||
</label>
|
||||
<input
|
||||
id="totp-verify-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="输入 6 位验证码"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono tracking-widest text-center focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && verifyCode.length === 6) handleVerify();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{displayError && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-600 bg-red-50 rounded-lg p-3">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
className="flex-1 px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVerify}
|
||||
disabled={isLoading || verifyCode.length !== 6}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg transition-colors 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isEnabled ? (
|
||||
<ShieldCheck className="w-5 h-5 text-emerald-600" />
|
||||
) : (
|
||||
<ShieldOff className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
<h3 className="text-sm font-semibold text-gray-900">双因素认证</h3>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
isEnabled ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{isEnabled ? '已启用' : '未启用'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
{isEnabled
|
||||
? '你的账号已启用双因素认证,登录时需要输入 TOTP 验证码。'
|
||||
: '启用双因素认证可以增强账号安全性。'}
|
||||
</p>
|
||||
|
||||
{displayError && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-600 bg-red-50 rounded-lg p-3">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="flex items-start gap-2 text-sm text-emerald-600 bg-emerald-50 rounded-lg p-3">
|
||||
<Check className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEnabled && !showDisable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetup}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Shield className="w-4 h-4" />}
|
||||
启用 TOTP
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isEnabled && !showDisable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDisable(true)}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 text-sm text-red-600 border border-red-300 rounded-lg hover:bg-red-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
禁用 TOTP
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showDisable && (
|
||||
<div className="space-y-3 p-3 bg-red-50 rounded-lg border border-red-200">
|
||||
<p className="text-sm text-red-700">禁用 TOTP 将降低账号安全性,请输入密码确认:</p>
|
||||
<input
|
||||
type="password"
|
||||
value={disablePassword}
|
||||
onChange={(e) => setDisablePassword(e.target.value)}
|
||||
placeholder="输入当前密码"
|
||||
autoComplete="current-password"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-500 bg-white text-gray-900"
|
||||
disabled={isLoading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleDisable();
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowDisable(false); setDisablePassword(''); setLocalError(null); }}
|
||||
disabled={isLoading}
|
||||
className="flex-1 px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDisable}
|
||||
disabled={isLoading || !disablePassword}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-1.5 text-sm text-red-600 border border-red-300 rounded-lg hover:bg-red-100 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null}
|
||||
确认禁用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user