/** * 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, loadSaaSSessionSync, saveSaaSSession, clearSaaSSession, saveConnectionMode, loadConnectionMode, type SaaSAccountInfo, type SaaSModelInfo, type SaaSLoginResponse, type TotpSetupResponse, type SyncConfigRequest, type AgentTemplateAvailable, } 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; /** Agent templates available for onboarding */ availableTemplates: AgentTemplateAvailable[]; /** Consecutive heartbeat/health-check failures */ _consecutiveFailures: number; _heartbeatTimer?: ReturnType; _healthCheckTimer?: ReturnType; } 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: () => Promise; setConnectionMode: (mode: ConnectionMode) => void; fetchAvailableModels: () => Promise; syncConfigFromSaaS: () => Promise; pushConfigToSaaS: () => Promise; registerCurrentDevice: () => Promise; fetchAvailableTemplates: () => Promise; clearError: () => void; restoreSession: () => void; setupTotp: () => Promise; verifyTotp: (code: string) => Promise; disableTotp: (password: string) => Promise; 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(sessionMeta: { saasUrl: string; account: SaaSAccountInfo | null } | null): ConnectionMode { const persistedMode = loadConnectionMode(); if (persistedMode === 'tauri' || persistedMode === 'gateway' || persistedMode === 'saas') { return persistedMode; } return sessionMeta ? 'saas' : 'tauri'; } // === Store Implementation === export const useSaaSStore = create((set, get) => { // Restore session metadata synchronously (URL + account only). // Token is loaded from secure storage asynchronously by restoreSession(). const sessionMeta = loadSaaSSessionSync(); const initialMode = resolveInitialMode(sessionMeta); // If session URL exists, configure the singleton client base URL if (sessionMeta) { saasClient.setBaseUrl(sessionMeta.saasUrl); } return { // === Initial State === // isLoggedIn starts false — will be set to true by restoreSession() isLoggedIn: false, account: sessionMeta?.account ?? null, saasUrl: sessionMeta?.saasUrl ?? DEFAULT_SAAS_URL, authToken: null, // In-memory only — loaded from secure storage by restoreSession() connectionMode: initialMode, availableModels: [], isLoading: false, error: null, totpRequired: false, totpSetupData: null, saasReachable: true, availableTemplates: [], _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: token → secure storage (OS keyring), metadata → localStorage const sessionData = { token: loginData.token, // Will be stored in OS keyring by saveSaaSSession account: loginData.account, saasUrl: normalizedUrl, }; saveSaaSSession(sessionData); // async — fire and forget (non-blocking) saveConnectionMode('saas'); set({ isLoggedIn: true, account: loginData.account, saasUrl: normalizedUrl, authToken: null, // Not stored in Zustand state — saasClient holds in memory 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 templates in background (non-blocking) get().fetchAvailableTemplates().catch((err: unknown) => { log.warn('Failed to fetch templates after login:', 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: null, 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: null, 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: async () => { saasClient.setToken(null); await 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, saasUrl } = get(); if (!isLoggedIn) { set({ availableModels: [] }); return; } try { saasClient.setBaseUrl(saasUrl); 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, saasUrl } = get(); if (!isLoggedIn) return; try { saasClient.setBaseUrl(saasUrl); // Collect all dirty config keys const dirtyKeys: string[] = []; const dirtyValues: Record = {}; 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, saasUrl } = get(); if (!isLoggedIn) return; try { saasClient.setBaseUrl(saasUrl); // 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, saasUrl } = get(); if (!isLoggedIn) { return; } try { saasClient.setBaseUrl(saasUrl); 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) { 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); } catch (err) { const failures = state._consecutiveFailures + 1; log.warn(`Heartbeat failed (${failures}/${DEGRADE_AFTER_FAILURES}): ${err}`); set({ _consecutiveFailures: failures } as unknown as Partial); // 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); saveConnectionMode('tauri'); } } }, 5 * 60 * 1000); set({ _heartbeatTimer: timer, _consecutiveFailures: 0 } as unknown as Partial); } } catch (err: unknown) { log.warn('Failed to register device:', err); } }, fetchAvailableTemplates: async () => { try { const templates = await saasClient.fetchAvailableTemplates(); set({ availableTemplates: templates }); } catch { // Graceful degradation - don't block login set({ availableTemplates: [] }); } }, clearError: () => { set({ error: null }); }, restoreSession: async () => { const restored = await loadSaaSSession(); if (!restored) return; saasClient.setBaseUrl(restored.saasUrl); // Strategy: try secure storage token first, then cookie auth let account: SaaSAccountInfo | null = null; if (restored.token) { // Token from secure storage — use as Bearer saasClient.setToken(restored.token); try { account = await saasClient.me(); } catch { // Token expired — try cookie auth saasClient.setToken(null); } } if (!account) { // Try cookie-based auth (works for same-origin, e.g. admin panel) account = await saasClient.restoreFromCookie(); } if (!account) { // Neither token nor cookie works — user needs to re-login set({ isLoggedIn: false, account: null, saasUrl: restored.saasUrl, authToken: null, }); return; } set({ isLoggedIn: true, account, saasUrl: restored.saasUrl, authToken: restored.token, // In-memory from secure storage (null if cookie-only) 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 } = get(); saveSaaSSession({ token: null, account, saasUrl }); // Token in saasClient memory only 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 } = get(); saveSaaSSession({ token: null, 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 }); }, }; });