Files
zclaw_openfang/desktop/src/store/saasStore.ts
iven 7de294375b
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
feat(auth): 添加异步密码哈希和验证函数
refactor(relay): 复用HTTP客户端和请求体序列化结果

feat(kernel): 添加获取单个审批记录的方法

fix(store): 改进SaaS连接错误分类和降级处理

docs: 更新审计文档和系统架构文档

refactor(prompt): 优化SQL查询参数化绑定

refactor(migration): 使用静态SQL和COALESCE更新配置项

feat(commands): 添加审批执行状态追踪和事件通知

chore: 更新启动脚本以支持Admin后台

fix(auth-guard): 优化授权状态管理和错误处理

refactor(db): 使用异步密码哈希函数

refactor(totp): 使用异步密码验证函数

style: 清理无用文件和注释

docs: 更新功能全景和审计文档

refactor(service): 优化HTTP客户端重用和请求处理

fix(connection): 改进SaaS不可用时的降级处理

refactor(handlers): 使用异步密码验证函数

chore: 更新依赖和工具链配置
2026-03-29 21:45:29 +08:00

672 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SaaS Store - SaaS Platform Connection State Management
*
* Manages SaaS login state, account info, connection mode,
* and available models. Persists auth state to localStorage
* via saas-client helpers.
*
* Connection modes:
* - 'tauri': Local Kernel via Tauri (default)
* - 'gateway': External Gateway via WebSocket
* - 'saas': SaaS backend relay
*/
import { create } from 'zustand';
import {
saasClient,
SaaSApiError,
loadSaaSSession,
saveSaaSSession,
clearSaaSSession,
saveConnectionMode,
loadConnectionMode,
type SaaSAccountInfo,
type SaaSModelInfo,
type SaaSLoginResponse,
type TotpSetupResponse,
type SyncConfigRequest,
} from '../lib/saas-client';
import { createLogger } from '../lib/logger';
import {
initTelemetryCollector,
stopTelemetryCollector,
} from '../lib/telemetry-collector';
import {
startPromptOTASync,
stopPromptOTASync,
} from '../lib/llm-service';
const log = createLogger('SaaSStore');
// === Device ID ===
/** Generate or load a persistent device ID for this browser instance */
function getOrCreateDeviceId(): string {
const KEY = 'zclaw-device-id';
const existing = localStorage.getItem(KEY);
if (existing) return existing;
const newId = crypto.randomUUID();
localStorage.setItem(KEY, newId);
return newId;
}
const DEVICE_ID = getOrCreateDeviceId();
// === Types ===
export type ConnectionMode = 'tauri' | 'gateway' | 'saas';
export interface SaaSStateSlice {
isLoggedIn: boolean;
account: SaaSAccountInfo | null;
saasUrl: string;
authToken: string | null;
connectionMode: ConnectionMode;
availableModels: SaaSModelInfo[];
isLoading: boolean;
error: string | null;
totpRequired: boolean;
totpSetupData: TotpSetupResponse | null;
/** Whether SaaS backend is currently reachable */
saasReachable: boolean;
/** Consecutive heartbeat/health-check failures */
_consecutiveFailures: number;
_heartbeatTimer?: ReturnType<typeof setInterval>;
_healthCheckTimer?: ReturnType<typeof setInterval>;
}
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;
fetchAvailableModels: () => Promise<void>;
syncConfigFromSaaS: () => Promise<void>;
pushConfigToSaaS: () => Promise<void>;
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;
// === Constants ===
const DEFAULT_SAAS_URL = import.meta.env.DEV
? 'http://127.0.0.1:8080'
: 'https://saas.zclaw.com';
// === Helpers ===
/** Determine the initial connection mode from persisted state */
function resolveInitialMode(session: ReturnType<typeof loadSaaSSession>): ConnectionMode {
const persistedMode = loadConnectionMode();
if (persistedMode === 'tauri' || persistedMode === 'gateway' || persistedMode === 'saas') {
return persistedMode;
}
return session ? 'saas' : 'tauri';
}
// === Store Implementation ===
export const useSaaSStore = create<SaaSStore>((set, get) => {
// Restore session from localStorage on init
const session = loadSaaSSession();
const initialMode = resolveInitialMode(session);
// If session exists, configure the singleton client
if (session) {
saasClient.setBaseUrl(session.saasUrl);
saasClient.setToken(session.token);
}
return {
// === Initial State ===
isLoggedIn: session !== null,
account: session?.account ?? null,
saasUrl: session?.saasUrl ?? DEFAULT_SAAS_URL,
authToken: session?.token ?? null,
connectionMode: initialMode,
availableModels: [],
isLoading: false,
error: null,
totpRequired: false,
totpSetupData: null,
saasReachable: true,
_consecutiveFailures: 0,
// === Actions ===
login: async (saasUrl: string, username: string, password: string) => {
set({ isLoading: true, error: null });
const trimmedUrl = saasUrl.trim();
const trimmedUsername = username.trim();
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
const requestUrl = normalizedUrl || window.location.origin;
try {
// 空 trimmedUrl 表示走 Vite proxy开发模式允许通过
if (!trimmedUsername) {
throw new Error('请输入用户名');
}
if (!password) {
throw new Error('请输入密码');
}
// Configure singleton client and attempt login
saasClient.setBaseUrl(normalizedUrl);
const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password);
// Persist session
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,
});
// Register device and start heartbeat in background
get().registerCurrentDevice().catch((err: unknown) => {
log.warn('Failed to register device:', err);
});
// Fetch available models in background (non-blocking)
get().fetchAvailableModels().catch((err: unknown) => {
log.warn('Failed to fetch models after login:', err);
});
// Auto-pull SaaS config in background (non-blocking)
get().syncConfigFromSaaS().then(() => {
// After pull, push any locally modified configs back to SaaS
get().pushConfigToSaaS().catch((err: unknown) => {
log.warn('Failed to push config to SaaS:', err);
});
}).catch((err: unknown) => {
log.warn('Failed to sync config after login:', err);
});
// Initialize telemetry collector
initTelemetryCollector(DEVICE_ID);
// Start Prompt OTA sync (background, non-blocking)
startPromptOTASync(DEVICE_ID);
} 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
? err.message
: String(err);
const isTimeout = message.includes('signal timed out')
|| message.includes('Timeout')
|| message.includes('timed out')
|| message.includes('AbortError');
const isConnectionRefused = message.includes('Failed to fetch')
|| message.includes('NetworkError')
|| message.includes('ECONNREFUSED')
|| message.includes('connection refused');
const userMessage = isTimeout
? `连接 SaaS 服务器超时,请确认后端服务正在运行: ${requestUrl}`
: isConnectionRefused
? `无法连接到 SaaS 服务器,请确认后端服务已启动: ${requestUrl}`
: message;
set({ isLoading: false, error: userMessage });
throw new Error(userMessage);
}
},
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);
});
// Initialize telemetry collector
initTelemetryCollector(DEVICE_ID);
// Start Prompt OTA sync (background, non-blocking)
startPromptOTASync(DEVICE_ID);
} 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 });
try {
const trimmedUrl = saasUrl.trim();
// 空 trimmedUrl 表示走 Vite proxy开发模式允许通过
if (!username.trim()) {
throw new Error('请输入用户名');
}
if (!email.trim()) {
throw new Error('请输入邮箱');
}
if (!password) {
throw new Error('请输入密码');
}
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
saasClient.setBaseUrl(normalizedUrl);
const registerData: SaaSLoginResponse = await saasClient.register({
username: username.trim(),
email: email.trim(),
password,
display_name: displayName,
});
const sessionData = {
token: registerData.token,
account: registerData.account,
saasUrl: normalizedUrl,
};
saveSaaSSession(sessionData);
saveConnectionMode('saas');
set({
isLoggedIn: true,
account: registerData.account,
saasUrl: normalizedUrl,
authToken: registerData.token,
connectionMode: 'saas',
isLoading: false,
error: null,
});
get().registerCurrentDevice().catch((err: unknown) => {
log.warn('Failed to register device after register:', err);
});
get().fetchAvailableModels().catch((err: unknown) => {
log.warn('Failed to fetch models after register:', err);
});
// Initialize telemetry collector
initTelemetryCollector(DEVICE_ID);
// Start Prompt OTA sync
startPromptOTASync(DEVICE_ID);
} 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);
}
},
logout: () => {
saasClient.setToken(null);
clearSaaSSession();
saveConnectionMode('tauri');
stopTelemetryCollector();
stopPromptOTASync();
set({
isLoggedIn: false,
account: null,
authToken: null,
connectionMode: 'tauri',
availableModels: [],
error: null,
totpRequired: false,
totpSetupData: null,
});
},
setConnectionMode: (mode: ConnectionMode) => {
const { isLoggedIn } = get();
// Cannot switch to SaaS mode if not logged in
if (mode === 'saas' && !isLoggedIn) {
return;
}
saveConnectionMode(mode);
set({ connectionMode: mode });
},
fetchAvailableModels: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
if (!isLoggedIn || !authToken) {
set({ availableModels: [] });
return;
}
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
const models = await saasClient.listModels();
set({ availableModels: models });
} catch (err: unknown) {
log.warn('Failed to fetch available models:', err);
// Do not set error state - model fetch failure is non-critical
set({ availableModels: [] });
}
},
/**
* Push locally modified configs to SaaS (push direction of bidirectional sync).
* Collects all "dirty" config keys, computes diff, and syncs via merge.
*/
pushConfigToSaaS: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
if (!isLoggedIn || !authToken) return;
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
// Collect all dirty config keys
const dirtyKeys: string[] = [];
const dirtyValues: Record<string, unknown> = {};
let i = 0;
while (true) {
const key = localStorage.key(i);
if (!key) break;
i++;
if (key.startsWith('zclaw-config-dirty.') && localStorage.getItem(key) === '1') {
const configKey = key.replace('zclaw-config-dirty.', '');
const storageKey = `zclaw-config.${configKey}`;
const value = localStorage.getItem(storageKey);
if (value !== null) {
dirtyKeys.push(configKey);
dirtyValues[configKey] = value;
}
}
}
if (dirtyKeys.length === 0) return;
// Generate a client fingerprint
const fingerprint = DEVICE_ID;
const syncRequest = {
client_fingerprint: fingerprint,
action: 'merge' as const,
config_keys: dirtyKeys,
client_values: dirtyValues,
};
// Compute diff first (dry run)
const diff = await saasClient.computeConfigDiff(syncRequest as SyncConfigRequest);
if (diff.conflicts > 0) {
log.warn(`Config sync has ${diff.conflicts} conflicts, using merge strategy`);
}
// Perform actual sync
const result = await saasClient.syncConfig(syncRequest);
log.info(`Config push result: ${result.updated} updated, ${result.created} created, ${result.skipped} skipped`);
// Clear dirty flags for successfully synced keys
for (const key of dirtyKeys) {
localStorage.removeItem(`zclaw-config-dirty.${key}`);
}
} catch (err: unknown) {
log.warn('Failed to push config to SaaS:', err);
}
},
/** Pull SaaS config and apply to local storage (startup auto-sync) */
syncConfigFromSaaS: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
if (!isLoggedIn || !authToken) return;
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
// Read last sync timestamp from localStorage
const lastSyncKey = 'zclaw-config-last-sync';
const lastSync = localStorage.getItem(lastSyncKey) || undefined;
const result = await saasClient.pullConfig(lastSync);
if (result.configs.length === 0) {
log.info('No config updates from SaaS');
return;
}
// Apply SaaS config values to localStorage
// Each config is stored as zclaw-config.{category}.{key}
for (const config of result.configs) {
if (config.value === null) continue;
const storageKey = `zclaw-config.${config.category}.${config.key}`;
const existing = localStorage.getItem(storageKey);
// Diff check: skip if local was modified since last pull
const dirtyKey = `zclaw-config-dirty.${config.category}.${config.key}`;
const lastPulledValue = localStorage.getItem(`zclaw-config-pulled.${config.category}.${config.key}`);
if (dirtyKey && localStorage.getItem(dirtyKey) === '1') {
// Local was modified since last pull → keep local, skip overwrite
log.warn(`Config conflict, keeping local: ${config.key}`);
continue;
}
// If existing value differs from what we last pulled AND differs from SaaS, local was modified
if (existing !== null && lastPulledValue !== null && existing !== lastPulledValue && existing !== config.value) {
log.warn(`Config conflict (local modified), keeping local: ${config.key}`);
continue;
}
// Only update if the value has actually changed
if (existing !== config.value) {
localStorage.setItem(storageKey, config.value);
// Record the pulled value for future diff checks
localStorage.setItem(`zclaw-config-pulled.${config.category}.${config.key}`, config.value);
log.info(`Config synced: ${config.key}`);
}
}
// Update last sync timestamp
localStorage.setItem(lastSyncKey, result.pulled_at);
log.info(`Synced ${result.configs.length} config items from SaaS`);
} catch (err: unknown) {
log.warn('Failed to sync config from SaaS:', err);
}
},
registerCurrentDevice: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
if (!isLoggedIn || !authToken) {
return;
}
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
await saasClient.registerDevice({
device_id: DEVICE_ID,
device_name: `${navigator.userAgent.split(' ').slice(0, 3).join(' ')}`,
platform: navigator.platform,
app_version: (typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown'),
});
log.info('Device registered successfully');
// Start periodic heartbeat (every 5 minutes) with failure tracking
if (typeof window !== 'undefined' && !get()._heartbeatTimer) {
const DEGRADE_AFTER_FAILURES = 3; // Degrade after 3 consecutive failures (~15 min)
const timer = window.setInterval(async () => {
const state = get();
if (!state.isLoggedIn || !state.authToken) {
window.clearInterval(timer);
return;
}
try {
await saasClient.deviceHeartbeat(DEVICE_ID);
// Reset failure count on success
if (state._consecutiveFailures > 0) {
log.info(`Heartbeat recovered after ${state._consecutiveFailures} failures`);
}
set({ _consecutiveFailures: 0, saasReachable: true } as unknown as Partial<SaaSStore>);
} catch (err) {
const failures = state._consecutiveFailures + 1;
log.warn(`Heartbeat failed (${failures}/${DEGRADE_AFTER_FAILURES}): ${err}`);
set({ _consecutiveFailures: failures } as unknown as Partial<SaaSStore>);
// Auto-degrade to local mode after threshold
if (failures >= DEGRADE_AFTER_FAILURES && state.connectionMode === 'saas') {
log.warn(`SaaS unreachable after ${failures} attempts — degrading to local mode`);
set({
saasReachable: false,
connectionMode: 'tauri',
} as unknown as Partial<SaaSStore>);
saveConnectionMode('tauri');
}
}
}, 5 * 60 * 1000);
set({ _heartbeatTimer: timer, _consecutiveFailures: 0 } as unknown as Partial<SaaSStore>);
}
} catch (err: unknown) {
log.warn('Failed to register device:', err);
}
},
clearError: () => {
set({ error: null });
},
restoreSession: () => {
const restored = loadSaaSSession();
if (restored) {
saasClient.setBaseUrl(restored.saasUrl);
saasClient.setToken(restored.token);
set({
isLoggedIn: true,
account: restored.account,
saasUrl: restored.saasUrl,
authToken: restored.token,
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
});
get().fetchAvailableModels().catch(() => {});
get().syncConfigFromSaaS().then(() => {
get().pushConfigToSaaS().catch(() => {});
}).catch(() => {});
initTelemetryCollector(DEVICE_ID);
startPromptOTASync(DEVICE_ID);
}
},
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 });
},
};
});