feat: 新增管理后台前端项目及安全加固
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
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
refactor(saas): 重构认证中间件与限流策略
- 登录限流调整为5次/分钟/IP
- 注册限流调整为3次/小时/IP
- GET请求不计入限流
fix(saas): 修复调度器时间戳处理
- 使用NOW()替代文本时间戳
- 兼容TEXT和TIMESTAMPTZ列类型
feat(saas): 实现环境变量插值
- 支持${ENV_VAR}语法解析
- 数据库密码支持环境变量注入
chore: 新增前端管理界面
- 基于React+Ant Design Pro
- 包含路由守卫/错误边界
- 对接58个API端点
docs: 更新安全加固文档
- 新增密钥管理规范
- 记录P0安全项审计结果
- 补充TLS终止说明
test: 完善配置解析单元测试
- 新增环境变量插值测试用例
This commit is contained in:
@@ -428,9 +428,8 @@ static LAST_INTERACTION: OnceLock<RwLock<StdHashMap<String, String>>> = OnceLock
|
||||
pub struct MemoryStatsCache {
|
||||
pub task_count: usize,
|
||||
pub total_entries: usize,
|
||||
#[allow(dead_code)] // Reserved for UI display
|
||||
pub storage_size_bytes: usize,
|
||||
#[allow(dead_code)] // Reserved for UI display
|
||||
#[allow(dead_code)] // Reserved for UI display; will be exposed via heartbeat_get_memory_stats
|
||||
pub last_updated: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -539,7 +539,7 @@ pub type IdentityManagerState = Arc<Mutex<AgentIdentityManager>>;
|
||||
|
||||
/// Initialize identity manager
|
||||
#[tauri::command]
|
||||
#[allow(dead_code)] // Registered via invoke_handler! at runtime
|
||||
#[allow(dead_code)] // NOT registered in invoke_handler — identity state is initialized lazily via identity_get
|
||||
pub async fn identity_init() -> Result<IdentityManagerState, String> {
|
||||
Ok(Arc::new(Mutex::new(AgentIdentityManager::new())))
|
||||
}
|
||||
|
||||
@@ -16,29 +16,20 @@ use zclaw_runtime::driver::LlmDriver;
|
||||
|
||||
/// Run pre-conversation intelligence hooks
|
||||
///
|
||||
/// 1. Build memory context from VikingStorage (FTS5 + TF-IDF + Embedding)
|
||||
/// 2. Build identity-enhanced system prompt (SOUL.md + instructions)
|
||||
/// Builds identity-enhanced system prompt (SOUL.md + instructions).
|
||||
///
|
||||
/// Returns the enhanced system prompt that should be passed to the kernel.
|
||||
/// NOTE: Memory context injection is NOT done here — it is handled by
|
||||
/// `MemoryMiddleware.before_completion()` in the Kernel's middleware chain.
|
||||
/// Previously, both paths injected memories, causing duplicate injection.
|
||||
pub async fn pre_conversation_hook(
|
||||
agent_id: &str,
|
||||
user_message: &str,
|
||||
_user_message: &str,
|
||||
identity_state: &IdentityManagerState,
|
||||
) -> Result<String, String> {
|
||||
// Step 1: Build memory context from Viking storage
|
||||
let memory_context = match build_memory_context(agent_id, user_message).await {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[intelligence_hooks] Failed to build memory context for agent {}: {}",
|
||||
agent_id, e
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Build identity-enhanced system prompt
|
||||
let enhanced_prompt = match build_identity_prompt(agent_id, &memory_context, identity_state).await {
|
||||
// Build identity-enhanced system prompt (SOUL.md + instructions)
|
||||
// Memory context is injected by MemoryMiddleware in the kernel middleware chain,
|
||||
// not here, to avoid duplicate injection.
|
||||
let enhanced_prompt = match build_identity_prompt(agent_id, "", identity_state).await {
|
||||
Ok(prompt) => prompt,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -117,6 +108,10 @@ pub async fn post_conversation_hook(
|
||||
}
|
||||
|
||||
/// Build memory context by searching VikingStorage for relevant memories
|
||||
///
|
||||
/// NOTE: Memory injection is now handled by MemoryMiddleware in the Kernel
|
||||
/// middleware chain. This function is kept as a utility for ad-hoc queries.
|
||||
#[allow(dead_code)]
|
||||
async fn build_memory_context(
|
||||
agent_id: &str,
|
||||
user_message: &str,
|
||||
|
||||
@@ -16,6 +16,9 @@ use crate::intelligence::validation::{validate_identifier, validate_string_lengt
|
||||
/// Kernel state wrapper for Tauri
|
||||
pub type KernelState = Arc<Mutex<Option<Kernel>>>;
|
||||
|
||||
/// Scheduler state — holds a reference to the SchedulerService so it can be stopped on shutdown
|
||||
pub type SchedulerState = Arc<Mutex<Option<zclaw_kernel::scheduler::SchedulerService>>>;
|
||||
|
||||
/// Session-level stream concurrency guard.
|
||||
/// Prevents two concurrent `agent_chat_stream` calls from interleaving events
|
||||
/// for the same session_id.
|
||||
@@ -146,6 +149,7 @@ fn default_kernel_model() -> String { "gpt-4o-mini".to_string() }
|
||||
#[tauri::command]
|
||||
pub async fn kernel_init(
|
||||
state: State<'_, KernelState>,
|
||||
scheduler_state: State<'_, SchedulerState>,
|
||||
config_request: Option<KernelConfigRequest>,
|
||||
) -> Result<KernelStatusResponse, String> {
|
||||
let mut kernel_lock = state.lock().await;
|
||||
@@ -267,6 +271,22 @@ pub async fn kernel_init(
|
||||
|
||||
*kernel_lock = Some(kernel);
|
||||
|
||||
// Start SchedulerService — periodically checks and fires scheduled triggers
|
||||
{
|
||||
let mut sched_lock = scheduler_state.lock().await;
|
||||
// Stop old scheduler if any
|
||||
if let Some(ref old) = *sched_lock {
|
||||
old.stop();
|
||||
}
|
||||
let scheduler = zclaw_kernel::scheduler::SchedulerService::new(
|
||||
state.inner().clone(),
|
||||
60, // check every 60 seconds
|
||||
);
|
||||
scheduler.start();
|
||||
tracing::info!("[kernel_init] SchedulerService started (60s interval)");
|
||||
*sched_lock = Some(scheduler);
|
||||
}
|
||||
|
||||
Ok(KernelStatusResponse {
|
||||
initialized: true,
|
||||
agent_count,
|
||||
@@ -305,7 +325,17 @@ pub async fn kernel_status(
|
||||
#[tauri::command]
|
||||
pub async fn kernel_shutdown(
|
||||
state: State<'_, KernelState>,
|
||||
scheduler_state: State<'_, SchedulerState>,
|
||||
) -> Result<(), String> {
|
||||
// Stop scheduler first
|
||||
{
|
||||
let mut sched_lock = scheduler_state.lock().await;
|
||||
if let Some(scheduler) = sched_lock.take() {
|
||||
scheduler.stop();
|
||||
tracing::info!("[kernel_shutdown] SchedulerService stopped");
|
||||
}
|
||||
}
|
||||
|
||||
let mut kernel_lock = state.lock().await;
|
||||
|
||||
if let Some(kernel) = kernel_lock.take() {
|
||||
@@ -806,6 +836,11 @@ pub fn create_kernel_state() -> KernelState {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
/// Create the scheduler state for Tauri
|
||||
pub fn create_scheduler_state() -> SchedulerState {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Skills Commands - Dynamic Discovery
|
||||
// ============================================================================
|
||||
@@ -1964,10 +1999,8 @@ pub struct ScheduledTaskResponse {
|
||||
|
||||
/// Create a scheduled task (backed by kernel TriggerManager)
|
||||
///
|
||||
/// ⚠️ PLANNNED: Tasks are stored in the kernel's trigger system, but automatic
|
||||
/// execution requires a scheduler loop that is not yet implemented in embedded
|
||||
/// kernel mode. Created tasks will be persisted but not auto-executed until
|
||||
/// the scheduler loop is implemented.
|
||||
/// Tasks are automatically executed by the SchedulerService which checks
|
||||
/// every 60 seconds for due triggers.
|
||||
#[tauri::command]
|
||||
pub async fn scheduled_task_create(
|
||||
state: State<'_, KernelState>,
|
||||
|
||||
@@ -212,7 +212,6 @@ fn get_platform_binary_names() -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Legacy: Build staged runtime using Node.js (for backward compatibility)
|
||||
#[allow(dead_code)]
|
||||
fn build_staged_runtime_legacy(source: &str, root_dir: PathBuf) -> Option<ZclawRuntime> {
|
||||
let node_executable = root_dir.join(if cfg!(target_os = "windows") {
|
||||
"node.exe"
|
||||
@@ -973,11 +972,9 @@ fn zclaw_version(app: AppHandle) -> Result<VersionResponse, String> {
|
||||
/// Health status enum
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[allow(dead_code)] // Reserved for future health check expansion
|
||||
enum HealthStatus {
|
||||
Healthy,
|
||||
Unhealthy,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Port check result
|
||||
@@ -1309,6 +1306,9 @@ pub fn run() {
|
||||
// Initialize internal ZCLAW Kernel state
|
||||
let kernel_state = kernel_commands::create_kernel_state();
|
||||
|
||||
// Initialize Scheduler state (for automatic trigger execution)
|
||||
let scheduler_state = kernel_commands::create_scheduler_state();
|
||||
|
||||
// Initialize Pipeline state (DSL-based workflows)
|
||||
let pipeline_state = pipeline_commands::create_pipeline_state();
|
||||
|
||||
@@ -1320,6 +1320,7 @@ pub fn run() {
|
||||
.manage(reflection_state)
|
||||
.manage(identity_state)
|
||||
.manage(kernel_state)
|
||||
.manage(scheduler_state)
|
||||
.manage(kernel_commands::SessionStreamGuard::default())
|
||||
.manage(pipeline_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
|
||||
@@ -957,29 +957,19 @@ export class KernelClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a skill by ID with optional input parameters.
|
||||
* Checks autonomy level before execution.
|
||||
*/
|
||||
async executeSkill(id: string, input?: Record<string, unknown>): Promise<{
|
||||
success: boolean;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
durationMs?: number;
|
||||
}> {
|
||||
// Autonomy check before executing skill
|
||||
const { canAutoExecute, getAutonomyManager } = await import('./autonomy-manager');
|
||||
const { canProceed, decision } = canAutoExecute('skill_install', 5);
|
||||
if (!canProceed) {
|
||||
return {
|
||||
success: false,
|
||||
error: `自主授权拒绝: ${decision.reason}`,
|
||||
};
|
||||
}
|
||||
|
||||
const autonomyLevel = getAutonomyManager().getConfig().level;
|
||||
|
||||
return invoke('skill_execute', {
|
||||
id,
|
||||
context: {},
|
||||
input: input || {},
|
||||
autonomyLevel,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -494,11 +494,12 @@ class SaasLLMAdapter implements LLMServiceAdapter {
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
// Check synchronously via localStorage for availability check
|
||||
// Dynamic import would be async, so we use a simpler check
|
||||
// Check synchronously via localStorage for availability check.
|
||||
// Auth is cookie-based — check connection mode + URL presence.
|
||||
try {
|
||||
const token = localStorage.getItem('zclaw-saas-token');
|
||||
return !!token;
|
||||
const mode = localStorage.getItem('zclaw-connection-mode');
|
||||
const saasUrl = localStorage.getItem('zclaw-saas-url');
|
||||
return mode === 'saas' && !!saasUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,20 @@
|
||||
* Handles authentication, model listing, chat relay, and config management.
|
||||
*
|
||||
* API base path: /api/v1/...
|
||||
* Auth: Bearer token in Authorization header
|
||||
* Auth: HttpOnly cookie (primary) + Bearer token fallback
|
||||
*
|
||||
* Security: Tokens are NO LONGER persisted to localStorage.
|
||||
* The backend sets HttpOnly cookies on login/register/refresh.
|
||||
* On page reload, cookie-based auth is verified via GET /api/v1/auth/me.
|
||||
*/
|
||||
|
||||
// === Storage Keys ===
|
||||
// Token is stored in secure storage (OS keyring), NOT in plain localStorage.
|
||||
// Auth state is carried by HttpOnly cookies when possible (same-origin).
|
||||
// On page reload, token is restored from secure storage as Bearer fallback.
|
||||
|
||||
const SAASTOKEN_KEY = 'zclaw-saas-token';
|
||||
const SAAS_TOKEN_SECURE_KEY = 'zclaw-saas-token'; // OS keyring key
|
||||
const SAASTOKEN_KEY = 'zclaw-saas-token'; // legacy localStorage — only used for cleanup
|
||||
const SAASURL_KEY = 'zclaw-saas-url';
|
||||
const SAASACCOUNT_KEY = 'zclaw-saas-account';
|
||||
const SAASMODE_KEY = 'zclaw-connection-mode';
|
||||
@@ -439,25 +447,42 @@ export class SaaSApiError extends Error {
|
||||
// === Session Persistence ===
|
||||
|
||||
export interface SaaSSession {
|
||||
token: string;
|
||||
token: string | null; // null when using cookie-based auth (page reload)
|
||||
account: SaaSAccountInfo | null;
|
||||
saasUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a persisted SaaS session from localStorage.
|
||||
* Returns null if no valid session exists.
|
||||
* Load a persisted SaaS session.
|
||||
* Token is stored in secure storage (OS keyring), not plain localStorage.
|
||||
* Returns null if no URL is stored (never logged in).
|
||||
*
|
||||
* NOTE: Token loading is async due to secure storage access.
|
||||
* For synchronous checks, use loadSaaSSessionSync() (URL + account only).
|
||||
*/
|
||||
export function loadSaaSSession(): SaaSSession | null {
|
||||
export async function loadSaaSSession(): Promise<SaaSSession | null> {
|
||||
try {
|
||||
const token = localStorage.getItem(SAASTOKEN_KEY);
|
||||
const saasUrl = localStorage.getItem(SAASURL_KEY);
|
||||
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
|
||||
|
||||
if (!token || !saasUrl) {
|
||||
if (!saasUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clean up any legacy plaintext token from localStorage
|
||||
const legacyToken = localStorage.getItem(SAASTOKEN_KEY);
|
||||
if (legacyToken) {
|
||||
localStorage.removeItem(SAASTOKEN_KEY);
|
||||
}
|
||||
|
||||
// Load token from secure storage
|
||||
let token: string | null = null;
|
||||
try {
|
||||
const { secureStorage } = await import('./secure-storage');
|
||||
token = await secureStorage.get(SAAS_TOKEN_SECURE_KEY);
|
||||
} catch {
|
||||
// Secure storage unavailable — token stays null (cookie auth will be attempted)
|
||||
}
|
||||
|
||||
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
|
||||
const account: SaaSAccountInfo | null = accountRaw
|
||||
? (JSON.parse(accountRaw) as SaaSAccountInfo)
|
||||
: null;
|
||||
@@ -471,10 +496,46 @@ export function loadSaaSSession(): SaaSSession | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a SaaS session to localStorage.
|
||||
* Synchronous version — returns URL + account only (no token).
|
||||
* Used during store initialization where async is not available.
|
||||
*/
|
||||
export function saveSaaSSession(session: SaaSSession): void {
|
||||
localStorage.setItem(SAASTOKEN_KEY, session.token);
|
||||
export function loadSaaSSessionSync(): { saasUrl: string; account: SaaSAccountInfo | null } | null {
|
||||
try {
|
||||
const saasUrl = localStorage.getItem(SAASURL_KEY);
|
||||
if (!saasUrl) return null;
|
||||
|
||||
// Clean up legacy plaintext token
|
||||
const legacyToken = localStorage.getItem(SAASTOKEN_KEY);
|
||||
if (legacyToken) {
|
||||
localStorage.removeItem(SAASTOKEN_KEY);
|
||||
}
|
||||
|
||||
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
|
||||
const account: SaaSAccountInfo | null = accountRaw
|
||||
? (JSON.parse(accountRaw) as SaaSAccountInfo)
|
||||
: null;
|
||||
|
||||
return { saasUrl, account };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist SaaS session.
|
||||
* Token goes to secure storage (OS keyring), metadata to localStorage.
|
||||
*/
|
||||
export async function saveSaaSSession(session: SaaSSession): Promise<void> {
|
||||
// Store token in secure storage (OS keyring), not plain localStorage
|
||||
if (session.token) {
|
||||
try {
|
||||
const { secureStorage } = await import('./secure-storage');
|
||||
await secureStorage.set(SAAS_TOKEN_SECURE_KEY, session.token);
|
||||
} catch {
|
||||
// Secure storage unavailable — token only in memory
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(SAASURL_KEY, session.saasUrl);
|
||||
if (session.account) {
|
||||
localStorage.setItem(SAASACCOUNT_KEY, JSON.stringify(session.account));
|
||||
@@ -482,9 +543,15 @@ export function saveSaaSSession(session: SaaSSession): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the persisted SaaS session from localStorage.
|
||||
* Clear the persisted SaaS session from all storage.
|
||||
*/
|
||||
export function clearSaaSSession(): void {
|
||||
export async function clearSaaSSession(): Promise<void> {
|
||||
// Remove from secure storage
|
||||
try {
|
||||
const { secureStorage } = await import('./secure-storage');
|
||||
await secureStorage.set(SAAS_TOKEN_SECURE_KEY, '');
|
||||
} catch { /* non-blocking */ }
|
||||
|
||||
localStorage.removeItem(SAASTOKEN_KEY);
|
||||
localStorage.removeItem(SAASURL_KEY);
|
||||
localStorage.removeItem(SAASACCOUNT_KEY);
|
||||
@@ -525,14 +592,33 @@ export class SaaSClient {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
/** Set or clear the auth token */
|
||||
/** Set or clear the auth token (in-memory only, never persisted) */
|
||||
setToken(token: string | null): void {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/** Check if the client has an auth token */
|
||||
/** Check if the client is authenticated (token in memory or cookie-based) */
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.token;
|
||||
return !!this.token || this._cookieAuth;
|
||||
}
|
||||
|
||||
/** Track cookie-based auth state (page reload) */
|
||||
private _cookieAuth: boolean = false;
|
||||
|
||||
/**
|
||||
* Attempt to restore auth state from HttpOnly cookie.
|
||||
* Called on page reload when no token is in memory.
|
||||
* Returns account info if cookie is valid, null otherwise.
|
||||
*/
|
||||
async restoreFromCookie(): Promise<SaaSAccountInfo | null> {
|
||||
try {
|
||||
const account = await this.me();
|
||||
this._cookieAuth = true;
|
||||
return account;
|
||||
} catch {
|
||||
this._cookieAuth = false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a path is an auth endpoint (avoid infinite refresh loop) */
|
||||
@@ -569,6 +655,7 @@ export class SaaSClient {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
// Bearer token as fallback — primary auth is HttpOnly cookie
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
@@ -577,6 +664,7 @@ export class SaaSClient {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include', // Send HttpOnly cookies
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
@@ -588,24 +676,12 @@ export class SaaSClient {
|
||||
try {
|
||||
const newToken = await this.refreshToken();
|
||||
if (newToken) {
|
||||
// Persist refreshed token to localStorage
|
||||
try {
|
||||
const raw = localStorage.getItem('zclaw-saas-session');
|
||||
if (raw) {
|
||||
const session = JSON.parse(raw);
|
||||
session.token = newToken;
|
||||
localStorage.setItem('zclaw-saas-session', JSON.stringify(session));
|
||||
}
|
||||
} catch { /* non-blocking */ }
|
||||
return this.request<T>(method, path, body, timeoutMs, true);
|
||||
}
|
||||
} catch (refreshErr) {
|
||||
// Token refresh failed — clear session and trigger logout
|
||||
try {
|
||||
const { clearSaaSSession } = require('./saas-client');
|
||||
clearSaaSSession();
|
||||
localStorage.removeItem('zclaw-connection-mode');
|
||||
} catch { /* non-blocking */ }
|
||||
clearSaaSSession().catch(() => {}); // async cleanup, fire-and-forget
|
||||
localStorage.removeItem('zclaw-connection-mode');
|
||||
throw new SaaSApiError(401, 'SESSION_EXPIRED', '会话已过期,请重新登录');
|
||||
}
|
||||
throw new SaaSApiError(401, 'UNAUTHORIZED', '认证已过期,请重新登录');
|
||||
@@ -844,6 +920,7 @@ export class SaaSClient {
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include', // Send HttpOnly cookies
|
||||
body: JSON.stringify(body),
|
||||
signal: effectiveSignal,
|
||||
},
|
||||
|
||||
@@ -359,17 +359,16 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||
|
||||
if (savedMode === 'saas') {
|
||||
const { loadSaaSSession, saasClient } = await import('../lib/saas-client');
|
||||
const session = loadSaaSSession();
|
||||
const session = await loadSaaSSession();
|
||||
|
||||
if (!session || !session.token || !session.saasUrl) {
|
||||
if (!session || !session.saasUrl) {
|
||||
throw new Error('SaaS 模式未登录,请先在设置中登录 SaaS 平台');
|
||||
}
|
||||
|
||||
log.debug('Using SaaS relay mode:', session.saasUrl);
|
||||
|
||||
// Configure the singleton client
|
||||
// Configure the singleton client (cookie auth — no token needed)
|
||||
saasClient.setBaseUrl(session.saasUrl);
|
||||
saasClient.setToken(session.token);
|
||||
|
||||
// Health check via GET /api/v1/relay/models
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
saasClient,
|
||||
SaaSApiError,
|
||||
loadSaaSSession,
|
||||
loadSaaSSessionSync,
|
||||
saveSaaSSession,
|
||||
clearSaaSSession,
|
||||
saveConnectionMode,
|
||||
@@ -79,7 +80,7 @@ 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;
|
||||
logout: () => Promise<void>;
|
||||
setConnectionMode: (mode: ConnectionMode) => void;
|
||||
fetchAvailableModels: () => Promise<void>;
|
||||
syncConfigFromSaaS: () => Promise<void>;
|
||||
@@ -104,33 +105,34 @@ const DEFAULT_SAAS_URL = import.meta.env.DEV
|
||||
// === Helpers ===
|
||||
|
||||
/** Determine the initial connection mode from persisted state */
|
||||
function resolveInitialMode(session: ReturnType<typeof loadSaaSSession>): ConnectionMode {
|
||||
function resolveInitialMode(sessionMeta: { saasUrl: string; account: SaaSAccountInfo | null } | null): ConnectionMode {
|
||||
const persistedMode = loadConnectionMode();
|
||||
if (persistedMode === 'tauri' || persistedMode === 'gateway' || persistedMode === 'saas') {
|
||||
return persistedMode;
|
||||
}
|
||||
return session ? 'saas' : 'tauri';
|
||||
return sessionMeta ? 'saas' : 'tauri';
|
||||
}
|
||||
|
||||
// === Store Implementation ===
|
||||
|
||||
export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
// Restore session from localStorage on init
|
||||
const session = loadSaaSSession();
|
||||
const initialMode = resolveInitialMode(session);
|
||||
// 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 exists, configure the singleton client
|
||||
if (session) {
|
||||
saasClient.setBaseUrl(session.saasUrl);
|
||||
saasClient.setToken(session.token);
|
||||
// If session URL exists, configure the singleton client base URL
|
||||
if (sessionMeta) {
|
||||
saasClient.setBaseUrl(sessionMeta.saasUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
// === Initial State ===
|
||||
isLoggedIn: session !== null,
|
||||
account: session?.account ?? null,
|
||||
saasUrl: session?.saasUrl ?? DEFAULT_SAAS_URL,
|
||||
authToken: session?.token ?? null,
|
||||
// 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,
|
||||
@@ -163,20 +165,20 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
saasClient.setBaseUrl(normalizedUrl);
|
||||
const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password);
|
||||
|
||||
// Persist session
|
||||
// Persist session: token → secure storage (OS keyring), metadata → localStorage
|
||||
const sessionData = {
|
||||
token: loginData.token,
|
||||
token: loginData.token, // Will be stored in OS keyring by saveSaaSSession
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
};
|
||||
saveSaaSSession(sessionData);
|
||||
saveSaaSSession(sessionData); // async — fire and forget (non-blocking)
|
||||
saveConnectionMode('saas');
|
||||
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
authToken: loginData.token,
|
||||
authToken: null, // Not stored in Zustand state — saasClient holds in memory
|
||||
connectionMode: 'saas',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
@@ -261,7 +263,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
isLoggedIn: true,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
authToken: loginData.token,
|
||||
authToken: null,
|
||||
connectionMode: 'saas',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
@@ -326,7 +328,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
isLoggedIn: true,
|
||||
account: registerData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
authToken: registerData.token,
|
||||
authToken: null,
|
||||
connectionMode: 'saas',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
@@ -357,9 +359,9 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
logout: async () => {
|
||||
saasClient.setToken(null);
|
||||
clearSaaSSession();
|
||||
await clearSaaSSession();
|
||||
saveConnectionMode('tauri');
|
||||
stopTelemetryCollector();
|
||||
stopPromptOTASync();
|
||||
@@ -389,16 +391,15 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
},
|
||||
|
||||
fetchAvailableModels: async () => {
|
||||
const { isLoggedIn, authToken, saasUrl } = get();
|
||||
const { isLoggedIn, saasUrl } = get();
|
||||
|
||||
if (!isLoggedIn || !authToken) {
|
||||
if (!isLoggedIn) {
|
||||
set({ availableModels: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
saasClient.setBaseUrl(saasUrl);
|
||||
saasClient.setToken(authToken);
|
||||
const models = await saasClient.listModels();
|
||||
set({ availableModels: models });
|
||||
} catch (err: unknown) {
|
||||
@@ -413,12 +414,11 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
* Collects all "dirty" config keys, computes diff, and syncs via merge.
|
||||
*/
|
||||
pushConfigToSaaS: async () => {
|
||||
const { isLoggedIn, authToken, saasUrl } = get();
|
||||
if (!isLoggedIn || !authToken) return;
|
||||
const { isLoggedIn, saasUrl } = get();
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
try {
|
||||
saasClient.setBaseUrl(saasUrl);
|
||||
saasClient.setToken(authToken);
|
||||
|
||||
// Collect all dirty config keys
|
||||
const dirtyKeys: string[] = [];
|
||||
@@ -472,13 +472,12 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
|
||||
/** Pull SaaS config and apply to local storage (startup auto-sync) */
|
||||
syncConfigFromSaaS: async () => {
|
||||
const { isLoggedIn, authToken, saasUrl } = get();
|
||||
const { isLoggedIn, saasUrl } = get();
|
||||
|
||||
if (!isLoggedIn || !authToken) return;
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
try {
|
||||
saasClient.setBaseUrl(saasUrl);
|
||||
saasClient.setToken(authToken);
|
||||
|
||||
// Read last sync timestamp from localStorage
|
||||
const lastSyncKey = 'zclaw-config-last-sync';
|
||||
@@ -533,15 +532,14 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
},
|
||||
|
||||
registerCurrentDevice: async () => {
|
||||
const { isLoggedIn, authToken, saasUrl } = get();
|
||||
const { isLoggedIn, saasUrl } = get();
|
||||
|
||||
if (!isLoggedIn || !authToken) {
|
||||
if (!isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
saasClient.setBaseUrl(saasUrl);
|
||||
saasClient.setToken(authToken);
|
||||
await saasClient.registerDevice({
|
||||
device_id: DEVICE_ID,
|
||||
device_name: `${navigator.userAgent.split(' ').slice(0, 3).join(' ')}`,
|
||||
@@ -555,7 +553,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
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) {
|
||||
if (!state.isLoggedIn) {
|
||||
window.clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
@@ -593,25 +591,55 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
set({ error: null });
|
||||
},
|
||||
|
||||
restoreSession: () => {
|
||||
const restored = loadSaaSSession();
|
||||
if (restored) {
|
||||
saasClient.setBaseUrl(restored.saasUrl);
|
||||
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);
|
||||
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);
|
||||
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 () => {
|
||||
@@ -633,10 +661,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
try {
|
||||
await saasClient.verifyTotp(code);
|
||||
const account = await saasClient.me();
|
||||
const { saasUrl, authToken } = get();
|
||||
if (authToken) {
|
||||
saveSaaSSession({ token: authToken, account, saasUrl });
|
||||
}
|
||||
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
|
||||
@@ -651,10 +677,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
try {
|
||||
await saasClient.disableTotp(password);
|
||||
const account = await saasClient.me();
|
||||
const { saasUrl, authToken } = get();
|
||||
if (authToken) {
|
||||
saveSaaSSession({ token: authToken, account, saasUrl });
|
||||
}
|
||||
const { saasUrl } = get();
|
||||
saveSaaSSession({ token: null, account, saasUrl });
|
||||
set({ isLoading: false, account });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError ? err.message
|
||||
|
||||
Reference in New Issue
Block a user