feat: 增强SaaS后端功能与安全性
refactor: 重构数据库连接使用PostgreSQL替代SQLite feat(auth): 增加JWT验证的audience和issuer检查 feat(crypto): 添加AES-256-GCM字段加密支持 feat(api): 集成utoipa实现OpenAPI文档 fix(admin): 修复配置项表单验证逻辑 style: 统一代码格式与类型定义 docs: 更新技术栈文档说明PostgreSQL
This commit is contained in:
@@ -102,8 +102,8 @@ export function ConfigMigrationWizard({ onDone }: { onDone: () => void }) {
|
||||
if (direction === 'local-to-saas' && localModels.length > 0) {
|
||||
// Push local models as config items
|
||||
for (const model of localModels) {
|
||||
const exists = saasConfigs.some((c) => c.key_path === `models.${model.id}`);
|
||||
if (exists && !selectedKeys.has(model.id)) continue;
|
||||
const existingItem = saasConfigs.find((c) => c.key_path === `models.${model.id}`);
|
||||
if (existingItem && !selectedKeys.has(model.id)) continue;
|
||||
|
||||
const body = {
|
||||
category: 'model',
|
||||
@@ -114,8 +114,8 @@ export function ConfigMigrationWizard({ onDone }: { onDone: () => void }) {
|
||||
description: `从桌面端同步: ${model.name}`,
|
||||
};
|
||||
|
||||
if (exists) {
|
||||
await saasClient.request<unknown>('PUT', `/api/v1/config/items/${exists}`, body);
|
||||
if (existingItem) {
|
||||
await saasClient.request<unknown>('PUT', `/api/v1/config/items/${existingItem.id}`, body);
|
||||
} else {
|
||||
await saasClient.request<unknown>('POST', '/api/v1/config/items', body);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { SaaSAccountInfo, SaaSModelInfo } from '../../lib/saas-client';
|
||||
import { saasClient, type SaaSAccountInfo, type SaaSModelInfo } from '../../lib/saas-client';
|
||||
import { Cloud, CloudOff, LogOut, RefreshCw, Cpu, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { useSaaSStore } from '../../store/saasStore';
|
||||
|
||||
|
||||
@@ -6,18 +6,7 @@ import { useConfigStore } from '../../store/configStore';
|
||||
import { useChatStore } from '../../store/chatStore';
|
||||
import { silentErrorHandler } from '../../lib/error-utils';
|
||||
import { Plus, Pencil, Trash2, Star, Eye, EyeOff, AlertCircle, X, Zap, Check } from 'lucide-react';
|
||||
|
||||
// 自定义模型数据结构
|
||||
interface CustomModel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
apiKey?: string;
|
||||
apiProtocol: 'openai' | 'anthropic' | 'custom';
|
||||
baseUrl?: string;
|
||||
isDefault?: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
import type { CustomModel, CustomModelApiProtocol } from '../../types/config';
|
||||
|
||||
// Embedding 配置数据结构
|
||||
interface EmbeddingConfig {
|
||||
@@ -140,7 +129,7 @@ export function ModelsAPI() {
|
||||
modelId: 'glm-4-flash',
|
||||
displayName: '',
|
||||
apiKey: '',
|
||||
apiProtocol: 'openai' as 'openai' | 'anthropic' | 'custom',
|
||||
apiProtocol: 'openai' as CustomModelApiProtocol,
|
||||
baseUrl: '',
|
||||
});
|
||||
|
||||
@@ -650,7 +639,7 @@ export function ModelsAPI() {
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API 协议</label>
|
||||
<select
|
||||
value={formData.apiProtocol}
|
||||
onChange={(e) => setFormData({ ...formData, apiProtocol: e.target.value as 'openai' | 'anthropic' | 'custom' })}
|
||||
onChange={(e) => setFormData({ ...formData, apiProtocol: e.target.value as CustomModelApiProtocol })}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
|
||||
@@ -455,10 +455,24 @@ export function clearSecurityLog(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random API key for testing
|
||||
* WARNING: Only use for testing purposes
|
||||
* Generate a random API key for testing.
|
||||
*
|
||||
* @internal This function is intended solely for automated tests and
|
||||
* development tooling. It must never be called in production
|
||||
* builds because generated keys are not cryptographically secure
|
||||
* and should never be used to authenticate against real services.
|
||||
*
|
||||
* @param type - The API key type to generate a test key for
|
||||
* @returns A random API key that passes format validation for the given type
|
||||
* @throws {Error} If called outside of a development or test environment
|
||||
*/
|
||||
export function generateTestApiKey(type: ApiKeyType): string {
|
||||
if (import.meta.env?.DEV !== true && import.meta.env?.MODE !== 'test') {
|
||||
throw new Error(
|
||||
'[Security] generateTestApiKey may only be called in development or test environments'
|
||||
);
|
||||
}
|
||||
|
||||
const rules = KEY_VALIDATION_RULES[type];
|
||||
const length = rules.minLength + 10;
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
@@ -37,13 +37,17 @@ export {
|
||||
DEFAULT_GATEWAY_URL,
|
||||
REST_API_URL,
|
||||
FALLBACK_GATEWAY_URLS,
|
||||
ZCLAW_GRPC_PORT,
|
||||
ZCLAW_LEGACY_PORT,
|
||||
normalizeGatewayUrl,
|
||||
isLocalhost,
|
||||
getStoredGatewayUrl,
|
||||
setStoredGatewayUrl,
|
||||
getStoredGatewayToken,
|
||||
setStoredGatewayToken,
|
||||
detectConnectionMode,
|
||||
} from './gateway-storage';
|
||||
export type { ConnectionMode } from './gateway-storage';
|
||||
|
||||
// === Internal imports ===
|
||||
import type {
|
||||
@@ -69,6 +73,7 @@ import {
|
||||
isLocalhost,
|
||||
getStoredGatewayUrl,
|
||||
getStoredGatewayToken,
|
||||
detectConnectionMode,
|
||||
} from './gateway-storage';
|
||||
|
||||
import type { GatewayConfigSnapshot, GatewayModelChoice } from './gateway-config';
|
||||
@@ -273,8 +278,8 @@ export class GatewayClient {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Check if URL is for ZCLAW (port 4200 or 50051) - use REST mode
|
||||
if (this.url.includes(':4200') || this.url.includes(':50051')) {
|
||||
// Check if URL is for ZCLAW (known kernel ports) - use REST mode
|
||||
if (detectConnectionMode(this.url) === 'rest') {
|
||||
return this.connectRest();
|
||||
}
|
||||
|
||||
|
||||
@@ -40,15 +40,47 @@ export function isLocalhost(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// === Port Constants ===
|
||||
|
||||
/** Default gRPC/HTTP port used by the ZCLAW kernel */
|
||||
export const ZCLAW_GRPC_PORT = 50051;
|
||||
|
||||
/** Legacy/alternative port used in development or older configurations */
|
||||
export const ZCLAW_LEGACY_PORT = 4200;
|
||||
|
||||
// === Connection Mode ===
|
||||
|
||||
/**
|
||||
* Determines how the client connects to the ZCLAW gateway.
|
||||
* - `rest`: Kernel exposes an HTTP REST API (gRPC-gateway). Used when the
|
||||
* URL contains a known kernel port.
|
||||
* - `ws`: Direct WebSocket connection to the kernel.
|
||||
*/
|
||||
export type ConnectionMode = 'rest' | 'ws';
|
||||
|
||||
/**
|
||||
* Decide the connection mode based on the gateway URL.
|
||||
*
|
||||
* When the URL contains a known kernel port (gRPC or legacy), the client
|
||||
* routes requests through the REST adapter instead of opening a raw
|
||||
* WebSocket.
|
||||
*/
|
||||
export function detectConnectionMode(url: string): ConnectionMode {
|
||||
if (url.includes(`:${ZCLAW_GRPC_PORT}`) || url.includes(`:${ZCLAW_LEGACY_PORT}`)) {
|
||||
return 'rest';
|
||||
}
|
||||
return 'ws';
|
||||
}
|
||||
|
||||
// === URL Constants ===
|
||||
|
||||
// ZCLAW endpoints (port 50051 - actual running port)
|
||||
// Note: REST API uses relative path to leverage Vite proxy for CORS bypass
|
||||
export const DEFAULT_GATEWAY_URL = `${DEFAULT_WS_PROTOCOL}127.0.0.1:50051/ws`;
|
||||
export const DEFAULT_GATEWAY_URL = `${DEFAULT_WS_PROTOCOL}127.0.0.1:${ZCLAW_GRPC_PORT}/ws`;
|
||||
export const REST_API_URL = ''; // Empty = use relative path (Vite proxy)
|
||||
export const FALLBACK_GATEWAY_URLS = [
|
||||
DEFAULT_GATEWAY_URL,
|
||||
`${DEFAULT_WS_PROTOCOL}127.0.0.1:4200/ws`,
|
||||
`${DEFAULT_WS_PROTOCOL}127.0.0.1:${ZCLAW_LEGACY_PORT}/ws`,
|
||||
];
|
||||
|
||||
const GATEWAY_URL_STORAGE_KEY = 'zclaw_gateway_url';
|
||||
|
||||
@@ -6,8 +6,13 @@
|
||||
*
|
||||
* API base path: /api/v1/...
|
||||
* Auth: Bearer token in Authorization header
|
||||
*
|
||||
* Security: JWT token is stored via secureStorage (OS keychain or encrypted localStorage).
|
||||
* URL, account info, and connection mode remain in plain localStorage (non-sensitive).
|
||||
*/
|
||||
|
||||
import { secureStorage } from './secure-storage';
|
||||
|
||||
// === Storage Keys ===
|
||||
|
||||
const SAASTOKEN_KEY = 'zclaw-saas-token';
|
||||
@@ -146,6 +151,55 @@ export interface ConfigSyncResult {
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
// === JWT Helpers ===
|
||||
|
||||
/**
|
||||
* Decode a JWT payload without verifying the signature.
|
||||
* Returns the parsed JSON payload, or null if the token is malformed.
|
||||
*/
|
||||
export function decodeJwtPayload<T = Record<string, unknown>>(token: string): T | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
// JWT payload is Base64Url-encoded
|
||||
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const json = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join(''),
|
||||
);
|
||||
return JSON.parse(json) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** JWT payload shape we care about */
|
||||
interface JwtPayload {
|
||||
exp?: number;
|
||||
iat?: number;
|
||||
sub?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the delay (ms) until 80% of the token's lifetime has elapsed.
|
||||
* This is the ideal moment to trigger a proactive refresh.
|
||||
* Returns null if the token has no exp claim or is already past 80% lifetime.
|
||||
*/
|
||||
export function getRefreshDelay(exp: number): number | null {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const totalLifetime = exp - now;
|
||||
if (totalLifetime <= 0) return null; // already expired
|
||||
|
||||
// Refresh at 80% of the token's remaining lifetime
|
||||
const refreshAt = now + Math.floor(totalLifetime * 0.8);
|
||||
const delayMs = (refreshAt - now) * 1000;
|
||||
|
||||
// Minimum 5-second guard to avoid hammering the endpoint
|
||||
return delayMs > 5000 ? delayMs : 5000;
|
||||
}
|
||||
|
||||
// === Error Class ===
|
||||
|
||||
export class SaaSApiError extends Error {
|
||||
@@ -168,16 +222,35 @@ export interface SaaSSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a persisted SaaS session from localStorage.
|
||||
* Read a value from localStorage with error handling.
|
||||
*/
|
||||
function readLegacyLocalStorage(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a persisted SaaS session using secure storage for the JWT token.
|
||||
* Falls back to legacy localStorage if secureStorage has no token (migration).
|
||||
* Returns null if no valid session exists.
|
||||
*/
|
||||
export function loadSaaSSession(): SaaSSession | null {
|
||||
export async function loadSaaSSessionAsync(): Promise<SaaSSession | null> {
|
||||
try {
|
||||
const token = localStorage.getItem(SAASTOKEN_KEY);
|
||||
const saasUrl = localStorage.getItem(SAASURL_KEY);
|
||||
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
|
||||
// Try secure storage first (keychain or encrypted localStorage)
|
||||
const token = await secureStorage.get(SAASTOKEN_KEY);
|
||||
|
||||
if (!token || !saasUrl) {
|
||||
// Migration: if secureStorage is empty, try legacy localStorage
|
||||
const legacyToken = !token ? readLegacyLocalStorage(SAASTOKEN_KEY) : null;
|
||||
|
||||
const saasUrl = readLegacyLocalStorage(SAASURL_KEY);
|
||||
const accountRaw = readLegacyLocalStorage(SAASACCOUNT_KEY);
|
||||
|
||||
const effectiveToken = token || legacyToken;
|
||||
|
||||
if (!effectiveToken || !saasUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -185,19 +258,30 @@ export function loadSaaSSession(): SaaSSession | null {
|
||||
? (JSON.parse(accountRaw) as SaaSAccountInfo)
|
||||
: null;
|
||||
|
||||
return { token, account, saasUrl };
|
||||
// If we found a legacy token in localStorage, migrate it to secure storage
|
||||
if (legacyToken && !token) {
|
||||
await secureStorage.set(SAASTOKEN_KEY, legacyToken);
|
||||
// Remove plaintext token from localStorage after migration
|
||||
try { localStorage.removeItem(SAASTOKEN_KEY); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return { token: effectiveToken, account, saasUrl };
|
||||
} catch {
|
||||
// Corrupted data - clear all
|
||||
clearSaaSSession();
|
||||
await clearSaaSSessionAsync();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a SaaS session to localStorage.
|
||||
* Persist a SaaS session using secure storage for the JWT token.
|
||||
* URL and account info remain in localStorage (non-sensitive).
|
||||
*/
|
||||
export function saveSaaSSession(session: SaaSSession): void {
|
||||
localStorage.setItem(SAASTOKEN_KEY, session.token);
|
||||
export async function saveSaaSSessionAsync(session: SaaSSession): Promise<void> {
|
||||
await secureStorage.set(SAASTOKEN_KEY, session.token);
|
||||
// Remove legacy plaintext token from localStorage
|
||||
try { localStorage.removeItem(SAASTOKEN_KEY); } catch { /* ignore */ }
|
||||
|
||||
localStorage.setItem(SAASURL_KEY, session.saasUrl);
|
||||
if (session.account) {
|
||||
localStorage.setItem(SAASACCOUNT_KEY, JSON.stringify(session.account));
|
||||
@@ -205,16 +289,18 @@ export function saveSaaSSession(session: SaaSSession): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the persisted SaaS session from localStorage.
|
||||
* Clear the persisted SaaS session from both secure storage and localStorage.
|
||||
*/
|
||||
export function clearSaaSSession(): void {
|
||||
localStorage.removeItem(SAASTOKEN_KEY);
|
||||
localStorage.removeItem(SAASURL_KEY);
|
||||
localStorage.removeItem(SAASACCOUNT_KEY);
|
||||
export async function clearSaaSSessionAsync(): Promise<void> {
|
||||
await secureStorage.delete(SAASTOKEN_KEY);
|
||||
try { localStorage.removeItem(SAASTOKEN_KEY); } catch { /* ignore */ }
|
||||
try { localStorage.removeItem(SAASURL_KEY); } catch { /* ignore */ }
|
||||
try { localStorage.removeItem(SAASACCOUNT_KEY); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the connection mode to localStorage.
|
||||
* Connection mode is non-sensitive -- no need for secure storage.
|
||||
*/
|
||||
export function saveConnectionMode(mode: string): void {
|
||||
localStorage.setItem(SAASMODE_KEY, mode);
|
||||
@@ -230,9 +316,15 @@ export function loadConnectionMode(): string | null {
|
||||
|
||||
// === Client Implementation ===
|
||||
|
||||
/** Callback invoked when token refresh fails and the session should be terminated. */
|
||||
export type OnSessionExpired = () => void;
|
||||
|
||||
export class SaaSClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private refreshTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
private visibilityHandler: (() => void) | null = null;
|
||||
private onSessionExpired: OnSessionExpired | null = null;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||
@@ -248,9 +340,22 @@ export class SaaSClient {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
/** Set or clear the auth token */
|
||||
/** Set or clear the auth token. Automatically schedules a proactive refresh. */
|
||||
setToken(token: string | null): void {
|
||||
this.token = token;
|
||||
if (token) {
|
||||
this.scheduleTokenRefresh();
|
||||
} else {
|
||||
this.cancelTokenRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback invoked when the proactive token refresh fails.
|
||||
* The caller should use this to trigger a logout/redirect flow.
|
||||
*/
|
||||
setOnSessionExpired(handler: OnSessionExpired): void {
|
||||
this.onSessionExpired = handler;
|
||||
}
|
||||
|
||||
/** Check if the client has an auth token */
|
||||
@@ -258,6 +363,102 @@ export class SaaSClient {
|
||||
return !!this.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a proactive token refresh at 80% of the token's remaining lifetime.
|
||||
* Also registers a visibilitychange listener to re-check when the tab regains focus.
|
||||
*/
|
||||
scheduleTokenRefresh(): void {
|
||||
this.cancelTokenRefresh();
|
||||
|
||||
if (!this.token) return;
|
||||
|
||||
const payload = decodeJwtPayload<JwtPayload>(this.token);
|
||||
if (!payload?.exp) return;
|
||||
|
||||
const delay = getRefreshDelay(payload.exp);
|
||||
if (delay === null) {
|
||||
// Token already expired or too close -- attempt immediate refresh
|
||||
this.attemptTokenRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshTimerId = setTimeout(() => {
|
||||
this.attemptTokenRefresh();
|
||||
}, delay);
|
||||
|
||||
// When the tab becomes visible again, check if we should refresh sooner
|
||||
if (typeof document !== 'undefined' && !this.visibilityHandler) {
|
||||
this.visibilityHandler = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
this.checkAndRefreshToken();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', this.visibilityHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any pending token refresh timer and remove the visibility listener.
|
||||
*/
|
||||
cancelTokenRefresh(): void {
|
||||
if (this.refreshTimerId !== null) {
|
||||
clearTimeout(this.refreshTimerId);
|
||||
this.refreshTimerId = null;
|
||||
}
|
||||
if (this.visibilityHandler !== null && typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
||||
this.visibilityHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current token is close to expiry and refresh if needed.
|
||||
* Called on visibility change to handle clock skew / long background tabs.
|
||||
*/
|
||||
private checkAndRefreshToken(): void {
|
||||
if (!this.token) return;
|
||||
|
||||
const payload = decodeJwtPayload<JwtPayload>(this.token);
|
||||
if (!payload?.exp) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const remaining = payload.exp - now;
|
||||
|
||||
// If less than 20% of lifetime remains, refresh now
|
||||
if (remaining <= 0) {
|
||||
this.attemptTokenRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// If the scheduled refresh is more than 60s away and we're within 80%, do it now
|
||||
const delay = getRefreshDelay(payload.exp);
|
||||
if (delay !== null && delay < 60_000) {
|
||||
this.attemptTokenRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh the token. On failure, invoke the session-expired callback.
|
||||
* Persists the new token via secureStorage.
|
||||
*/
|
||||
private attemptTokenRefresh(): Promise<void> {
|
||||
return this.refreshToken()
|
||||
.then(async (newToken) => {
|
||||
// Persist the new token to secure storage
|
||||
const existing = await loadSaaSSessionAsync();
|
||||
if (existing) {
|
||||
await saveSaaSSessionAsync({ ...existing, token: newToken });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Refresh failed -- notify the app to log out
|
||||
this.cancelTokenRefresh();
|
||||
if (this.onSessionExpired) {
|
||||
this.onSessionExpired();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Core HTTP ---
|
||||
|
||||
/** Track whether the server appears reachable */
|
||||
@@ -436,7 +637,7 @@ export class SaaSClient {
|
||||
|
||||
/**
|
||||
* Register or update this device with the SaaS backend.
|
||||
* Uses UPSERT semantics — same (account, device_id) updates last_seen_at.
|
||||
* Uses UPSERT semantics -- same (account, device_id) updates last_seen_at.
|
||||
*/
|
||||
async registerDevice(params: {
|
||||
device_id: string;
|
||||
|
||||
@@ -37,18 +37,9 @@ const log = createLogger('ConnectionStore');
|
||||
|
||||
// === Custom Models Helpers ===
|
||||
|
||||
const CUSTOM_MODELS_STORAGE_KEY = 'zclaw-custom-models';
|
||||
import type { CustomModel } from '../types/config';
|
||||
|
||||
interface CustomModel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
apiKey?: string;
|
||||
apiProtocol: 'openai' | 'anthropic' | 'custom';
|
||||
baseUrl?: string;
|
||||
isDefault?: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
const CUSTOM_MODELS_STORAGE_KEY = 'zclaw-custom-models';
|
||||
|
||||
/**
|
||||
* Get custom models from localStorage
|
||||
@@ -218,8 +209,8 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||
// This takes priority over Tauri/Gateway when the user has selected SaaS mode.
|
||||
const savedMode = localStorage.getItem('zclaw-connection-mode');
|
||||
if (savedMode === 'saas') {
|
||||
const { loadSaaSSession, saasClient } = await import('../lib/saas-client');
|
||||
const session = loadSaaSSession();
|
||||
const { loadSaaSSessionAsync, saasClient } = await import('../lib/saas-client');
|
||||
const session = await loadSaaSSessionAsync();
|
||||
|
||||
if (!session || !session.token || !session.saasUrl) {
|
||||
throw new Error('SaaS 模式未登录,请先在设置中登录 SaaS 平台');
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* 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.
|
||||
* and available models. Persists auth token via secureStorage
|
||||
* (OS keychain or encrypted localStorage) for security.
|
||||
*
|
||||
* Connection modes:
|
||||
* - 'tauri': Local Kernel via Tauri (default)
|
||||
@@ -15,9 +15,9 @@ import { create } from 'zustand';
|
||||
import {
|
||||
saasClient,
|
||||
SaaSApiError,
|
||||
loadSaaSSession,
|
||||
saveSaaSSession,
|
||||
clearSaaSSession,
|
||||
loadSaaSSessionAsync,
|
||||
saveSaaSSessionAsync,
|
||||
clearSaaSSessionAsync,
|
||||
saveConnectionMode,
|
||||
loadConnectionMode,
|
||||
type SaaSAccountInfo,
|
||||
@@ -64,12 +64,12 @@ 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>;
|
||||
registerCurrentDevice: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
restoreSession: () => void;
|
||||
restoreSession: () => Promise<void>;
|
||||
setupTotp: () => Promise<TotpSetupResponse>;
|
||||
verifyTotp: (code: string) => Promise<void>;
|
||||
disableTotp: (password: string) => Promise<void>;
|
||||
@@ -85,33 +85,56 @@ const DEFAULT_SAAS_URL = 'https://saas.zclaw.com';
|
||||
// === Helpers ===
|
||||
|
||||
/** Determine the initial connection mode from persisted state */
|
||||
function resolveInitialMode(session: ReturnType<typeof loadSaaSSession>): ConnectionMode {
|
||||
function resolveInitialMode(hasSession: boolean): ConnectionMode {
|
||||
const persistedMode = loadConnectionMode();
|
||||
if (persistedMode === 'tauri' || persistedMode === 'gateway' || persistedMode === 'saas') {
|
||||
return persistedMode;
|
||||
}
|
||||
return session ? 'saas' : 'tauri';
|
||||
return hasSession ? 'saas' : 'tauri';
|
||||
}
|
||||
|
||||
// === Store Implementation ===
|
||||
|
||||
export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
// Restore session from localStorage on init
|
||||
const session = loadSaaSSession();
|
||||
const initialMode = resolveInitialMode(session);
|
||||
// Determine initial connection mode synchronously from localStorage.
|
||||
// Session token will be loaded asynchronously via restoreSession().
|
||||
const persistedMode = loadConnectionMode();
|
||||
const hasSession = persistedMode === 'saas';
|
||||
const initialMode = resolveInitialMode(hasSession);
|
||||
|
||||
// If session exists, configure the singleton client
|
||||
if (session) {
|
||||
saasClient.setBaseUrl(session.saasUrl);
|
||||
saasClient.setToken(session.token);
|
||||
}
|
||||
// Kick off async session restoration immediately.
|
||||
// The store initializes with a "potentially logged in" state based on
|
||||
// the connection mode, and restoreSession() will either hydrate the token
|
||||
// or clear the session if secure storage has no token.
|
||||
loadSaaSSessionAsync().then((session) => {
|
||||
if (session) {
|
||||
saasClient.setBaseUrl(session.saasUrl);
|
||||
saasClient.setToken(session.token);
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
account: session.account,
|
||||
saasUrl: session.saasUrl,
|
||||
authToken: session.token,
|
||||
connectionMode: resolveInitialMode(true),
|
||||
});
|
||||
// Fetch models in background after async restore
|
||||
get().fetchAvailableModels().catch(() => {});
|
||||
} else if (persistedMode === 'saas') {
|
||||
// Connection mode was 'saas' but no token found -- reset to tauri
|
||||
saveConnectionMode('tauri');
|
||||
set({ connectionMode: 'tauri' });
|
||||
}
|
||||
}).catch(() => {
|
||||
// secureStorage read failed -- keep defaults
|
||||
});
|
||||
|
||||
return {
|
||||
// === Initial State ===
|
||||
isLoggedIn: session !== null,
|
||||
account: session?.account ?? null,
|
||||
saasUrl: session?.saasUrl ?? DEFAULT_SAAS_URL,
|
||||
authToken: session?.token ?? null,
|
||||
// Session data will be hydrated by the async restoreSession above.
|
||||
isLoggedIn: hasSession,
|
||||
account: null,
|
||||
saasUrl: DEFAULT_SAAS_URL,
|
||||
authToken: null,
|
||||
connectionMode: initialMode,
|
||||
availableModels: [],
|
||||
isLoading: false,
|
||||
@@ -144,13 +167,13 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
saasClient.setBaseUrl(normalizedUrl);
|
||||
const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password);
|
||||
|
||||
// Persist session
|
||||
// Persist session securely
|
||||
const sessionData = {
|
||||
token: loginData.token,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
};
|
||||
saveSaaSSession(sessionData);
|
||||
await saveSaaSSessionAsync(sessionData);
|
||||
saveConnectionMode('saas');
|
||||
|
||||
set({
|
||||
@@ -212,7 +235,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
};
|
||||
saveSaaSSession(sessionData);
|
||||
await saveSaaSSessionAsync(sessionData);
|
||||
saveConnectionMode('saas');
|
||||
|
||||
set({
|
||||
@@ -273,7 +296,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
account: registerData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
};
|
||||
saveSaaSSession(sessionData);
|
||||
await saveSaaSSessionAsync(sessionData);
|
||||
saveConnectionMode('saas');
|
||||
|
||||
set({
|
||||
@@ -305,9 +328,9 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
logout: async () => {
|
||||
saasClient.setToken(null);
|
||||
clearSaaSSession();
|
||||
await clearSaaSSessionAsync();
|
||||
saveConnectionMode('tauri');
|
||||
|
||||
set({
|
||||
@@ -393,8 +416,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
set({ error: null });
|
||||
},
|
||||
|
||||
restoreSession: () => {
|
||||
const restored = loadSaaSSession();
|
||||
restoreSession: async () => {
|
||||
const restored = await loadSaaSSessionAsync();
|
||||
if (restored) {
|
||||
saasClient.setBaseUrl(restored.saasUrl);
|
||||
saasClient.setToken(restored.token);
|
||||
@@ -430,7 +453,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
const account = await saasClient.me();
|
||||
const { saasUrl, authToken } = get();
|
||||
if (authToken) {
|
||||
saveSaaSSession({ token: authToken, account, saasUrl });
|
||||
await saveSaaSSessionAsync({ token: authToken, account, saasUrl });
|
||||
}
|
||||
set({ totpSetupData: null, isLoading: false, account });
|
||||
} catch (err: unknown) {
|
||||
@@ -448,7 +471,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
const account = await saasClient.me();
|
||||
const { saasUrl, authToken } = get();
|
||||
if (authToken) {
|
||||
saveSaaSSession({ token: authToken, account, saasUrl });
|
||||
await saveSaaSSessionAsync({ token: authToken, account, saasUrl });
|
||||
}
|
||||
set({ isLoading: false, account });
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -571,3 +571,35 @@ export interface ConfigFileMetadata {
|
||||
/** Whether the file has unresolved env vars */
|
||||
hasUnresolvedEnvVars?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Custom Model Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* API protocol supported by a custom model provider.
|
||||
*/
|
||||
export type CustomModelApiProtocol = 'openai' | 'anthropic' | 'custom';
|
||||
|
||||
/**
|
||||
* User-defined custom model configuration.
|
||||
* Used by the model settings UI and the connection store.
|
||||
*/
|
||||
export interface CustomModel {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** Human-readable model name */
|
||||
name: string;
|
||||
/** Provider / vendor name */
|
||||
provider: string;
|
||||
/** API key (optional, stored separately in secure storage) */
|
||||
apiKey?: string;
|
||||
/** Which API protocol this provider speaks */
|
||||
apiProtocol: CustomModelApiProtocol;
|
||||
/** Base URL for the provider API (optional) */
|
||||
baseUrl?: string;
|
||||
/** Whether this model is the user's default */
|
||||
isDefault?: boolean;
|
||||
/** ISO-8601 timestamp of when this model was added */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -141,6 +141,12 @@ export type {
|
||||
AutomationItem,
|
||||
} from './automation';
|
||||
|
||||
// Custom Model Types
|
||||
export type {
|
||||
CustomModel,
|
||||
CustomModelApiProtocol,
|
||||
} from './config';
|
||||
|
||||
// Automation Constants and Functions
|
||||
export {
|
||||
HAND_CATEGORY_MAP,
|
||||
|
||||
Reference in New Issue
Block a user