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:
iven
2026-03-27 18:20:11 +08:00
parent 452ff45a5f
commit 4d8d560d1f
6 changed files with 1028 additions and 183 deletions

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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">

View 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>
);
}

View File

@@ -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 ===

View File

@@ -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 });
},
};
});