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,6 +122,73 @@ export function SaaSLogin({ onLogin, onRegister, initialUrl, isLoggingIn, error
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
|
||||
{/* 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>
|
||||
<p className="text-sm text-gray-500">
|
||||
此账号已启用双因素认证,请输入 TOTP 验证码。
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label htmlFor="totp-code" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
TOTP 验证码
|
||||
</label>
|
||||
<input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
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>
|
||||
|
||||
{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={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"
|
||||
>
|
||||
<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>
|
||||
@@ -290,6 +387,8 @@ export function SaaSLogin({ onLogin, onRegister, initialUrl, isLoggingIn, error
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,20 @@ interface SaaSRefreshResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** TOTP setup response from POST /api/v1/auth/totp/setup */
|
||||
export interface TotpSetupResponse {
|
||||
otpauth_uri: string;
|
||||
secret: string;
|
||||
issuer: string;
|
||||
}
|
||||
|
||||
/** TOTP verify/disable response */
|
||||
export interface TotpResultResponse {
|
||||
ok: boolean;
|
||||
totp_enabled: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Device info stored on the SaaS backend */
|
||||
export interface DeviceInfo {
|
||||
id: string;
|
||||
@@ -83,6 +97,55 @@ export interface DeviceInfo {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Relay task info from GET /api/v1/relay/tasks */
|
||||
export interface RelayTaskInfo {
|
||||
id: string;
|
||||
account_id: string;
|
||||
provider_id: string;
|
||||
model_id: string;
|
||||
status: string;
|
||||
priority: number;
|
||||
attempt_count: number;
|
||||
max_attempts: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
error_message: string | null;
|
||||
queued_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Config diff request for POST /api/v1/config/diff and /sync */
|
||||
export interface SyncConfigRequest {
|
||||
client_fingerprint: string;
|
||||
action: 'push' | 'merge';
|
||||
config_keys: string[];
|
||||
client_values: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A single config diff entry */
|
||||
export interface ConfigDiffItem {
|
||||
key_path: string;
|
||||
client_value: string | null;
|
||||
saas_value: string | null;
|
||||
conflict: boolean;
|
||||
}
|
||||
|
||||
/** Config diff response */
|
||||
export interface ConfigDiffResponse {
|
||||
items: ConfigDiffItem[];
|
||||
total_keys: number;
|
||||
conflicts: number;
|
||||
}
|
||||
|
||||
/** Config sync result */
|
||||
export interface ConfigSyncResult {
|
||||
updated: number;
|
||||
created: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
// === Error Class ===
|
||||
|
||||
export class SaaSApiError extends Error {
|
||||
@@ -210,7 +273,7 @@ export class SaaSClient {
|
||||
* Retries up to 2 times with exponential backoff (1s, 2s).
|
||||
* Throws SaaSApiError on non-ok responses.
|
||||
*/
|
||||
private async request<T>(
|
||||
public async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
@@ -298,9 +361,11 @@ export class SaaSClient {
|
||||
* Login with username and password.
|
||||
* Auto-sets the client token on success.
|
||||
*/
|
||||
async login(username: string, password: string): Promise<SaaSLoginResponse> {
|
||||
async login(username: string, password: string, totpCode?: string): Promise<SaaSLoginResponse> {
|
||||
const body: Record<string, string> = { username, password };
|
||||
if (totpCode) body.totp_code = totpCode;
|
||||
const data = await this.request<SaaSLoginResponse>(
|
||||
'POST', '/api/v1/auth/login', { username, password },
|
||||
'POST', '/api/v1/auth/login', body,
|
||||
);
|
||||
this.token = data.token;
|
||||
return data;
|
||||
@@ -350,6 +415,23 @@ export class SaaSClient {
|
||||
});
|
||||
}
|
||||
|
||||
// --- TOTP Endpoints ---
|
||||
|
||||
/** Generate a TOTP secret and otpauth URI */
|
||||
async setupTotp(): Promise<TotpSetupResponse> {
|
||||
return this.request<TotpSetupResponse>('POST', '/api/v1/auth/totp/setup');
|
||||
}
|
||||
|
||||
/** Verify a TOTP code and enable 2FA */
|
||||
async verifyTotp(code: string): Promise<TotpResultResponse> {
|
||||
return this.request<TotpResultResponse>('POST', '/api/v1/auth/totp/verify', { code });
|
||||
}
|
||||
|
||||
/** Disable 2FA (requires password confirmation) */
|
||||
async disableTotp(password: string): Promise<TotpResultResponse> {
|
||||
return this.request<TotpResultResponse>('POST', '/api/v1/auth/totp/disable', { password });
|
||||
}
|
||||
|
||||
// --- Device Endpoints ---
|
||||
|
||||
/**
|
||||
@@ -391,6 +473,28 @@ export class SaaSClient {
|
||||
return this.request<SaaSModelInfo[]>('GET', '/api/v1/relay/models');
|
||||
}
|
||||
|
||||
// --- Relay Task Management ---
|
||||
|
||||
/** List relay tasks for the current user */
|
||||
async listRelayTasks(query?: { status?: string; page?: number; page_size?: number }): Promise<RelayTaskInfo[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.status) params.set('status', query.status);
|
||||
if (query?.page) params.set('page', String(query.page));
|
||||
if (query?.page_size) params.set('page_size', String(query.page_size));
|
||||
const qs = params.toString();
|
||||
return this.request<RelayTaskInfo[]>('GET', `/api/v1/relay/tasks${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
/** Get a single relay task */
|
||||
async getRelayTask(taskId: string): Promise<RelayTaskInfo> {
|
||||
return this.request<RelayTaskInfo>('GET', `/api/v1/relay/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
/** Retry a failed relay task (admin only) */
|
||||
async retryRelayTask(taskId: string): Promise<{ ok: boolean; task_id: string }> {
|
||||
return this.request<{ ok: boolean; task_id: string }>('POST', `/api/v1/relay/tasks/${taskId}/retry`);
|
||||
}
|
||||
|
||||
// --- Chat Relay ---
|
||||
|
||||
/**
|
||||
@@ -437,6 +541,16 @@ export class SaaSClient {
|
||||
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||
return this.request<SaaSConfigItem[]>('GET', `/api/v1/config/items${qs}`);
|
||||
}
|
||||
|
||||
/** Compute config diff between client and SaaS (read-only) */
|
||||
async computeConfigDiff(request: SyncConfigRequest): Promise<ConfigDiffResponse> {
|
||||
return this.request<ConfigDiffResponse>('POST', '/api/v1/config/diff', request);
|
||||
}
|
||||
|
||||
/** Sync config from client to SaaS (push) or merge */
|
||||
async syncConfig(request: SyncConfigRequest): Promise<ConfigSyncResult> {
|
||||
return this.request<ConfigSyncResult>('POST', '/api/v1/config/sync', request);
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type SaaSAccountInfo,
|
||||
type SaaSModelInfo,
|
||||
type SaaSLoginResponse,
|
||||
type TotpSetupResponse,
|
||||
} from '../lib/saas-client';
|
||||
import { createLogger } from '../lib/logger';
|
||||
|
||||
@@ -55,10 +56,13 @@ export interface SaaSStateSlice {
|
||||
availableModels: SaaSModelInfo[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
totpRequired: boolean;
|
||||
totpSetupData: TotpSetupResponse | null;
|
||||
}
|
||||
|
||||
export interface SaaSActionsSlice {
|
||||
login: (saasUrl: string, username: string, password: string) => Promise<void>;
|
||||
loginWithTotp: (saasUrl: string, username: string, password: string, totpCode: string) => Promise<void>;
|
||||
register: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
setConnectionMode: (mode: ConnectionMode) => void;
|
||||
@@ -66,6 +70,10 @@ export interface SaaSActionsSlice {
|
||||
registerCurrentDevice: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
restoreSession: () => void;
|
||||
setupTotp: () => Promise<TotpSetupResponse>;
|
||||
verifyTotp: (code: string) => Promise<void>;
|
||||
disableTotp: (password: string) => Promise<void>;
|
||||
cancelTotpSetup: () => void;
|
||||
}
|
||||
|
||||
export type SaaSStore = SaaSStateSlice & SaaSActionsSlice;
|
||||
@@ -108,6 +116,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
availableModels: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
totpRequired: false,
|
||||
totpSetupData: null,
|
||||
|
||||
// === Actions ===
|
||||
|
||||
@@ -163,6 +173,12 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
log.warn('Failed to fetch models after login:', err);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Check for TOTP required signal
|
||||
if (err instanceof SaaSApiError && err.code === 'TOTP_ERROR' && err.status === 400) {
|
||||
set({ isLoading: false, totpRequired: true, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const message = err instanceof SaaSApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
@@ -183,6 +199,47 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
loginWithTotp: async (saasUrl: string, username: string, password: string, totpCode: string) => {
|
||||
set({ isLoading: true, error: null, totpRequired: false });
|
||||
|
||||
try {
|
||||
const normalizedUrl = saasUrl.trim().replace(/\/+$/, '');
|
||||
saasClient.setBaseUrl(normalizedUrl);
|
||||
const loginData = await saasClient.login(username.trim(), password, totpCode);
|
||||
|
||||
const sessionData = {
|
||||
token: loginData.token,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
};
|
||||
saveSaaSSession(sessionData);
|
||||
saveConnectionMode('saas');
|
||||
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
authToken: loginData.token,
|
||||
connectionMode: 'saas',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
totpRequired: false,
|
||||
});
|
||||
|
||||
get().registerCurrentDevice().catch((err: unknown) => {
|
||||
log.warn('Failed to register device:', err);
|
||||
});
|
||||
get().fetchAvailableModels().catch((err: unknown) => {
|
||||
log.warn('Failed to fetch models:', err);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError ? err.message
|
||||
: err instanceof Error ? err.message : String(err);
|
||||
set({ isLoading: false, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
register: async (saasUrl: string, username: string, email: string, password: string, displayName?: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
@@ -260,6 +317,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
connectionMode: 'tauri',
|
||||
availableModels: [],
|
||||
error: null,
|
||||
totpRequired: false,
|
||||
totpSetupData: null,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -346,7 +405,62 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
authToken: restored.token,
|
||||
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
|
||||
});
|
||||
get().fetchAvailableModels().catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
setupTotp: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const setupData = await saasClient.setupTotp();
|
||||
set({ totpSetupData: setupData, isLoading: false });
|
||||
return setupData;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError ? err.message
|
||||
: err instanceof Error ? err.message : String(err);
|
||||
set({ isLoading: false, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
verifyTotp: async (code: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await saasClient.verifyTotp(code);
|
||||
const account = await saasClient.me();
|
||||
const { saasUrl, authToken } = get();
|
||||
if (authToken) {
|
||||
saveSaaSSession({ token: authToken, account, saasUrl });
|
||||
}
|
||||
set({ totpSetupData: null, isLoading: false, account });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError ? err.message
|
||||
: err instanceof Error ? err.message : String(err);
|
||||
set({ isLoading: false, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
disableTotp: async (password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await saasClient.disableTotp(password);
|
||||
const account = await saasClient.me();
|
||||
const { saasUrl, authToken } = get();
|
||||
if (authToken) {
|
||||
saveSaaSSession({ token: authToken, account, saasUrl });
|
||||
}
|
||||
set({ isLoading: false, account });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError ? err.message
|
||||
: err instanceof Error ? err.message : String(err);
|
||||
set({ isLoading: false, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
cancelTotpSetup: () => {
|
||||
set({ totpSetupData: null });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user