From 4d8d560d1fdda249abf7b8875db747dd4eb161c2 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 27 Mar 2026 18:20:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(saas):=20=E6=A1=8C=E9=9D=A2=E7=AB=AF=20P2?= =?UTF-8?q?=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E8=A1=A5=E9=BD=90=20=E2=80=94=20?= =?UTF-8?q?TOTP=202FA=E3=80=81Relay=20=E4=BB=BB=E5=8A=A1=E3=80=81Config=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 面板到设置页 --- .../src/components/SaaS/RelayTasksPanel.tsx | 190 ++++++++ desktop/src/components/SaaS/SaaSLogin.tsx | 459 +++++++++++------- desktop/src/components/SaaS/SaaSSettings.tsx | 43 ++ desktop/src/components/SaaS/TOTPSettings.tsx | 285 +++++++++++ desktop/src/lib/saas-client.ts | 120 ++++- desktop/src/store/saasStore.ts | 114 +++++ 6 files changed, 1028 insertions(+), 183 deletions(-) create mode 100644 desktop/src/components/SaaS/RelayTasksPanel.tsx create mode 100644 desktop/src/components/SaaS/TOTPSettings.tsx diff --git a/desktop/src/components/SaaS/RelayTasksPanel.tsx b/desktop/src/components/SaaS/RelayTasksPanel.tsx new file mode 100644 index 0000000..4e50f57 --- /dev/null +++ b/desktop/src/components/SaaS/RelayTasksPanel.tsx @@ -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 = { + 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 ( + + + {c.text} + + ); +} + +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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [statusFilter, setStatusFilter] = useState(''); + const [retryingId, setRetryingId] = useState(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 ( +
+ {/* Header */} +
+

中转任务

+ +
+ + {/* Status filter tabs */} +
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ + {error && ( +
+ + {error} +
+ )} + + {isLoading && tasks.length === 0 ? ( +
+ + 加载中... +
+ ) : tasks.length === 0 ? ( +
+ 暂无中转任务 +
+ ) : ( +
+ {tasks.map((task) => ( +
+ {/* Status */} + + + {/* Info */} +
+
+ + {task.model_id} + + + {task.input_tokens > 0 || task.output_tokens > 0 + ? `(${task.input_tokens}in / ${task.output_tokens}out)` + : ''} + +
+ {task.error_message && ( +

+ {task.error_message} +

+ )} +
+ + {/* Time */} + + {formatTime(task.created_at)} + + + {/* Retry button (admin only, failed tasks only) */} + {isAdmin && task.status === 'failed' && ( + + )} +
+ ))} +
+ )} +
+ ); +} diff --git a/desktop/src/components/SaaS/SaaSLogin.tsx b/desktop/src/components/SaaS/SaaSLogin.tsx index e2d9758..545359c 100644 --- a/desktop/src/components/SaaS/SaaSLogin.tsx +++ b/desktop/src/components/SaaS/SaaSLogin.tsx @@ -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; + onLoginWithTotp?: (saasUrl: string, username: string, password: string, totpCode: string) => Promise; onRegister?: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise; 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(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 (
-

- {isRegister ? '注册 SaaS 账号' : '登录 SaaS 平台'} -

-

- {isRegister - ? '创建账号以使用 ZCLAW 云端服务' - : '连接到 ZCLAW SaaS 平台,解锁云端能力'} -

- - {/* Tab Switcher */} -
- - {onRegister && ( - - )} -
- - {/* Form */} -
- {/* Server URL */} -
- -
- - 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 ? ( +
+
+ +

双因素认证

-
+

+ 此账号已启用双因素认证,请输入 TOTP 验证码。 +

- {/* Username */} -
- - 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} - /> -
- - {/* Email (Register only) */} - {isRegister && (
- -
- - 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} - /> -
-
- )} - - {/* Display Name (Register only, optional) */} - {isRegister && ( -
-
- )} - {/* Password */} -
- -
- 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 && ( +
+ + {displayError} +
+ )} + +
+
+ ) : ( + <> +

+ {isRegister ? '注册 SaaS 账号' : '登录 SaaS 平台'} +

+

+ {isRegister + ? '创建账号以使用 ZCLAW 云端服务' + : '连接到 ZCLAW SaaS 平台,解锁云端能力'} +

- {/* Confirm Password (Register only) */} - {isRegister && ( -
- - 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 */} +
+ + {onRegister && ( + + )} +
+ + {/* Form */} + + {/* Server URL */} +
+ +
+ + 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} + /> +
+
+ + {/* Username */} +
+ + 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} + /> +
+ + {/* Email (Register only) */} + {isRegister && ( +
+ +
+ + 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} + /> +
+
+ )} + + {/* Display Name (Register only, optional) */} + {isRegister && ( +
+ + 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} + /> +
+ )} + + {/* Password */} +
+ +
+ 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} + /> + +
+
+ + {/* Confirm Password (Register only) */} + {isRegister && ( +
+ + 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} + /> +
+ )} + + {/* Error Display */} + {displayError && ( +
+ + {displayError} +
+ )} + + {/* Submit Button */} +
- )} - - {/* Error Display */} - {displayError && ( -
- - {displayError} -
- )} - - {/* Submit Button */} - - + + + + )}
); } diff --git a/desktop/src/components/SaaS/SaaSSettings.tsx b/desktop/src/components/SaaS/SaaSSettings.tsx index 8cddb2e..623b813 100644 --- a/desktop/src/components/SaaS/SaaSSettings.tsx +++ b/desktop/src/components/SaaS/SaaSSettings.tsx @@ -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(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() { ) : ( )} @@ -131,6 +154,26 @@ export function SaaSSettings() { {/* Password change section */} {isLoggedIn && !showLogin && } + {/* TOTP 2FA */} + {isLoggedIn && !showLogin && ( +
+

+ 双因素认证 +

+ +
+ )} + + {/* Relay tasks */} + {isLoggedIn && !showLogin && ( +
+

+ 中转任务 +

+ +
+ )} + {/* Config migration wizard */} {isLoggedIn && !showLogin && (
diff --git a/desktop/src/components/SaaS/TOTPSettings.tsx b/desktop/src/components/SaaS/TOTPSettings.tsx new file mode 100644 index 0000000..60aeaf2 --- /dev/null +++ b/desktop/src/components/SaaS/TOTPSettings.tsx @@ -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(null); + const [success, setSuccess] = useState(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 ( +
+
+
+ +

设置双因素认证

+
+ +
+ +

+ 使用 Google Authenticator / Authy 扫描下方二维码,然后输入验证码完成绑定。 +

+ + {/* QR Code */} +
+ TOTP QR Code +
+ + {/* Manual secret */} +
+

手动输入密钥:

+
+ + {totpSetupData.secret} + + +
+
+ + {/* Verify code input */} +
+ + 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(); + }} + /> +
+ + {displayError && ( +
+ + {displayError} +
+ )} + +
+ + +
+
+ ); + } + + return ( +
+
+
+ {isEnabled ? ( + + ) : ( + + )} +

双因素认证

+
+ + {isEnabled ? '已启用' : '未启用'} + +
+ +

+ {isEnabled + ? '你的账号已启用双因素认证,登录时需要输入 TOTP 验证码。' + : '启用双因素认证可以增强账号安全性。'} +

+ + {displayError && ( +
+ + {displayError} +
+ )} + + {success && ( +
+ + {success} +
+ )} + + {!isEnabled && !showDisable && ( + + )} + + {isEnabled && !showDisable && ( + + )} + + {showDisable && ( +
+

禁用 TOTP 将降低账号安全性,请输入密码确认:

+ 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(); + }} + /> +
+ + +
+
+ )} +
+ ); +} diff --git a/desktop/src/lib/saas-client.ts b/desktop/src/lib/saas-client.ts index 803dba9..150327d 100644 --- a/desktop/src/lib/saas-client.ts +++ b/desktop/src/lib/saas-client.ts @@ -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; +} + +/** 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( + public async request( 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 { + async login(username: string, password: string, totpCode?: string): Promise { + const body: Record = { username, password }; + if (totpCode) body.totp_code = totpCode; const data = await this.request( - '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 { + return this.request('POST', '/api/v1/auth/totp/setup'); + } + + /** Verify a TOTP code and enable 2FA */ + async verifyTotp(code: string): Promise { + return this.request('POST', '/api/v1/auth/totp/verify', { code }); + } + + /** Disable 2FA (requires password confirmation) */ + async disableTotp(password: string): Promise { + return this.request('POST', '/api/v1/auth/totp/disable', { password }); + } + // --- Device Endpoints --- /** @@ -391,6 +473,28 @@ export class SaaSClient { return this.request('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 { + 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('GET', `/api/v1/relay/tasks${qs ? '?' + qs : ''}`); + } + + /** Get a single relay task */ + async getRelayTask(taskId: string): Promise { + return this.request('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('GET', `/api/v1/config/items${qs}`); } + + /** Compute config diff between client and SaaS (read-only) */ + async computeConfigDiff(request: SyncConfigRequest): Promise { + return this.request('POST', '/api/v1/config/diff', request); + } + + /** Sync config from client to SaaS (push) or merge */ + async syncConfig(request: SyncConfigRequest): Promise { + return this.request('POST', '/api/v1/config/sync', request); + } } // === Singleton === diff --git a/desktop/src/store/saasStore.ts b/desktop/src/store/saasStore.ts index 03880ff..1567334 100644 --- a/desktop/src/store/saasStore.ts +++ b/desktop/src/store/saasStore.ts @@ -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; + loginWithTotp: (saasUrl: string, username: string, password: string, totpCode: string) => Promise; register: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise; logout: () => void; setConnectionMode: (mode: ConnectionMode) => void; @@ -66,6 +70,10 @@ export interface SaaSActionsSlice { registerCurrentDevice: () => Promise; clearError: () => void; restoreSession: () => void; + setupTotp: () => Promise; + verifyTotp: (code: string) => Promise; + disableTotp: (password: string) => Promise; + cancelTotpSetup: () => void; } export type SaaSStore = SaaSStateSlice & SaaSActionsSlice; @@ -108,6 +116,8 @@ export const useSaaSStore = create((set, get) => { availableModels: [], isLoading: false, error: null, + totpRequired: false, + totpSetupData: null, // === Actions === @@ -163,6 +173,12 @@ export const useSaaSStore = create((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((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((set, get) => { connectionMode: 'tauri', availableModels: [], error: null, + totpRequired: false, + totpSetupData: null, }); }, @@ -346,7 +405,62 @@ export const useSaaSStore = create((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 }); + }, }; });