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 { 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 {
|
interface SaaSLoginProps {
|
||||||
onLogin: (saasUrl: string, username: string, password: string) => Promise<void>;
|
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>;
|
onRegister?: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
|
||||||
initialUrl?: string;
|
initialUrl?: string;
|
||||||
isLoggingIn?: boolean;
|
isLoggingIn?: boolean;
|
||||||
|
totpRequired?: boolean;
|
||||||
error?: string | null;
|
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 [serverUrl, setServerUrl] = useState(initialUrl || '');
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -19,6 +21,13 @@ export function SaaSLogin({ onLogin, onRegister, initialUrl, isLoggingIn, error
|
|||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isRegister, setIsRegister] = useState(false);
|
const [isRegister, setIsRegister] = useState(false);
|
||||||
const [localError, setLocalError] = useState<string | null>(null);
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -74,12 +83,33 @@ export function SaaSLogin({ onLogin, onRegister, initialUrl, isLoggingIn, error
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await onLogin(serverUrl.trim(), username.trim(), password);
|
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) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setLocalError(message);
|
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 displayError = error || localError;
|
||||||
|
|
||||||
const handleTabSwitch = (register: boolean) => {
|
const handleTabSwitch = (register: boolean) => {
|
||||||
@@ -92,6 +122,73 @@ export function SaaSLogin({ onLogin, onRegister, initialUrl, isLoggingIn, error
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
|
<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">
|
<h2 className="text-lg font-semibold text-gray-900 mb-1">
|
||||||
{isRegister ? '注册 SaaS 账号' : '登录 SaaS 平台'}
|
{isRegister ? '注册 SaaS 账号' : '登录 SaaS 平台'}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -290,6 +387,8 @@ export function SaaSLogin({ onLogin, onRegister, initialUrl, isLoggingIn, error
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useSaaSStore } from '../../store/saasStore';
|
|||||||
import { SaaSLogin } from './SaaSLogin';
|
import { SaaSLogin } from './SaaSLogin';
|
||||||
import { SaaSStatus } from './SaaSStatus';
|
import { SaaSStatus } from './SaaSStatus';
|
||||||
import { ConfigMigrationWizard } from './ConfigMigrationWizard';
|
import { ConfigMigrationWizard } from './ConfigMigrationWizard';
|
||||||
|
import { TOTPSettings } from './TOTPSettings';
|
||||||
|
import { RelayTasksPanel } from './RelayTasksPanel';
|
||||||
import { Cloud, Info, KeyRound } from 'lucide-react';
|
import { Cloud, Info, KeyRound } from 'lucide-react';
|
||||||
import { saasClient } from '../../lib/saas-client';
|
import { saasClient } from '../../lib/saas-client';
|
||||||
|
|
||||||
@@ -12,8 +14,10 @@ export function SaaSSettings() {
|
|||||||
const saasUrl = useSaaSStore((s) => s.saasUrl);
|
const saasUrl = useSaaSStore((s) => s.saasUrl);
|
||||||
const connectionMode = useSaaSStore((s) => s.connectionMode);
|
const connectionMode = useSaaSStore((s) => s.connectionMode);
|
||||||
const login = useSaaSStore((s) => s.login);
|
const login = useSaaSStore((s) => s.login);
|
||||||
|
const loginWithTotp = useSaaSStore((s) => s.loginWithTotp);
|
||||||
const register = useSaaSStore((s) => s.register);
|
const register = useSaaSStore((s) => s.register);
|
||||||
const logout = useSaaSStore((s) => s.logout);
|
const logout = useSaaSStore((s) => s.logout);
|
||||||
|
const totpRequired = useSaaSStore((s) => s.totpRequired);
|
||||||
|
|
||||||
const [showLogin, setShowLogin] = useState(!isLoggedIn);
|
const [showLogin, setShowLogin] = useState(!isLoggedIn);
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
@@ -24,6 +28,9 @@ export function SaaSSettings() {
|
|||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
try {
|
try {
|
||||||
await login(url, username, password);
|
await login(url, username, password);
|
||||||
|
if (useSaaSStore.getState().totpRequired) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setShowLogin(false);
|
setShowLogin(false);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : '登录失败';
|
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 (
|
const handleRegister = async (
|
||||||
url: string,
|
url: string,
|
||||||
username: string,
|
username: string,
|
||||||
@@ -93,9 +114,11 @@ export function SaaSSettings() {
|
|||||||
) : (
|
) : (
|
||||||
<SaaSLogin
|
<SaaSLogin
|
||||||
onLogin={handleLogin}
|
onLogin={handleLogin}
|
||||||
|
onLoginWithTotp={handleLoginWithTotp}
|
||||||
onRegister={handleRegister}
|
onRegister={handleRegister}
|
||||||
initialUrl={saasUrl}
|
initialUrl={saasUrl}
|
||||||
isLoggingIn={isLoggingIn}
|
isLoggingIn={isLoggingIn}
|
||||||
|
totpRequired={totpRequired}
|
||||||
error={loginError}
|
error={loginError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -131,6 +154,26 @@ export function SaaSSettings() {
|
|||||||
{/* Password change section */}
|
{/* Password change section */}
|
||||||
{isLoggedIn && !showLogin && <ChangePasswordSection />}
|
{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 */}
|
{/* Config migration wizard */}
|
||||||
{isLoggedIn && !showLogin && (
|
{isLoggedIn && !showLogin && (
|
||||||
<div className="mt-6">
|
<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;
|
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 */
|
/** Device info stored on the SaaS backend */
|
||||||
export interface DeviceInfo {
|
export interface DeviceInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -83,6 +97,55 @@ export interface DeviceInfo {
|
|||||||
created_at: string;
|
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 ===
|
// === Error Class ===
|
||||||
|
|
||||||
export class SaaSApiError extends Error {
|
export class SaaSApiError extends Error {
|
||||||
@@ -210,7 +273,7 @@ export class SaaSClient {
|
|||||||
* Retries up to 2 times with exponential backoff (1s, 2s).
|
* Retries up to 2 times with exponential backoff (1s, 2s).
|
||||||
* Throws SaaSApiError on non-ok responses.
|
* Throws SaaSApiError on non-ok responses.
|
||||||
*/
|
*/
|
||||||
private async request<T>(
|
public async request<T>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
@@ -298,9 +361,11 @@ export class SaaSClient {
|
|||||||
* Login with username and password.
|
* Login with username and password.
|
||||||
* Auto-sets the client token on success.
|
* 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>(
|
const data = await this.request<SaaSLoginResponse>(
|
||||||
'POST', '/api/v1/auth/login', { username, password },
|
'POST', '/api/v1/auth/login', body,
|
||||||
);
|
);
|
||||||
this.token = data.token;
|
this.token = data.token;
|
||||||
return data;
|
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 ---
|
// --- Device Endpoints ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -391,6 +473,28 @@ export class SaaSClient {
|
|||||||
return this.request<SaaSModelInfo[]>('GET', '/api/v1/relay/models');
|
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 ---
|
// --- Chat Relay ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -437,6 +541,16 @@ export class SaaSClient {
|
|||||||
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
|
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||||
return this.request<SaaSConfigItem[]>('GET', `/api/v1/config/items${qs}`);
|
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 ===
|
// === Singleton ===
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
type SaaSAccountInfo,
|
type SaaSAccountInfo,
|
||||||
type SaaSModelInfo,
|
type SaaSModelInfo,
|
||||||
type SaaSLoginResponse,
|
type SaaSLoginResponse,
|
||||||
|
type TotpSetupResponse,
|
||||||
} from '../lib/saas-client';
|
} from '../lib/saas-client';
|
||||||
import { createLogger } from '../lib/logger';
|
import { createLogger } from '../lib/logger';
|
||||||
|
|
||||||
@@ -55,10 +56,13 @@ export interface SaaSStateSlice {
|
|||||||
availableModels: SaaSModelInfo[];
|
availableModels: SaaSModelInfo[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
totpRequired: boolean;
|
||||||
|
totpSetupData: TotpSetupResponse | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaaSActionsSlice {
|
export interface SaaSActionsSlice {
|
||||||
login: (saasUrl: string, username: string, password: string) => Promise<void>;
|
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>;
|
register: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
setConnectionMode: (mode: ConnectionMode) => void;
|
setConnectionMode: (mode: ConnectionMode) => void;
|
||||||
@@ -66,6 +70,10 @@ export interface SaaSActionsSlice {
|
|||||||
registerCurrentDevice: () => Promise<void>;
|
registerCurrentDevice: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
restoreSession: () => void;
|
restoreSession: () => void;
|
||||||
|
setupTotp: () => Promise<TotpSetupResponse>;
|
||||||
|
verifyTotp: (code: string) => Promise<void>;
|
||||||
|
disableTotp: (password: string) => Promise<void>;
|
||||||
|
cancelTotpSetup: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SaaSStore = SaaSStateSlice & SaaSActionsSlice;
|
export type SaaSStore = SaaSStateSlice & SaaSActionsSlice;
|
||||||
@@ -108,6 +116,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
|||||||
availableModels: [],
|
availableModels: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
totpRequired: false,
|
||||||
|
totpSetupData: null,
|
||||||
|
|
||||||
// === Actions ===
|
// === Actions ===
|
||||||
|
|
||||||
@@ -163,6 +173,12 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
|||||||
log.warn('Failed to fetch models after login:', err);
|
log.warn('Failed to fetch models after login:', err);
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} 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
|
const message = err instanceof SaaSApiError
|
||||||
? err.message
|
? err.message
|
||||||
: err instanceof Error
|
: 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) => {
|
register: async (saasUrl: string, username: string, email: string, password: string, displayName?: string) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
@@ -260,6 +317,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
|||||||
connectionMode: 'tauri',
|
connectionMode: 'tauri',
|
||||||
availableModels: [],
|
availableModels: [],
|
||||||
error: null,
|
error: null,
|
||||||
|
totpRequired: false,
|
||||||
|
totpSetupData: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -346,7 +405,62 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
|||||||
authToken: restored.token,
|
authToken: restored.token,
|
||||||
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
|
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