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

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