feat: production readiness improvements
## Error Handling - Add GlobalErrorBoundary with error classification and recovery - Add custom error types (SecurityError, ConnectionError, TimeoutError) - Fix ErrorAlert component syntax errors ## Offline Mode - Add offlineStore for offline state management - Implement message queue with localStorage persistence - Add exponential backoff reconnection (1s→60s) - Add OfflineIndicator component with status display - Queue messages when offline, auto-retry on reconnect ## Security Hardening - Add AES-256-GCM encryption for chat history storage - Add secure API key storage with OS keychain integration - Add security audit logging system - Add XSS prevention and input validation utilities - Add rate limiting and token generation helpers ## CI/CD (Gitea Actions) - Add .gitea/workflows/ci.yml for continuous integration - Add .gitea/workflows/release.yml for release automation - Support Windows Tauri build and release ## UI Components - Add LoadingSpinner, LoadingOverlay, LoadingDots components - Add MessageSkeleton, ConversationListSkeleton skeletons - Add EmptyMessages, EmptyConversations empty states - Integrate loading states in ChatArea and ConversationList ## E2E Tests - Fix WebSocket mock for streaming response tests - Fix approval endpoint route matching - Add store state exposure for testing - All 19 core-features tests now passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
228
.gitea/workflows/ci.yml
Normal file
228
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# ZCLAW Continuous Integration Workflow for Gitea
|
||||||
|
# Runs on every push to main and all pull requests
|
||||||
|
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '20'
|
||||||
|
PNPM_VERSION: '9'
|
||||||
|
RUST_VERSION: '1.78'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ============================================================================
|
||||||
|
# Lint and Type Check
|
||||||
|
# ============================================================================
|
||||||
|
lint:
|
||||||
|
name: Lint & TypeCheck
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:20
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ${{ env.PNPM_VERSION }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install root dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install desktop dependencies
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Type check desktop
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
- name: Type check root
|
||||||
|
run: pnpm exec tsc --noEmit
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Unit Tests
|
||||||
|
# ============================================================================
|
||||||
|
test:
|
||||||
|
name: Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:20
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ${{ env.PNPM_VERSION }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install root dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install desktop dependencies
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run desktop unit tests
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: Run root unit tests
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Build Verification (Frontend only - no Tauri)
|
||||||
|
# ============================================================================
|
||||||
|
build-frontend:
|
||||||
|
name: Build Frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:20
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ${{ env.PNPM_VERSION }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install desktop dependencies
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Rust Backend Check
|
||||||
|
# ============================================================================
|
||||||
|
rust-check:
|
||||||
|
name: Rust Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: rust:1.78
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust components
|
||||||
|
run: rustup component add clippy rustfmt
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: |
|
||||||
|
desktop/src-tauri
|
||||||
|
|
||||||
|
- name: Check Rust formatting
|
||||||
|
working-directory: desktop/src-tauri
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: Run Clippy
|
||||||
|
working-directory: desktop/src-tauri
|
||||||
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
- name: Check Rust build
|
||||||
|
working-directory: desktop/src-tauri
|
||||||
|
run: cargo check --all-targets
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Security Scan
|
||||||
|
# ============================================================================
|
||||||
|
security:
|
||||||
|
name: Security Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:20
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ${{ env.PNPM_VERSION }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
cd desktop && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run npm audit (root)
|
||||||
|
run: pnpm audit --audit-level=high
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Run npm audit (desktop)
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm audit --audit-level=high
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# E2E Tests (Optional - requires browser)
|
||||||
|
# ============================================================================
|
||||||
|
e2e:
|
||||||
|
name: E2E Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, test]
|
||||||
|
container:
|
||||||
|
image: mcr.microsoft.com/playwright:v1.42.0-jammy
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ${{ env.PNPM_VERSION }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm exec playwright install chromium
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm test:e2e
|
||||||
|
continue-on-error: true
|
||||||
139
.gitea/workflows/release.yml
Normal file
139
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# ZCLAW Release Workflow for Gitea
|
||||||
|
# Builds Tauri application and creates Gitea Release
|
||||||
|
# Triggered by pushing version tags (e.g., v0.2.0)
|
||||||
|
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '20'
|
||||||
|
PNPM_VERSION: '9'
|
||||||
|
RUST_VERSION: '1.78'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ============================================================================
|
||||||
|
# Build Tauri Application for Windows
|
||||||
|
# ============================================================================
|
||||||
|
build-windows:
|
||||||
|
name: Build Windows
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: ${{ env.PNPM_VERSION }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-action@stable
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: |
|
||||||
|
desktop/src-tauri
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Prepare OpenFang Runtime
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm prepare:openfang-runtime
|
||||||
|
|
||||||
|
- name: Build Tauri application
|
||||||
|
working-directory: desktop
|
||||||
|
run: pnpm tauri:build:bundled
|
||||||
|
|
||||||
|
- name: Find installer
|
||||||
|
id: find-installer
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$installer = Get-ChildItem -Path "desktop/src-tauri/target/release/bundle/nsis" -Filter "*.exe" -Recurse | Select-Object -First 1
|
||||||
|
echo "INSTALLER_PATH=$($installer.FullName)" >> $env:GITEA_OUTPUT
|
||||||
|
echo "INSTALLER_NAME=$($installer.Name)" >> $env:GITEA_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-installer
|
||||||
|
path: ${{ steps.find-installer.outputs.INSTALLER_PATH }}
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Create Gitea Release
|
||||||
|
# ============================================================================
|
||||||
|
create-release:
|
||||||
|
name: Create Release
|
||||||
|
needs: build-windows
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download Windows artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-installer
|
||||||
|
path: ./artifacts
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: get_version
|
||||||
|
run: echo "VERSION=${GITEA_REF#refs/tags/}" >> $GITEA_OUTPUT
|
||||||
|
|
||||||
|
- name: Create Gitea Release
|
||||||
|
uses: actions/gitea-release@v1
|
||||||
|
with:
|
||||||
|
tag_name: ${{ gitea.ref_name }}
|
||||||
|
name: ZCLAW ${{ steps.get_version.outputs.VERSION }}
|
||||||
|
body: |
|
||||||
|
## ZCLAW ${{ steps.get_version.outputs.VERSION }}
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- See CHANGELOG.md for details
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
- **Windows**: Download the `.exe` installer below
|
||||||
|
|
||||||
|
### System Requirements
|
||||||
|
- Windows 10/11 (64-bit)
|
||||||
|
draft: true
|
||||||
|
prerelease: false
|
||||||
|
files: |
|
||||||
|
./artifacts/*.exe
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Build Summary
|
||||||
|
# ============================================================================
|
||||||
|
release-summary:
|
||||||
|
name: Release Summary
|
||||||
|
needs: [build-windows, create-release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Release Summary
|
||||||
|
run: |
|
||||||
|
echo "## Release Build Complete"
|
||||||
|
echo ""
|
||||||
|
echo "**Tag**: ${{ gitea.ref_name }}"
|
||||||
|
echo ""
|
||||||
|
echo "### Artifacts"
|
||||||
|
echo "- Windows installer uploaded to release"
|
||||||
|
echo ""
|
||||||
|
echo "### Next Steps"
|
||||||
|
echo "1. Review the draft release"
|
||||||
|
echo "2. Update release notes if needed"
|
||||||
|
echo "3. Publish the release when ready"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, useMemo, type CSSProperties, type RefObject, type MutableRefObject } from 'react';
|
import { useState, useEffect, useRef, useCallback, useMemo, type MutableRefObject, type RefObject, type CSSProperties } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { List, type ListImperativeAPI } from 'react-window';
|
import { List, type ListImperativeAPI } from 'react-window';
|
||||||
import { useChatStore, Message } from '../store/chatStore';
|
import { useChatStore, Message } from '../store/chatStore';
|
||||||
@@ -6,13 +6,14 @@ import { useConnectionStore } from '../store/connectionStore';
|
|||||||
import { useAgentStore } from '../store/agentStore';
|
import { useAgentStore } from '../store/agentStore';
|
||||||
import { useConfigStore } from '../store/configStore';
|
import { useConfigStore } from '../store/configStore';
|
||||||
import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare, Download, Copy, Check } from 'lucide-react';
|
import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare, Download, Copy, Check } from 'lucide-react';
|
||||||
import { Button, EmptyState } from './ui';
|
import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui';
|
||||||
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
|
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
|
||||||
import { FirstConversationPrompt } from './FirstConversationPrompt';
|
import { FirstConversationPrompt } from './FirstConversationPrompt';
|
||||||
import { MessageSearch } from './MessageSearch';
|
import { MessageSearch } from './MessageSearch';
|
||||||
|
import { OfflineIndicator } from './OfflineIndicator';
|
||||||
import {
|
import {
|
||||||
useVirtualizedMessages,
|
useVirtualizedMessages,
|
||||||
type VirtualizedMessageItem,
|
type VirtualizedMessageItem
|
||||||
} from '../lib/message-virtualization';
|
} from '../lib/message-virtualization';
|
||||||
|
|
||||||
// Default heights for virtualized messages
|
// Default heights for virtualized messages
|
||||||
@@ -30,7 +31,7 @@ const VIRTUALIZATION_THRESHOLD = 100;
|
|||||||
|
|
||||||
export function ChatArea() {
|
export function ChatArea() {
|
||||||
const {
|
const {
|
||||||
messages, currentAgent, isStreaming, currentModel,
|
messages, currentAgent, isStreaming, isLoading, currentModel,
|
||||||
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
|
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
|
||||||
newConversation,
|
newConversation,
|
||||||
} = useChatStore();
|
} = useChatStore();
|
||||||
@@ -105,7 +106,8 @@ export function ChatArea() {
|
|||||||
}, [messages, useVirtualization, scrollToBottom]);
|
}, [messages, useVirtualization, scrollToBottom]);
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (!input.trim() || isStreaming || !connected) return;
|
if (!input.trim() || isStreaming) return;
|
||||||
|
// Allow sending in offline mode - message will be queued
|
||||||
sendToGateway(input);
|
sendToGateway(input);
|
||||||
setInput('');
|
setInput('');
|
||||||
};
|
};
|
||||||
@@ -134,6 +136,7 @@ export function ChatArea() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
|
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -151,6 +154,8 @@ export function ChatArea() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Offline indicator in header */}
|
||||||
|
<OfflineIndicator compact />
|
||||||
{messages.length > 0 && (
|
{messages.length > 0 && (
|
||||||
<MessageSearch onNavigateToMessage={handleNavigateToMessage} />
|
<MessageSearch onNavigateToMessage={handleNavigateToMessage} />
|
||||||
)}
|
)}
|
||||||
@@ -171,9 +176,23 @@ export function ChatArea() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6 bg-white dark:bg-gray-900">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar bg-white dark:bg-gray-900">
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
{messages.length === 0 && (
|
{/* Loading skeleton */}
|
||||||
|
{isLoading && messages.length === 0 && (
|
||||||
|
<motion.div
|
||||||
|
key="loading-skeleton"
|
||||||
|
variants={fadeInVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
>
|
||||||
|
<MessageListSkeleton count={3} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!isLoading && messages.length === 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="empty-state"
|
key="empty-state"
|
||||||
variants={fadeInVariants}
|
variants={fadeInVariants}
|
||||||
@@ -189,8 +208,8 @@ export function ChatArea() {
|
|||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<MessageSquare className="w-8 h-8" />}
|
icon={<MessageSquare className="w-8 h-8" />}
|
||||||
title="欢迎使用 ZCLAW"
|
title="Welcome to ZCLAW"
|
||||||
description={connected ? '发送消息开始对话' : '请先在设置中连接 Gateway'}
|
description={connected ? 'Send a message to start the conversation.' : 'Please connect to Gateway first in Settings.'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -242,13 +261,11 @@ export function ChatArea() {
|
|||||||
onChange={(e) => { setInput(e.target.value); adjustTextarea(); }}
|
onChange={(e) => { setInput(e.target.value); adjustTextarea(); }}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
!connected
|
isStreaming
|
||||||
? '请先连接 Gateway'
|
|
||||||
: isStreaming
|
|
||||||
? 'Agent 正在回复...'
|
? 'Agent 正在回复...'
|
||||||
: `发送给 ${currentAgent?.name || 'ZCLAW'}`
|
: `发送给 ${currentAgent?.name || 'ZCLAW'}${!connected ? ' (离线模式)' : ''}`
|
||||||
}
|
}
|
||||||
disabled={isStreaming || !connected}
|
disabled={isStreaming}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="w-full bg-transparent border-none focus:outline-none text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none leading-relaxed mt-1"
|
className="w-full bg-transparent border-none focus:outline-none text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none leading-relaxed mt-1"
|
||||||
style={{ minHeight: '24px', maxHeight: '160px' }}
|
style={{ minHeight: '24px', maxHeight: '160px' }}
|
||||||
@@ -289,8 +306,8 @@ export function ChatArea() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={isStreaming || !input.trim() || !connected}
|
disabled={isStreaming || !input.trim()}
|
||||||
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white"
|
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white disabled:opacity-50"
|
||||||
aria-label="发送消息"
|
aria-label="发送消息"
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-4 h-4 text-white" />
|
<ArrowUp className="w-4 h-4 text-white" />
|
||||||
@@ -549,14 +566,10 @@ function MessageBubble({ message }: { message: Message }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={isUser ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
|
<div className={isUser ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
|
||||||
{isThinking ? (
|
{isThinking ? (
|
||||||
// 思考中指示器
|
// Thinking indicator
|
||||||
<div className="flex items-center gap-2 px-4 py-3 text-gray-500 dark:text-gray-400">
|
<div className="flex items-center gap-2 px-4 py-3 text-gray-500 dark:text-gray-400">
|
||||||
<div className="flex gap-1">
|
<LoadingDots />
|
||||||
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
<span className="text-sm">Thinking...</span>
|
||||||
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
||||||
<span className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm">思考中...</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'} relative group`}>
|
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'} relative group`}>
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { useChatStore } from '../store/chatStore';
|
import { useChatStore } from '../store/chatStore';
|
||||||
import { MessageSquare, Trash2, SquarePen } from 'lucide-react';
|
import { MessageSquare, Trash2, SquarePen } from 'lucide-react';
|
||||||
|
import { EmptyConversations, ConversationListSkeleton } from './ui';
|
||||||
|
|
||||||
export function ConversationList() {
|
export function ConversationList() {
|
||||||
const {
|
const {
|
||||||
conversations, currentConversationId, messages, agents, currentAgent,
|
conversations, currentConversationId, messages, agents, currentAgent,
|
||||||
newConversation, switchConversation, deleteConversation,
|
newConversation, switchConversation, deleteConversation,
|
||||||
|
isLoading,
|
||||||
} = useChatStore();
|
} = useChatStore();
|
||||||
|
|
||||||
const hasActiveChat = messages.length > 0;
|
const hasActiveChat = messages.length > 0;
|
||||||
|
|
||||||
|
// Show skeleton during initial load
|
||||||
|
if (isLoading && conversations.length === 0 && !hasActiveChat) {
|
||||||
|
return <ConversationListSkeleton count={4} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -86,11 +93,7 @@ export function ConversationList() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{conversations.length === 0 && !hasActiveChat && (
|
{conversations.length === 0 && !hasActiveChat && (
|
||||||
<div className="text-center py-8 text-xs text-gray-400">
|
<EmptyConversations size="sm" className="h-auto" />
|
||||||
<MessageSquare className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
|
||||||
<p>暂无对话</p>
|
|
||||||
<p className="mt-1">发送消息开始对话</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
378
desktop/src/components/OfflineIndicator.tsx
Normal file
378
desktop/src/components/OfflineIndicator.tsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* OfflineIndicator Component
|
||||||
|
*
|
||||||
|
* Displays offline mode status, pending message count, and reconnection info.
|
||||||
|
* Shows a prominent banner when the app is offline with visual feedback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
WifiOff,
|
||||||
|
CloudOff,
|
||||||
|
RefreshCw,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Send,
|
||||||
|
X,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useOfflineStore, type QueuedMessage } from '../store/offlineStore';
|
||||||
|
import { useConnectionStore } from '../store/connectionStore';
|
||||||
|
|
||||||
|
interface OfflineIndicatorProps {
|
||||||
|
/** Show compact version (minimal) */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Show pending messages list */
|
||||||
|
showQueue?: boolean;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
/** Callback when reconnect button is clicked */
|
||||||
|
onReconnect?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format relative time
|
||||||
|
*/
|
||||||
|
function formatRelativeTime(timestamp: number): string {
|
||||||
|
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||||
|
|
||||||
|
if (seconds < 60) return '刚刚';
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}分钟前`;
|
||||||
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}小时前`;
|
||||||
|
return `${Math.floor(seconds / 86400)}天前`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format reconnect delay for display
|
||||||
|
*/
|
||||||
|
function formatReconnectDelay(delay: number): string {
|
||||||
|
if (delay < 1000) return '立即';
|
||||||
|
if (delay < 60000) return `${Math.ceil(delay / 1000)}秒`;
|
||||||
|
return `${Math.ceil(delay / 60000)}分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate message content for display
|
||||||
|
*/
|
||||||
|
function truncateContent(content: string, maxLength: number = 50): string {
|
||||||
|
if (content.length <= maxLength) return content;
|
||||||
|
return content.slice(0, maxLength) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full offline indicator with banner, queue, and reconnect info
|
||||||
|
*/
|
||||||
|
export function OfflineIndicator({
|
||||||
|
compact = false,
|
||||||
|
showQueue = true,
|
||||||
|
className = '',
|
||||||
|
onReconnect,
|
||||||
|
}: OfflineIndicatorProps) {
|
||||||
|
const {
|
||||||
|
isOffline,
|
||||||
|
isReconnecting,
|
||||||
|
reconnectAttempt,
|
||||||
|
nextReconnectDelay,
|
||||||
|
queuedMessages,
|
||||||
|
cancelReconnect,
|
||||||
|
} = useOfflineStore();
|
||||||
|
|
||||||
|
const connect = useConnectionStore((s) => s.connect);
|
||||||
|
|
||||||
|
const [showMessageQueue, setShowMessageQueue] = useState(false);
|
||||||
|
const [countdown, setCountdown] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Countdown timer for reconnection
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isReconnecting || !nextReconnectDelay) {
|
||||||
|
setCountdown(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = Date.now() + nextReconnectDelay;
|
||||||
|
setCountdown(nextReconnectDelay);
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const remaining = Math.max(0, endTime - Date.now());
|
||||||
|
setCountdown(remaining);
|
||||||
|
|
||||||
|
if (remaining === 0) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isReconnecting, nextReconnectDelay]);
|
||||||
|
|
||||||
|
// Handle manual reconnect
|
||||||
|
const handleReconnect = async () => {
|
||||||
|
onReconnect?.();
|
||||||
|
try {
|
||||||
|
await connect();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[OfflineIndicator] Manual reconnect failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingCount = queuedMessages.filter(
|
||||||
|
(m) => m.status === 'pending' || m.status === 'failed'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Don't show if online and no pending messages
|
||||||
|
if (!isOffline && pendingCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact version for headers/toolbars
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 ${className}`}>
|
||||||
|
{isOffline ? (
|
||||||
|
<>
|
||||||
|
<CloudOff className="w-4 h-4 text-orange-500" />
|
||||||
|
<span className="text-sm text-orange-500 font-medium">
|
||||||
|
离线模式
|
||||||
|
</span>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="text-xs bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 px-1.5 py-0.5 rounded">
|
||||||
|
{pendingCount} 条待发
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="text-sm text-green-500">已恢复连接</span>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded">
|
||||||
|
发送中 {pendingCount} 条
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full banner version
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
className={`${className}`}
|
||||||
|
>
|
||||||
|
{/* Main Banner */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-lg ${
|
||||||
|
isOffline
|
||||||
|
? 'bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800'
|
||||||
|
: 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Status Icon */}
|
||||||
|
<motion.div
|
||||||
|
animate={isReconnecting ? { rotate: 360 } : {}}
|
||||||
|
transition={
|
||||||
|
isReconnecting
|
||||||
|
? { duration: 1, repeat: Infinity, ease: 'linear' }
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isOffline ? (
|
||||||
|
<WifiOff className="w-5 h-5 text-orange-500" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Status Text */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
isOffline ? 'text-orange-700 dark:text-orange-400' : 'text-green-700 dark:text-green-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isOffline ? '后端服务不可用' : '连接已恢复'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{isReconnecting ? (
|
||||||
|
<>
|
||||||
|
正在尝试重连 ({reconnectAttempt}次)
|
||||||
|
{countdown !== null && (
|
||||||
|
<span className="ml-2">
|
||||||
|
{formatReconnectDelay(countdown)}后重试
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : isOffline ? (
|
||||||
|
'消息将保存在本地,连接后自动发送'
|
||||||
|
) : pendingCount > 0 ? (
|
||||||
|
`正在发送 ${pendingCount} 条排队消息...`
|
||||||
|
) : (
|
||||||
|
'所有消息已同步'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isOffline && !isReconnecting && (
|
||||||
|
<button
|
||||||
|
onClick={handleReconnect}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
重连
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isReconnecting && (
|
||||||
|
<button
|
||||||
|
onClick={cancelReconnect}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showQueue && pendingCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMessageQueue(!showMessageQueue)}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
{showMessageQueue ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{pendingCount} 条待发
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Queue */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showMessageQueue && pendingCount > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="mt-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
排队消息
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{queuedMessages
|
||||||
|
.filter((m) => m.status === 'pending' || m.status === 'failed')
|
||||||
|
.map((msg) => (
|
||||||
|
<QueuedMessageItem key={msg.id} message={msg} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual queued message item
|
||||||
|
*/
|
||||||
|
function QueuedMessageItem({ message }: { message: QueuedMessage }) {
|
||||||
|
const { removeMessage } = useOfflineStore();
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
pending: { icon: Clock, color: 'text-gray-400', label: '等待中' },
|
||||||
|
sending: { icon: Send, color: 'text-blue-500', label: '发送中' },
|
||||||
|
failed: { icon: AlertCircle, color: 'text-red-500', label: '发送失败' },
|
||||||
|
sent: { icon: CheckCircle, color: 'text-green-500', label: '已发送' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[message.status];
|
||||||
|
const StatusIcon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 px-4 py-2 border-b border-gray-100 dark:border-gray-800 last:border-b-0">
|
||||||
|
<StatusIcon className={`w-4 h-4 mt-0.5 ${config.color}`} />
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||||
|
{truncateContent(message.content)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{formatRelativeTime(message.timestamp)}
|
||||||
|
</span>
|
||||||
|
{message.status === 'failed' && message.lastError && (
|
||||||
|
<span className="text-xs text-red-500">{message.lastError}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message.status === 'failed' && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeMessage(message.id)}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
|
||||||
|
title="删除消息"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal connection status indicator for headers
|
||||||
|
*/
|
||||||
|
export function ConnectionStatusBadge({ className = '' }: { className?: string }) {
|
||||||
|
const connectionState = useConnectionStore((s) => s.connectionState);
|
||||||
|
const queuedMessages = useOfflineStore((s) => s.queuedMessages);
|
||||||
|
|
||||||
|
const pendingCount = queuedMessages.filter(
|
||||||
|
(m) => m.status === 'pending' || m.status === 'failed'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const isConnected = connectionState === 'connected';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-1.5 ${className}`}>
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full ${
|
||||||
|
isConnected
|
||||||
|
? 'bg-green-400'
|
||||||
|
: connectionState === 'reconnecting'
|
||||||
|
? 'bg-orange-400 animate-pulse'
|
||||||
|
: 'bg-red-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-xs ${
|
||||||
|
isConnected
|
||||||
|
? 'text-green-500'
|
||||||
|
: connectionState === 'reconnecting'
|
||||||
|
? 'text-orange-500'
|
||||||
|
: 'text-red-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isConnected ? '在线' : connectionState === 'reconnecting' ? '重连中' : '离线'}
|
||||||
|
</span>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="text-xs bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 px-1.5 py-0.5 rounded">
|
||||||
|
{pendingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OfflineIndicator;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
import { MessageSquare, Inbox, Search, FileX, Wifi, Bot } from 'lucide-react';
|
||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
@@ -6,19 +7,60 @@ interface EmptyStateProps {
|
|||||||
description: string;
|
description: string;
|
||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Size variant */
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
size = 'md'
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: {
|
||||||
|
container: 'py-4',
|
||||||
|
iconWrapper: 'w-12 h-12',
|
||||||
|
icon: 'w-5 h-5',
|
||||||
|
title: 'text-sm',
|
||||||
|
description: 'text-xs',
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
container: 'p-6',
|
||||||
|
iconWrapper: 'w-16 h-16',
|
||||||
|
icon: 'w-8 h-8',
|
||||||
|
title: 'text-base',
|
||||||
|
description: 'text-sm',
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
container: 'p-8',
|
||||||
|
iconWrapper: 'w-20 h-20',
|
||||||
|
icon: 'w-10 h-10',
|
||||||
|
title: 'text-lg',
|
||||||
|
description: 'text-base',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = sizeClasses[size];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('h-full flex items-center justify-center p-6', className)}>
|
<div className={cn('h-full flex items-center justify-center', sizes.container, className)}>
|
||||||
<div className="text-center max-w-sm">
|
<div className="text-center max-w-sm">
|
||||||
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4 text-gray-400">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-full flex items-center justify-center mx-auto mb-4 text-gray-400',
|
||||||
|
sizes.iconWrapper,
|
||||||
|
'bg-gray-100 dark:bg-gray-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
<h3 className={cn('font-semibold text-gray-700 dark:text-gray-300 mb-2', sizes.title)}>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
<p className={cn('text-gray-500 dark:text-gray-400 mb-4', sizes.description)}>
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
{action}
|
{action}
|
||||||
@@ -26,3 +68,134 @@ export function EmptyState({ icon, title, description, action, className }: Empt
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Pre-built Empty State Variants ===
|
||||||
|
|
||||||
|
interface PrebuiltEmptyStateProps {
|
||||||
|
action?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state for no messages in chat.
|
||||||
|
*/
|
||||||
|
export function EmptyMessages({ action, className, size }: PrebuiltEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<MessageSquare className="w-8 h-8" />}
|
||||||
|
title="No messages yet"
|
||||||
|
description="Start the conversation by sending a message below."
|
||||||
|
action={action}
|
||||||
|
className={className}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state for no conversations.
|
||||||
|
*/
|
||||||
|
export function EmptyConversations({ action, className, size }: PrebuiltEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Inbox className="w-8 h-8" />}
|
||||||
|
title="No conversations"
|
||||||
|
description="Your conversation history will appear here."
|
||||||
|
action={action}
|
||||||
|
className={className}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state for search with no results.
|
||||||
|
*/
|
||||||
|
export function EmptySearchResults({ query, action, className, size }: PrebuiltEmptyStateProps & { query?: string }) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Search className="w-8 h-8" />}
|
||||||
|
title="No results found"
|
||||||
|
description={query ? `No messages matching "${query}"` : 'Try adjusting your search terms.'}
|
||||||
|
action={action}
|
||||||
|
className={className}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state for no files or attachments.
|
||||||
|
*/
|
||||||
|
export function EmptyFiles({ action, className, size }: PrebuiltEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<FileX className="w-8 h-8" />}
|
||||||
|
title="No files"
|
||||||
|
description="No files or attachments here yet."
|
||||||
|
action={action}
|
||||||
|
className={className}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state for offline/disconnected state.
|
||||||
|
*/
|
||||||
|
export function EmptyOffline({ action, className, size }: PrebuiltEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Wifi className="w-8 h-8 text-orange-400" />}
|
||||||
|
title="Offline"
|
||||||
|
description="Please check your connection and try again."
|
||||||
|
action={action}
|
||||||
|
className={className}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state for no agents/clones available.
|
||||||
|
*/
|
||||||
|
export function EmptyAgents({ action, className, size }: PrebuiltEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Bot className="w-8 h-8" />}
|
||||||
|
title="No agents"
|
||||||
|
description="Create an agent to get started with personalized conversations."
|
||||||
|
action={action}
|
||||||
|
className={className}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state for welcome screen.
|
||||||
|
*/
|
||||||
|
export function WelcomeEmptyState({
|
||||||
|
title = "Welcome to ZCLAW",
|
||||||
|
description = "Send a message to start the conversation.",
|
||||||
|
connected = true,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
size,
|
||||||
|
}: PrebuiltEmptyStateProps & {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
connected?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<MessageSquare className="w-8 h-8" />}
|
||||||
|
title={title}
|
||||||
|
description={connected ? description : 'Please connect to Gateway first.'}
|
||||||
|
action={action}
|
||||||
|
className={className}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,15 +109,15 @@ const CATEGORY_CONFIG: Record<ErrorCategory, {
|
|||||||
/**
|
/**
|
||||||
* Get icon component for error category
|
* Get icon component for error category
|
||||||
*/
|
*/
|
||||||
export function getIconByCategory(category: ErrorCategory) typeof Wifi | typeof Shield | typeof Clock | typeof Settings | typeof AlertCircle | typeof AlertTriangle {
|
export function getIconByCategory(category: ErrorCategory): typeof Wifi | typeof Shield | typeof Clock | typeof Settings | typeof AlertCircle | typeof AlertTriangle {
|
||||||
return CATEGORY_CONFIG[category]?. CATEGORY_CONFIG[category].icon : AlertCircle;
|
return CATEGORY_CONFIG[category]?.icon ?? AlertCircle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get color class for error category
|
* Get color class for error category
|
||||||
*/
|
*/
|
||||||
export function getColorByCategory(category: ErrorCategory) string {
|
export function getColorByCategory(category: ErrorCategory): string {
|
||||||
return CATEGORY_CONFIG[category]?. CATEGORY_CONFIG[category].color : 'text-gray-500';
|
return CATEGORY_CONFIG[category]?.color ?? 'text-gray-500';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,11 +140,11 @@ export function ErrorAlert({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Normalize error input
|
// Normalize error input
|
||||||
const appError = typeof error === 'string'
|
const appError = typeof errorProp === 'string'
|
||||||
? classifyError(new Error(error))
|
? classifyError(new Error(errorProp))
|
||||||
: error instanceof Error
|
: errorProp instanceof Error
|
||||||
? classifyError(error)
|
? classifyError(errorProp)
|
||||||
: error;
|
: errorProp;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
category,
|
category,
|
||||||
|
|||||||
@@ -1,66 +1,210 @@
|
|||||||
import { Component, ReactNode, ErrorInfo } from 'react';
|
import { Component, ReactNode, ErrorInfo as ReactErrorInfo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { AlertTriangle, RefreshCcw, Bug, Home } from 'lucide-react';
|
import { AlertTriangle, RefreshCcw, Bug, Home, WifiOff } from 'lucide-react';
|
||||||
import { cn } from '../../lib/utils';
|
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { reportError } from '../../lib/error-handling';
|
import { reportError } from '../../lib/error-handling';
|
||||||
|
import { classifyError, AppError } from '../../lib/error-types';
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
/** Extended error info with additional metadata */
|
||||||
|
interface ExtendedErrorInfo extends ReactErrorInfo {
|
||||||
|
errorName?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
interface ErrorBoundaryProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
fallback?: ReactNode;
|
fallback?: ReactNode;
|
||||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
onError?: (error: Error, errorInfo: ReactErrorInfo) => void;
|
||||||
onReset?: () => void;
|
onReset?: () => void;
|
||||||
|
/** Whether to show connection status indicator */
|
||||||
|
showConnectionStatus?: boolean;
|
||||||
|
/** Custom error title */
|
||||||
|
errorTitle?: string;
|
||||||
|
/** Custom error message */
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
interface ErrorBoundaryState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
errorInfo: ErrorInfo | null;
|
errorInfo: ExtendedErrorInfo | null;
|
||||||
|
appError: AppError | null;
|
||||||
|
showDetails: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Global Error Types ===
|
||||||
|
|
||||||
|
type GlobalErrorType = 'unhandled-rejection' | 'error' | 'websocket' | 'network';
|
||||||
|
|
||||||
|
interface GlobalErrorEvent {
|
||||||
|
type: GlobalErrorType;
|
||||||
|
error: unknown;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Global Error Handler Registry ===
|
||||||
|
|
||||||
|
const globalErrorListeners = new Set<(event: GlobalErrorEvent) => void>();
|
||||||
|
|
||||||
|
export function addGlobalErrorListener(listener: (event: GlobalErrorEvent) => void): () => void {
|
||||||
|
globalErrorListeners.add(listener);
|
||||||
|
return () => globalErrorListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyGlobalErrorListeners(event: GlobalErrorEvent): void {
|
||||||
|
globalErrorListeners.forEach(listener => {
|
||||||
|
try {
|
||||||
|
listener(event);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[GlobalErrorHandler] Listener error:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Setup Global Error Handlers ===
|
||||||
|
|
||||||
|
let globalHandlersSetup = false;
|
||||||
|
|
||||||
|
export function setupGlobalErrorHandlers(): () => void {
|
||||||
|
if (globalHandlersSetup) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
globalHandlersSetup = true;
|
||||||
|
|
||||||
|
// Handle unhandled promise rejections
|
||||||
|
const handleRejection = (event: PromiseRejectionEvent) => {
|
||||||
|
console.error('[GlobalErrorHandler] Unhandled rejection:', event.reason);
|
||||||
|
notifyGlobalErrorListeners({
|
||||||
|
type: 'unhandled-rejection',
|
||||||
|
error: event.reason,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
// Prevent default browser error logging (we handle it ourselves)
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle uncaught errors
|
||||||
|
const handleError = (event: ErrorEvent) => {
|
||||||
|
console.error('[GlobalErrorHandler] Uncaught error:', event.error);
|
||||||
|
notifyGlobalErrorListeners({
|
||||||
|
type: 'error',
|
||||||
|
error: event.error,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
// Let the error boundary handle it if possible
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle WebSocket errors globally
|
||||||
|
const handleWebSocketError = (event: Event) => {
|
||||||
|
if (event.target instanceof WebSocket) {
|
||||||
|
console.error('[GlobalErrorHandler] WebSocket error:', event);
|
||||||
|
notifyGlobalErrorListeners({
|
||||||
|
type: 'websocket',
|
||||||
|
error: new Error('WebSocket connection error'),
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', handleRejection);
|
||||||
|
window.addEventListener('error', handleError);
|
||||||
|
window.addEventListener('error', handleWebSocketError, true); // Capture phase for WebSocket
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('unhandledrejection', handleRejection);
|
||||||
|
window.removeEventListener('error', handleError);
|
||||||
|
window.removeEventListener('error', handleWebSocketError, true);
|
||||||
|
globalHandlersSetup = false;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ErrorBoundary Component
|
* GlobalErrorBoundary Component
|
||||||
*
|
*
|
||||||
* Catches React rendering errors and displays a friendly error screen
|
* Root-level error boundary that catches all React errors and global errors.
|
||||||
* with recovery options and error reporting.
|
* Displays a user-friendly error screen with recovery options.
|
||||||
*/
|
*/
|
||||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
export class GlobalErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
private cleanupGlobalHandlers: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(props: ErrorBoundaryProps) {
|
constructor(props: ErrorBoundaryProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
hasError: false,
|
hasError: false,
|
||||||
error: null,
|
error: null,
|
||||||
errorInfo: null,
|
errorInfo: null,
|
||||||
|
appError: null,
|
||||||
|
showDetails: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): ErrorInfo {
|
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||||
|
const appError = classifyError(error);
|
||||||
return {
|
return {
|
||||||
componentStack: error.stack || 'No stack trace available',
|
hasError: true,
|
||||||
|
error,
|
||||||
|
appError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Setup global error handlers
|
||||||
|
this.cleanupGlobalHandlers = setupGlobalErrorHandlers();
|
||||||
|
|
||||||
|
// Listen for global errors and update state
|
||||||
|
const unsubscribe = addGlobalErrorListener((event) => {
|
||||||
|
if (!this.state.hasError) {
|
||||||
|
const appError = classifyError(event.error);
|
||||||
|
this.setState({
|
||||||
|
hasError: true,
|
||||||
|
error: event.error instanceof Error ? event.error : new Error(String(event.error)),
|
||||||
|
appError,
|
||||||
|
errorInfo: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store cleanup function
|
||||||
|
this.cleanupGlobalHandlers = () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.cleanupGlobalHandlers?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ReactErrorInfo) {
|
||||||
|
const { onError } = this.props;
|
||||||
|
|
||||||
|
// Classify the error
|
||||||
|
const appError = classifyError(error);
|
||||||
|
|
||||||
|
// Update state with extended error info
|
||||||
|
const extendedErrorInfo: ExtendedErrorInfo = {
|
||||||
|
componentStack: errorInfo.componentStack,
|
||||||
errorName: error.name || 'Unknown Error',
|
errorName: error.name || 'Unknown Error',
|
||||||
errorMessage: error.message || 'An unexpected error occurred',
|
errorMessage: error.message || 'An unexpected error occurred',
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
this.setState({
|
||||||
const { onError } = this.props;
|
errorInfo: extendedErrorInfo,
|
||||||
|
appError,
|
||||||
|
});
|
||||||
|
|
||||||
// Call optional error handler
|
// Call optional error handler
|
||||||
if (onError) {
|
if (onError) {
|
||||||
onError(error, errorInfo);
|
onError(error, errorInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update state to show error UI
|
// Report to error tracking
|
||||||
this.setState({
|
reportError(error, {
|
||||||
hasError: true,
|
componentStack: errorInfo.componentStack ?? undefined,
|
||||||
error,
|
errorName: error.name,
|
||||||
errorInfo: {
|
errorMessage: error.message,
|
||||||
componentStack: errorInfo.componentStack,
|
|
||||||
errorName: errorInfo.errorName || error.name || 'Unknown Error',
|
|
||||||
errorMessage: errorInfo.errorMessage || error.message || 'An unexpected error occurred',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleReset = () => {
|
handleReset = () => {
|
||||||
@@ -71,6 +215,8 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
hasError: false,
|
hasError: false,
|
||||||
error: null,
|
error: null,
|
||||||
errorInfo: null,
|
errorInfo: null,
|
||||||
|
appError: null,
|
||||||
|
showDetails: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Call optional reset handler
|
// Call optional reset handler
|
||||||
@@ -79,25 +225,34 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleReport = () => {
|
handleReload = () => {
|
||||||
const { error, errorInfo } = this.state;
|
window.location.reload();
|
||||||
if (error && errorInfo) {
|
|
||||||
reportError(error, {
|
|
||||||
componentStack: errorInfo.componentStack,
|
|
||||||
errorName: errorInfo.errorName,
|
|
||||||
errorMessage: errorInfo.errorMessage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleGoHome = () => {
|
handleGoHome = () => {
|
||||||
// Navigate to home/main view
|
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleReport = () => {
|
||||||
|
const { error, errorInfo } = this.state;
|
||||||
|
if (error) {
|
||||||
|
reportError(error, {
|
||||||
|
componentStack: errorInfo?.componentStack ?? undefined,
|
||||||
|
errorName: errorInfo?.errorName || error.name,
|
||||||
|
errorMessage: errorInfo?.errorMessage || error.message,
|
||||||
|
});
|
||||||
|
// Show confirmation
|
||||||
|
alert('Error reported. Thank you for your feedback.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleDetails = () => {
|
||||||
|
this.setState(prev => ({ showDetails: !prev.showDetails }));
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { children, fallback } = this.props;
|
const { children, fallback, errorTitle, errorMessage } = this.props;
|
||||||
const { hasError, error, errorInfo } = this.state;
|
const { hasError, error, errorInfo, appError, showDetails } = this.state;
|
||||||
|
|
||||||
if (hasError && error) {
|
if (hasError && error) {
|
||||||
// Use custom fallback if provided
|
// Use custom fallback if provided
|
||||||
@@ -105,47 +260,129 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default error UI
|
// Get error display info
|
||||||
|
const title = errorTitle || appError?.title || 'Something went wrong';
|
||||||
|
const message = errorMessage || appError?.message || error.message || 'An unexpected error occurred';
|
||||||
|
const category = appError?.category || 'system';
|
||||||
|
const isNetworkError = category === 'network';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden"
|
transition={{ duration: 0.2 }}
|
||||||
|
className="max-w-lg w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Error Icon */}
|
{/* Error Header */}
|
||||||
<div className="flex items-center justify-center w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full mx-4">
|
<div className={`p-6 ${isNetworkError ? 'bg-orange-50 dark:bg-orange-900/20' : 'bg-red-50 dark:bg-red-900/20'}`}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`p-3 rounded-full ${isNetworkError ? 'bg-orange-100 dark:bg-orange-900/40' : 'bg-red-100 dark:bg-red-900/40'}`}>
|
||||||
|
{isNetworkError ? (
|
||||||
|
<WifiOff className="w-8 h-8 text-orange-500" />
|
||||||
|
) : (
|
||||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
{/* Content */}
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
<div className="p-6 text-center">
|
{title}
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
|
||||||
Something went wrong
|
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
{errorInfo?.errorMessage || error.message || 'An unexpected error occurred'}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Error Details */}
|
{/* Error Details */}
|
||||||
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg text-left">
|
<div className="p-6">
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
{/* Category Badge */}
|
||||||
{errorInfo?.errorName || 'Unknown Error'}
|
{appError && (
|
||||||
</p>
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
|
category === 'network' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' :
|
||||||
|
category === 'auth' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
|
||||||
|
category === 'server' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
|
||||||
|
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
}`}>
|
||||||
|
{category.charAt(0).toUpperCase() + category.slice(1)} Error
|
||||||
|
</span>
|
||||||
|
{appError.recoverable && (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||||
|
Recoverable
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recovery Steps */}
|
||||||
|
{appError?.recoverySteps && appError.recoverySteps.length > 0 && (
|
||||||
|
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Suggested Actions:
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{appError.recoverySteps.slice(0, 3).map((step, index) => (
|
||||||
|
<li key={index} className="text-sm text-gray-600 dark:text-gray-400 flex items-start gap-2">
|
||||||
|
<span className="text-gray-400 mt-0.5">{index + 1}.</span>
|
||||||
|
<span>{step.description}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Technical Details Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={this.toggleDetails}
|
||||||
|
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 flex items-center gap-1 mb-4"
|
||||||
|
>
|
||||||
|
<span>{showDetails ? 'Hide' : 'Show'} technical details</span>
|
||||||
|
<motion.span
|
||||||
|
animate={{ rotate: showDetails ? 180 : 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</motion.span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Technical Details */}
|
||||||
|
{showDetails && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="overflow-hidden mb-4"
|
||||||
|
>
|
||||||
|
<pre className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap break-words max-h-48">
|
||||||
|
{errorInfo?.errorName || error.name}: {errorInfo?.errorMessage || error.message}
|
||||||
|
{errorInfo?.componentStack && `\n\nComponent Stack:${errorInfo.componentStack}`}
|
||||||
|
</pre>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-col gap-2 mt-6">
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={this.handleReset}
|
onClick={this.handleReset}
|
||||||
className="w-full"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<RefreshC className="w-4 h-4 mr-2" />
|
<RefreshCcw className="w-4 h-4 mr-2" />
|
||||||
Try Again
|
Try Again
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={this.handleReload}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Reload Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -156,7 +393,6 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
<Bug className="w-4 h-4 mr-2" />
|
<Bug className="w-4 h-4 mr-2" />
|
||||||
Report Issue
|
Report Issue
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -168,6 +404,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -175,5 +412,115 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ErrorBoundary Component
|
||||||
|
*
|
||||||
|
* A simpler error boundary for wrapping individual components or sections.
|
||||||
|
* Use GlobalErrorBoundary for the root level.
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
appError: null,
|
||||||
|
showDetails: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||||
|
const appError = classifyError(error);
|
||||||
|
return {
|
||||||
|
hasError: true,
|
||||||
|
error,
|
||||||
|
appError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ReactErrorInfo) {
|
||||||
|
const { onError } = this.props;
|
||||||
|
|
||||||
|
// Update state with extended error info
|
||||||
|
const extendedErrorInfo: ExtendedErrorInfo = {
|
||||||
|
componentStack: errorInfo.componentStack,
|
||||||
|
errorName: error.name || 'Unknown Error',
|
||||||
|
errorMessage: error.message || 'An unexpected error occurred',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
errorInfo: extendedErrorInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call optional error handler
|
||||||
|
if (onError) {
|
||||||
|
onError(error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report error
|
||||||
|
reportError(error, {
|
||||||
|
componentStack: errorInfo.componentStack ?? undefined,
|
||||||
|
errorName: error.name,
|
||||||
|
errorMessage: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
const { onReset } = this.props;
|
||||||
|
this.setState({
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
appError: null,
|
||||||
|
showDetails: false,
|
||||||
|
});
|
||||||
|
if (onReset) {
|
||||||
|
onReset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { children, fallback } = this.props;
|
||||||
|
const { hasError, error, appError } = this.state;
|
||||||
|
|
||||||
|
if (hasError && error) {
|
||||||
|
if (fallback) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact error UI for nested boundaries
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||||
|
{appError?.title || 'Error'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||||
|
{appError?.message || error.message}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={this.handleReset}
|
||||||
|
className="mt-2 text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="w-3 h-3 mr-1" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Re-export for convenience ===
|
||||||
|
export { GlobalErrorBoundary as RootErrorBoundary };
|
||||||
|
|||||||
106
desktop/src/components/ui/LoadingSpinner.tsx
Normal file
106
desktop/src/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
/** Size of the spinner */
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
/** Optional text to display below the spinner */
|
||||||
|
text?: string;
|
||||||
|
/** Additional class names */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-4 h-4',
|
||||||
|
md: 'w-6 h-6',
|
||||||
|
lg: 'w-8 h-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small inline loading spinner for buttons and inline contexts.
|
||||||
|
*/
|
||||||
|
export function LoadingSpinner({ size = 'md', text, className }: LoadingSpinnerProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-2', className)}>
|
||||||
|
<Loader2 className={cn('animate-spin text-gray-400 dark:text-gray-500', sizeClasses[size])} />
|
||||||
|
{text && <span className="text-sm text-gray-500 dark:text-gray-400">{text}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadingOverlayProps {
|
||||||
|
/** Whether the overlay is visible */
|
||||||
|
visible: boolean;
|
||||||
|
/** Optional text to display */
|
||||||
|
text?: string;
|
||||||
|
/** Additional class names */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen loading overlay for blocking interactions during loading.
|
||||||
|
*/
|
||||||
|
export function LoadingOverlay({ visible, text = 'Loading...', className }: LoadingOverlayProps) {
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm',
|
||||||
|
'flex items-center justify-center z-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-300">{text}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadingDotsProps {
|
||||||
|
/** Additional class names */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animated dots for "thinking" states.
|
||||||
|
*/
|
||||||
|
export function LoadingDots({ className }: LoadingDotsProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-1', className)}>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
|
||||||
|
style={{ animationDelay: '0ms' }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
|
||||||
|
style={{ animationDelay: '150ms' }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
|
||||||
|
style={{ animationDelay: '300ms' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InlineLoadingProps {
|
||||||
|
/** Loading text */
|
||||||
|
text?: string;
|
||||||
|
/** Additional class names */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact inline loading indicator with text.
|
||||||
|
*/
|
||||||
|
export function InlineLoading({ text = 'Loading...', className }: InlineLoadingProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-2 px-4 py-3 text-gray-500 dark:text-gray-400', className)}>
|
||||||
|
<LoadingDots />
|
||||||
|
<span className="text-sm">{text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,3 +40,142 @@ export function ListSkeleton({ count = 3 }: { count?: number }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton for a single chat message bubble.
|
||||||
|
* Supports both user and assistant message styles.
|
||||||
|
*/
|
||||||
|
export function MessageSkeleton({ isUser = false }: { isUser?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex gap-4', isUser && 'justify-end')}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-lg flex-shrink-0',
|
||||||
|
isUser ? 'bg-gray-200 dark:bg-gray-600 order-last' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Skeleton className="w-full h-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className={cn('flex-1', isUser && 'max-w-2xl')}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-4 rounded-2xl',
|
||||||
|
isUser
|
||||||
|
? 'bg-orange-100 dark:bg-orange-900/30'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Skeleton className="h-4 w-full mb-2" />
|
||||||
|
<Skeleton className="h-4 w-3/4 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton for a list of chat messages.
|
||||||
|
* Alternates between user and assistant skeletons.
|
||||||
|
*/
|
||||||
|
export function MessageListSkeleton({ count = 4 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<MessageSkeleton key={i} isUser={i % 2 === 0} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton for a conversation item in the sidebar.
|
||||||
|
*/
|
||||||
|
export function ConversationItemSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 px-3 py-3 border-b border-gray-50 dark:border-gray-800">
|
||||||
|
<Skeleton className="w-7 h-7 rounded-lg flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Skeleton className="h-3 w-24 mb-1.5" />
|
||||||
|
<Skeleton className="h-2 w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton for the conversation list sidebar.
|
||||||
|
*/
|
||||||
|
export function ConversationListSkeleton({ count = 5 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header skeleton */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
<Skeleton className="w-4 h-4 rounded" />
|
||||||
|
</div>
|
||||||
|
{/* List items */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<ConversationItemSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton for the chat header.
|
||||||
|
*/
|
||||||
|
export function ChatHeaderSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-6 bg-white dark:bg-gray-900">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton for the chat input area.
|
||||||
|
*/
|
||||||
|
export function ChatInputSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-800 p-4 bg-white dark:bg-gray-900">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-end gap-2 bg-gray-50 dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 p-2">
|
||||||
|
<Skeleton className="w-5 h-5 rounded" />
|
||||||
|
<div className="flex-1 py-1">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="w-16 h-6 rounded" />
|
||||||
|
<Skeleton className="w-8 h-8 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center mt-2">
|
||||||
|
<Skeleton className="h-3 w-40 mx-auto" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full chat area skeleton including header, messages, and input.
|
||||||
|
*/
|
||||||
|
export function ChatAreaSkeleton({ messageCount = 4 }: { messageCount?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<ChatHeaderSkeleton />
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<MessageListSkeleton count={messageCount} />
|
||||||
|
</div>
|
||||||
|
<ChatInputSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,8 +8,38 @@ export type { InputProps } from './Input';
|
|||||||
|
|
||||||
export { Badge } from './Badge';
|
export { Badge } from './Badge';
|
||||||
|
|
||||||
export { Skeleton, CardSkeleton, ListSkeleton } from './Skeleton';
|
// Skeleton components
|
||||||
|
export {
|
||||||
|
Skeleton,
|
||||||
|
CardSkeleton,
|
||||||
|
ListSkeleton,
|
||||||
|
MessageSkeleton,
|
||||||
|
MessageListSkeleton,
|
||||||
|
ConversationItemSkeleton,
|
||||||
|
ConversationListSkeleton,
|
||||||
|
ChatHeaderSkeleton,
|
||||||
|
ChatInputSkeleton,
|
||||||
|
ChatAreaSkeleton,
|
||||||
|
} from './Skeleton';
|
||||||
|
|
||||||
export { EmptyState } from './EmptyState';
|
// Empty state components
|
||||||
|
export {
|
||||||
|
EmptyState,
|
||||||
|
EmptyMessages,
|
||||||
|
EmptyConversations,
|
||||||
|
EmptySearchResults,
|
||||||
|
EmptyFiles,
|
||||||
|
EmptyOffline,
|
||||||
|
EmptyAgents,
|
||||||
|
WelcomeEmptyState,
|
||||||
|
} from './EmptyState';
|
||||||
|
|
||||||
|
// Loading components
|
||||||
|
export {
|
||||||
|
LoadingSpinner,
|
||||||
|
LoadingOverlay,
|
||||||
|
LoadingDots,
|
||||||
|
InlineLoading,
|
||||||
|
} from './LoadingSpinner';
|
||||||
|
|
||||||
export { ToastProvider, useToast } from './Toast';
|
export { ToastProvider, useToast } from './Toast';
|
||||||
|
|||||||
476
desktop/src/lib/api-key-storage.ts
Normal file
476
desktop/src/lib/api-key-storage.ts
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
/**
|
||||||
|
* Secure API Key Storage
|
||||||
|
*
|
||||||
|
* Provides secure storage for API keys and sensitive credentials.
|
||||||
|
* Uses OS keychain when available, with encrypted localStorage fallback.
|
||||||
|
*
|
||||||
|
* Security features:
|
||||||
|
* - Keys stored in OS keychain (Windows DPAPI, macOS Keychain, Linux Secret Service)
|
||||||
|
* - Encrypted backup in localStorage for migration support
|
||||||
|
* - Key validation and format checking
|
||||||
|
* - Audit logging for key access
|
||||||
|
* - Support for multiple API key types
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { secureStorage, isSecureStorageAvailable } from './secure-storage';
|
||||||
|
import { hashSha256 } from './crypto-utils';
|
||||||
|
|
||||||
|
// Storage key prefixes
|
||||||
|
const API_KEY_PREFIX = 'zclaw_api_key_';
|
||||||
|
const API_KEY_META_PREFIX = 'zclaw_api_key_meta_';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported API key types
|
||||||
|
*/
|
||||||
|
export type ApiKeyType =
|
||||||
|
| 'openai'
|
||||||
|
| 'anthropic'
|
||||||
|
| 'google'
|
||||||
|
| 'deepseek'
|
||||||
|
| 'zhipu'
|
||||||
|
| 'moonshot'
|
||||||
|
| 'custom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API key metadata
|
||||||
|
*/
|
||||||
|
export interface ApiKeyMetadata {
|
||||||
|
type: ApiKeyType;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastUsedAt?: number;
|
||||||
|
keyHash: string; // Partial hash for validation
|
||||||
|
prefix: string; // First 8 characters for display
|
||||||
|
isValid?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API key entry with metadata
|
||||||
|
*/
|
||||||
|
export interface ApiKeyEntry {
|
||||||
|
key: string;
|
||||||
|
metadata: ApiKeyMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation rules for different API key types
|
||||||
|
*/
|
||||||
|
const KEY_VALIDATION_RULES: Record<ApiKeyType, {
|
||||||
|
pattern: RegExp;
|
||||||
|
minLength: number;
|
||||||
|
maxLength: number;
|
||||||
|
prefix?: string[];
|
||||||
|
}> = {
|
||||||
|
openai: {
|
||||||
|
pattern: /^sk-[A-Za-z0-9_-]{20,}$/,
|
||||||
|
minLength: 20,
|
||||||
|
maxLength: 200,
|
||||||
|
prefix: ['sk-'],
|
||||||
|
},
|
||||||
|
anthropic: {
|
||||||
|
pattern: /^sk-ant-[A-Za-z0-9_-]{20,}$/,
|
||||||
|
minLength: 20,
|
||||||
|
maxLength: 200,
|
||||||
|
prefix: ['sk-ant-'],
|
||||||
|
},
|
||||||
|
google: {
|
||||||
|
pattern: /^AIza[A-Za-z0-9_-]{35}$/,
|
||||||
|
minLength: 35,
|
||||||
|
maxLength: 50,
|
||||||
|
prefix: ['AIza'],
|
||||||
|
},
|
||||||
|
deepseek: {
|
||||||
|
pattern: /^sk-[A-Za-z0-9]{20,}$/,
|
||||||
|
minLength: 20,
|
||||||
|
maxLength: 100,
|
||||||
|
prefix: ['sk-'],
|
||||||
|
},
|
||||||
|
zhipu: {
|
||||||
|
pattern: /^[A-Za-z0-9_.-]{20,}$/,
|
||||||
|
minLength: 20,
|
||||||
|
maxLength: 100,
|
||||||
|
},
|
||||||
|
moonshot: {
|
||||||
|
pattern: /^sk-[A-Za-z0-9]{20,}$/,
|
||||||
|
minLength: 20,
|
||||||
|
maxLength: 100,
|
||||||
|
prefix: ['sk-'],
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
pattern: /^.{8,}$/,
|
||||||
|
minLength: 8,
|
||||||
|
maxLength: 500,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an API key format
|
||||||
|
*
|
||||||
|
* @param type - The API key type
|
||||||
|
* @param key - The API key to validate
|
||||||
|
* @returns True if the key format is valid
|
||||||
|
*/
|
||||||
|
export function validateApiKeyFormat(type: ApiKeyType, key: string): {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
const rules = KEY_VALIDATION_RULES[type];
|
||||||
|
|
||||||
|
if (!key || typeof key !== 'string') {
|
||||||
|
return { valid: false, error: 'API key is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
|
||||||
|
if (trimmedKey.length < rules.minLength) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `API key too short (minimum ${rules.minLength} characters)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedKey.length > rules.maxLength) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `API key too long (maximum ${rules.maxLength} characters)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rules.pattern.test(trimmedKey)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Invalid API key format for type: ${type}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.prefix && !rules.prefix.some(p => trimmedKey.startsWith(p))) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `API key must start with: ${rules.prefix.join(' or ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a partial hash for key validation
|
||||||
|
* Uses first 8 characters for identification without exposing full key
|
||||||
|
*/
|
||||||
|
async function createKeyHash(key: string): Promise<string> {
|
||||||
|
// Use partial hash for validation
|
||||||
|
const partialKey = key.slice(0, 8) + key.slice(-4);
|
||||||
|
return hashSha256(partialKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store an API key securely
|
||||||
|
*
|
||||||
|
* @param type - The API key type
|
||||||
|
* @param key - The API key value
|
||||||
|
* @param options - Optional metadata
|
||||||
|
*/
|
||||||
|
export async function storeApiKey(
|
||||||
|
type: ApiKeyType,
|
||||||
|
key: string,
|
||||||
|
options?: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
): Promise<ApiKeyMetadata> {
|
||||||
|
// Validate key format
|
||||||
|
const validation = validateApiKeyFormat(type, key);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new Error(validation.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
const now = Date.now();
|
||||||
|
const keyHash = await createKeyHash(trimmedKey);
|
||||||
|
|
||||||
|
const metadata: ApiKeyMetadata = {
|
||||||
|
type,
|
||||||
|
name: options?.name || `${type}_api_key`,
|
||||||
|
description: options?.description,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
keyHash,
|
||||||
|
prefix: trimmedKey.slice(0, 8) + '...',
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store key in secure storage
|
||||||
|
const storageKey = API_KEY_PREFIX + type;
|
||||||
|
await secureStorage.set(storageKey, trimmedKey);
|
||||||
|
|
||||||
|
// Store metadata in localStorage (non-sensitive)
|
||||||
|
localStorage.setItem(
|
||||||
|
API_KEY_META_PREFIX + type,
|
||||||
|
JSON.stringify(metadata)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log security event
|
||||||
|
logSecurityEvent('api_key_stored', { type, prefix: metadata.prefix });
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve an API key from secure storage
|
||||||
|
*
|
||||||
|
* @param type - The API key type
|
||||||
|
* @returns The API key or null if not found
|
||||||
|
*/
|
||||||
|
export async function getApiKey(type: ApiKeyType): Promise<string | null> {
|
||||||
|
const storageKey = API_KEY_PREFIX + type;
|
||||||
|
const key = await secureStorage.get(storageKey);
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate key still matches stored hash
|
||||||
|
const metaJson = localStorage.getItem(API_KEY_META_PREFIX + type);
|
||||||
|
if (metaJson) {
|
||||||
|
try {
|
||||||
|
const metadata: ApiKeyMetadata = JSON.parse(metaJson);
|
||||||
|
const currentHash = await createKeyHash(key);
|
||||||
|
|
||||||
|
if (currentHash !== metadata.keyHash) {
|
||||||
|
console.error('[ApiKeyStorage] Key hash mismatch - possible tampering');
|
||||||
|
logSecurityEvent('api_key_hash_mismatch', { type });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last used timestamp
|
||||||
|
metadata.lastUsedAt = Date.now();
|
||||||
|
localStorage.setItem(API_KEY_META_PREFIX + type, JSON.stringify(metadata));
|
||||||
|
} catch {
|
||||||
|
// Ignore metadata parsing errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logSecurityEvent('api_key_accessed', { type });
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get API key metadata (without the actual key)
|
||||||
|
*
|
||||||
|
* @param type - The API key type
|
||||||
|
* @returns The metadata or null if not found
|
||||||
|
*/
|
||||||
|
export function getApiKeyMetadata(type: ApiKeyType): ApiKeyMetadata | null {
|
||||||
|
const metaJson = localStorage.getItem(API_KEY_META_PREFIX + type);
|
||||||
|
if (!metaJson) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(metaJson) as ApiKeyMetadata;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all stored API key metadata
|
||||||
|
*
|
||||||
|
* @returns Array of API key metadata
|
||||||
|
*/
|
||||||
|
export function listApiKeyMetadata(): ApiKeyMetadata[] {
|
||||||
|
const metadata: ApiKeyMetadata[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key?.startsWith(API_KEY_META_PREFIX)) {
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(localStorage.getItem(key) || '');
|
||||||
|
metadata.push(meta);
|
||||||
|
} catch {
|
||||||
|
// Ignore parsing errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an API key
|
||||||
|
*
|
||||||
|
* @param type - The API key type
|
||||||
|
*/
|
||||||
|
export async function deleteApiKey(type: ApiKeyType): Promise<void> {
|
||||||
|
const storageKey = API_KEY_PREFIX + type;
|
||||||
|
await secureStorage.delete(storageKey);
|
||||||
|
localStorage.removeItem(API_KEY_META_PREFIX + type);
|
||||||
|
|
||||||
|
logSecurityEvent('api_key_deleted', { type });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update API key metadata
|
||||||
|
*
|
||||||
|
* @param type - The API key type
|
||||||
|
* @param updates - Metadata updates
|
||||||
|
*/
|
||||||
|
export function updateApiKeyMetadata(
|
||||||
|
type: ApiKeyType,
|
||||||
|
updates: Partial<Pick<ApiKeyMetadata, 'name' | 'description'>>
|
||||||
|
): void {
|
||||||
|
const metaJson = localStorage.getItem(API_KEY_META_PREFIX + type);
|
||||||
|
if (!metaJson) {
|
||||||
|
throw new Error(`API key metadata not found for type: ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: ApiKeyMetadata = JSON.parse(metaJson);
|
||||||
|
Object.assign(metadata, updates, { updatedAt: Date.now() });
|
||||||
|
|
||||||
|
localStorage.setItem(API_KEY_META_PREFIX + type, JSON.stringify(metadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an API key exists for a type
|
||||||
|
*
|
||||||
|
* @param type - The API key type
|
||||||
|
* @returns True if key exists
|
||||||
|
*/
|
||||||
|
export async function hasApiKey(type: ApiKeyType): Promise<boolean> {
|
||||||
|
const key = await getApiKey(type);
|
||||||
|
return key !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a stored API key
|
||||||
|
*
|
||||||
|
* @param type - The API key type
|
||||||
|
* @returns Validation result
|
||||||
|
*/
|
||||||
|
export async function validateStoredApiKey(type: ApiKeyType): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
const key = await getApiKey(type);
|
||||||
|
if (!key) {
|
||||||
|
return { valid: false, error: 'API key not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateApiKeyFormat(type, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate an API key
|
||||||
|
*
|
||||||
|
* @param type - The API key type
|
||||||
|
* @param newKey - The new API key value
|
||||||
|
*/
|
||||||
|
export async function rotateApiKey(type: ApiKeyType, newKey: string): Promise<ApiKeyMetadata> {
|
||||||
|
// Delete old key first
|
||||||
|
await deleteApiKey(type);
|
||||||
|
|
||||||
|
// Store new key
|
||||||
|
return storeApiKey(type, newKey, {
|
||||||
|
name: `${type}_api_key_rotated`,
|
||||||
|
description: `Rotated at ${new Date().toISOString()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export API key configuration (without actual keys)
|
||||||
|
* Useful for backup or migration
|
||||||
|
*/
|
||||||
|
export function exportApiKeyConfig(): Array<Omit<ApiKeyMetadata, 'keyHash'>> {
|
||||||
|
return listApiKeyMetadata().map(({ keyHash: _, ...meta }) => meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if using OS keychain for storage
|
||||||
|
*/
|
||||||
|
export async function isUsingKeychain(): Promise<boolean> {
|
||||||
|
return isSecureStorageAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Security Audit Logging
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface SecurityEvent {
|
||||||
|
type: string;
|
||||||
|
timestamp: number;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECURITY_LOG_KEY = 'zclaw_security_events';
|
||||||
|
const MAX_LOG_ENTRIES = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a security event
|
||||||
|
*/
|
||||||
|
function logSecurityEvent(
|
||||||
|
type: string,
|
||||||
|
details: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const events: SecurityEvent[] = JSON.parse(
|
||||||
|
localStorage.getItem(SECURITY_LOG_KEY) || '[]'
|
||||||
|
);
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
type,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trim old entries
|
||||||
|
if (events.length > MAX_LOG_ENTRIES) {
|
||||||
|
events.splice(0, events.length - MAX_LOG_ENTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(events));
|
||||||
|
} catch {
|
||||||
|
// Ignore logging failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get security event log
|
||||||
|
*/
|
||||||
|
export function getSecurityLog(): SecurityEvent[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(SECURITY_LOG_KEY) || '[]');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear security event log
|
||||||
|
*/
|
||||||
|
export function clearSecurityLog(): void {
|
||||||
|
localStorage.removeItem(SECURITY_LOG_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random API key for testing
|
||||||
|
* WARNING: Only use for testing purposes
|
||||||
|
*/
|
||||||
|
export function generateTestApiKey(type: ApiKeyType): string {
|
||||||
|
const rules = KEY_VALIDATION_RULES[type];
|
||||||
|
const length = rules.minLength + 10;
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
|
||||||
|
let key = '';
|
||||||
|
if (rules.prefix && rules.prefix.length > 0) {
|
||||||
|
key = rules.prefix[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = key.length; i < length; i++) {
|
||||||
|
key += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Cryptographic utilities for secure storage
|
* Cryptographic utilities for secure storage
|
||||||
* Uses Web Crypto API for AES-GCM encryption
|
* Uses Web Crypto API for AES-GCM encryption
|
||||||
|
*
|
||||||
|
* Security features:
|
||||||
|
* - AES-256-GCM for authenticated encryption
|
||||||
|
* - PBKDF2 with 100,000 iterations for key derivation
|
||||||
|
* - Random IV for each encryption operation
|
||||||
|
* - Constant-time comparison for integrity verification
|
||||||
|
* - Secure key caching with automatic expiration
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SALT = new TextEncoder().encode('zclaw-secure-storage-salt');
|
const SALT = new TextEncoder().encode('zclaw-secure-storage-salt');
|
||||||
const ITERATIONS = 100000;
|
const ITERATIONS = 100000;
|
||||||
|
const KEY_EXPIRY_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Uint8Array to base64 string
|
* Convert Uint8Array to base64 string
|
||||||
@@ -33,13 +41,64 @@ export function base64ToArray(base64: string): Uint8Array {
|
|||||||
return array;
|
return array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key cache entry with expiration
|
||||||
|
*/
|
||||||
|
interface CachedKey {
|
||||||
|
key: CryptoKey;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache for derived keys with automatic expiration
|
||||||
|
*/
|
||||||
|
const keyCache = new Map<string, CachedKey>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired keys from cache
|
||||||
|
*/
|
||||||
|
function cleanupExpiredKeys(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [cacheKey, entry] of keyCache.entries()) {
|
||||||
|
if (now - entry.createdAt > KEY_EXPIRY_MS) {
|
||||||
|
keyCache.delete(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cache key from master key and salt
|
||||||
|
*/
|
||||||
|
function getCacheKey(masterKey: string, salt: Uint8Array): string {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const combined = new Uint8Array(encoder.encode(masterKey).length + salt.length);
|
||||||
|
combined.set(encoder.encode(masterKey), 0);
|
||||||
|
combined.set(salt, encoder.encode(masterKey).length);
|
||||||
|
return arrayToBase64(combined.slice(0, 32)); // Use first 32 bytes as cache key
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive an encryption key from a master key
|
* Derive an encryption key from a master key
|
||||||
|
* Uses PBKDF2 with SHA-256 for key derivation
|
||||||
|
*
|
||||||
|
* @param masterKey - The master key string
|
||||||
|
* @param salt - Optional salt (uses default if not provided)
|
||||||
|
* @returns Promise<CryptoKey> - The derived encryption key
|
||||||
*/
|
*/
|
||||||
export async function deriveKey(
|
export async function deriveKey(
|
||||||
masterKey: string,
|
masterKey: string,
|
||||||
salt: Uint8Array = SALT
|
salt: Uint8Array = SALT
|
||||||
): Promise<CryptoKey> {
|
): Promise<CryptoKey> {
|
||||||
|
// Clean up expired keys periodically
|
||||||
|
cleanupExpiredKeys();
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cacheKey = getCacheKey(masterKey, salt);
|
||||||
|
const cached = keyCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.createdAt < KEY_EXPIRY_MS) {
|
||||||
|
return cached.key;
|
||||||
|
}
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const keyMaterial = await crypto.subtle.importKey(
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
@@ -49,7 +108,7 @@ export async function deriveKey(
|
|||||||
['deriveBits', 'deriveKey']
|
['deriveBits', 'deriveKey']
|
||||||
);
|
);
|
||||||
|
|
||||||
return crypto.subtle.deriveKey(
|
const derivedKey = await crypto.subtle.deriveKey(
|
||||||
{
|
{
|
||||||
name: 'PBKDF2',
|
name: 'PBKDF2',
|
||||||
salt,
|
salt,
|
||||||
@@ -61,15 +120,39 @@ export async function deriveKey(
|
|||||||
false,
|
false,
|
||||||
['encrypt', 'decrypt']
|
['encrypt', 'decrypt']
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Cache the derived key
|
||||||
|
keyCache.set(cacheKey, { key: derivedKey, createdAt: Date.now() });
|
||||||
|
|
||||||
|
return derivedKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted data structure
|
||||||
|
*/
|
||||||
|
export interface EncryptedData {
|
||||||
|
iv: string;
|
||||||
|
data: string;
|
||||||
|
authTag?: string; // For future use with separate auth tag
|
||||||
|
version?: number; // Schema version for future migrations
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current encryption schema version
|
||||||
|
*/
|
||||||
|
const ENCRYPTION_VERSION = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt data using AES-GCM
|
* Encrypt data using AES-GCM
|
||||||
|
*
|
||||||
|
* @param plaintext - The plaintext string to encrypt
|
||||||
|
* @param key - The encryption key
|
||||||
|
* @returns Promise<EncryptedData> - The encrypted data with IV
|
||||||
*/
|
*/
|
||||||
export async function encrypt(
|
export async function encrypt(
|
||||||
plaintext: string,
|
plaintext: string,
|
||||||
key: CryptoKey
|
key: CryptoKey
|
||||||
): Promise<{ iv: string; data: string }> {
|
): Promise<EncryptedData> {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
|
||||||
@@ -82,14 +165,19 @@ export async function encrypt(
|
|||||||
return {
|
return {
|
||||||
iv: arrayToBase64(iv),
|
iv: arrayToBase64(iv),
|
||||||
data: arrayToBase64(new Uint8Array(encrypted)),
|
data: arrayToBase64(new Uint8Array(encrypted)),
|
||||||
|
version: ENCRYPTION_VERSION,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt data using AES-GCM
|
* Decrypt data using AES-GCM
|
||||||
|
*
|
||||||
|
* @param encrypted - The encrypted data object
|
||||||
|
* @param key - The decryption key
|
||||||
|
* @returns Promise<string> - The decrypted plaintext
|
||||||
*/
|
*/
|
||||||
export async function decrypt(
|
export async function decrypt(
|
||||||
encrypted: { iv: string; data: string },
|
encrypted: EncryptedData,
|
||||||
key: CryptoKey
|
key: CryptoKey
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@@ -104,8 +192,169 @@ export async function decrypt(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a random master key for encryption
|
* Generate a random master key for encryption
|
||||||
|
* Uses cryptographically secure random number generator
|
||||||
|
*
|
||||||
|
* @returns string - Base64-encoded 256-bit random key
|
||||||
*/
|
*/
|
||||||
export function generateMasterKey(): string {
|
export function generateMasterKey(): string {
|
||||||
const array = crypto.getRandomValues(new Uint8Array(32));
|
const array = crypto.getRandomValues(new Uint8Array(32));
|
||||||
return arrayToBase64(array);
|
return arrayToBase64(array);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random salt
|
||||||
|
*
|
||||||
|
* @param length - Salt length in bytes (default: 16)
|
||||||
|
* @returns Uint8Array - Random salt
|
||||||
|
*/
|
||||||
|
export function generateSalt(length: number = 16): Uint8Array {
|
||||||
|
return crypto.getRandomValues(new Uint8Array(length));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time comparison to prevent timing attacks
|
||||||
|
*
|
||||||
|
* @param a - First byte array
|
||||||
|
* @param b - Second byte array
|
||||||
|
* @returns boolean - True if arrays are equal
|
||||||
|
*/
|
||||||
|
export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
result |= a[i] ^ b[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a string using SHA-256
|
||||||
|
*
|
||||||
|
* @param input - The input string to hash
|
||||||
|
* @returns Promise<string> - Hex-encoded hash
|
||||||
|
*/
|
||||||
|
export async function hashSha256(input: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(input);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = new Uint8Array(hashBuffer);
|
||||||
|
|
||||||
|
// Convert to hex string
|
||||||
|
return Array.from(hashArray)
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a string using SHA-512 (for sensitive data)
|
||||||
|
*
|
||||||
|
* @param input - The input string to hash
|
||||||
|
* @returns Promise<string> - Hex-encoded hash
|
||||||
|
*/
|
||||||
|
export async function hashSha512(input: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(input);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-512', data);
|
||||||
|
const hashArray = new Uint8Array(hashBuffer);
|
||||||
|
|
||||||
|
return Array.from(hashArray)
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically secure random string
|
||||||
|
*
|
||||||
|
* @param length - Length of the string (default: 32)
|
||||||
|
* @returns string - Random alphanumeric string
|
||||||
|
*/
|
||||||
|
export function generateRandomString(length: number = 32): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const array = crypto.getRandomValues(new Uint8Array(length));
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars[array[i] % chars.length];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the key cache (for logout or security events)
|
||||||
|
*/
|
||||||
|
export function clearKeyCache(): void {
|
||||||
|
keyCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a JSON object
|
||||||
|
*
|
||||||
|
* @param obj - The object to encrypt
|
||||||
|
* @param key - The encryption key
|
||||||
|
* @returns Promise<EncryptedData> - The encrypted data
|
||||||
|
*/
|
||||||
|
export async function encryptObject<T>(
|
||||||
|
obj: T,
|
||||||
|
key: CryptoKey
|
||||||
|
): Promise<EncryptedData> {
|
||||||
|
const plaintext = JSON.stringify(obj);
|
||||||
|
return encrypt(plaintext, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a JSON object
|
||||||
|
*
|
||||||
|
* @param encrypted - The encrypted data
|
||||||
|
* @param key - The decryption key
|
||||||
|
* @returns Promise<T> - The decrypted object
|
||||||
|
*/
|
||||||
|
export async function decryptObject<T>(
|
||||||
|
encrypted: EncryptedData,
|
||||||
|
key: CryptoKey
|
||||||
|
): Promise<T> {
|
||||||
|
const plaintext = await decrypt(encrypted, key);
|
||||||
|
return JSON.parse(plaintext) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Securely wipe a string from memory (best effort)
|
||||||
|
* Note: JavaScript strings are immutable, so this only works for
|
||||||
|
* data that was explicitly copied to a Uint8Array
|
||||||
|
*
|
||||||
|
* @param array - The byte array to wipe
|
||||||
|
*/
|
||||||
|
export function secureWipe(array: Uint8Array): void {
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
array.fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Web Crypto API is available
|
||||||
|
*/
|
||||||
|
export function isCryptoAvailable(): boolean {
|
||||||
|
return (
|
||||||
|
typeof crypto !== 'undefined' &&
|
||||||
|
typeof crypto.subtle !== 'undefined' &&
|
||||||
|
typeof crypto.getRandomValues === 'function'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate encrypted data structure
|
||||||
|
*/
|
||||||
|
export function isValidEncryptedData(data: unknown): data is EncryptedData {
|
||||||
|
if (typeof data !== 'object' || data === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = data as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof obj.iv === 'string' &&
|
||||||
|
typeof obj.data === 'string' &&
|
||||||
|
obj.iv.length > 0 &&
|
||||||
|
obj.data.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
412
desktop/src/lib/encrypted-chat-storage.ts
Normal file
412
desktop/src/lib/encrypted-chat-storage.ts
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
/**
|
||||||
|
* Encrypted Chat History Storage
|
||||||
|
*
|
||||||
|
* Provides encrypted persistence for chat messages and conversations.
|
||||||
|
* Uses AES-256-GCM encryption with the secure storage infrastructure.
|
||||||
|
*
|
||||||
|
* Security features:
|
||||||
|
* - All chat data encrypted at rest
|
||||||
|
* - Master key stored in OS keychain when available
|
||||||
|
* - Automatic key derivation with key rotation support
|
||||||
|
* - Secure backup to encrypted localStorage
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
deriveKey,
|
||||||
|
encryptObject,
|
||||||
|
decryptObject,
|
||||||
|
generateMasterKey,
|
||||||
|
hashSha256,
|
||||||
|
isValidEncryptedData,
|
||||||
|
clearKeyCache,
|
||||||
|
} from './crypto-utils';
|
||||||
|
import { secureStorage, isSecureStorageAvailable } from './secure-storage';
|
||||||
|
|
||||||
|
// Storage keys
|
||||||
|
const CHAT_DATA_KEY = 'zclaw_chat_data';
|
||||||
|
const CHAT_KEY_IDENTIFIER = 'zclaw_chat_master_key';
|
||||||
|
const CHAT_KEY_HASH_KEY = 'zclaw_chat_key_hash';
|
||||||
|
const ENCRYPTED_PREFIX = 'enc_chat_';
|
||||||
|
|
||||||
|
// Encryption version for future migrations
|
||||||
|
const STORAGE_VERSION = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage metadata for integrity verification
|
||||||
|
*/
|
||||||
|
interface StorageMetadata {
|
||||||
|
version: number;
|
||||||
|
keyHash: string;
|
||||||
|
createdAt: number;
|
||||||
|
lastAccessedAt: number;
|
||||||
|
encryptedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted storage container
|
||||||
|
*/
|
||||||
|
interface EncryptedContainer {
|
||||||
|
metadata: StorageMetadata;
|
||||||
|
data: string; // Encrypted payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached crypto key for chat encryption
|
||||||
|
*/
|
||||||
|
let cachedChatKey: CryptoKey | null = null;
|
||||||
|
let keyHash: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or initialize the master encryption key for chat storage
|
||||||
|
* Uses OS keychain when available, falls back to encrypted localStorage
|
||||||
|
*/
|
||||||
|
async function getOrCreateMasterKey(): Promise<string> {
|
||||||
|
// Try to get existing key from secure storage
|
||||||
|
const existingKey = await secureStorage.get(CHAT_KEY_IDENTIFIER);
|
||||||
|
if (existingKey) {
|
||||||
|
return existingKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new master key
|
||||||
|
const newKey = generateMasterKey();
|
||||||
|
|
||||||
|
// Store in secure storage (keychain or encrypted localStorage)
|
||||||
|
await secureStorage.set(CHAT_KEY_IDENTIFIER, newKey);
|
||||||
|
|
||||||
|
// Store hash for integrity verification
|
||||||
|
const keyHashValue = await hashSha256(newKey);
|
||||||
|
localStorage.setItem(CHAT_KEY_HASH_KEY, keyHashValue);
|
||||||
|
|
||||||
|
console.log('[EncryptedChatStorage] Generated new master key');
|
||||||
|
return newKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the derived encryption key for chat data
|
||||||
|
*/
|
||||||
|
async function getChatEncryptionKey(): Promise<CryptoKey> {
|
||||||
|
if (cachedChatKey && keyHash) {
|
||||||
|
// Verify key hash matches
|
||||||
|
const storedHash = localStorage.getItem(CHAT_KEY_HASH_KEY);
|
||||||
|
if (storedHash === keyHash) {
|
||||||
|
return cachedChatKey;
|
||||||
|
}
|
||||||
|
// Hash mismatch - clear cache and re-derive
|
||||||
|
console.warn('[EncryptedChatStorage] Key hash mismatch, re-deriving key');
|
||||||
|
cachedChatKey = null;
|
||||||
|
keyHash = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterKey = await getOrCreateMasterKey();
|
||||||
|
cachedChatKey = await deriveKey(masterKey);
|
||||||
|
keyHash = await hashSha256(masterKey);
|
||||||
|
|
||||||
|
return cachedChatKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize encrypted chat storage
|
||||||
|
* Called during app startup
|
||||||
|
*/
|
||||||
|
export async function initializeEncryptedChatStorage(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Pre-load the encryption key
|
||||||
|
await getChatEncryptionKey();
|
||||||
|
|
||||||
|
// Check if we have existing encrypted data to migrate
|
||||||
|
const legacyData = localStorage.getItem('zclaw-chat-storage');
|
||||||
|
if (legacyData && !localStorage.getItem(ENCRYPTED_PREFIX + 'migrated')) {
|
||||||
|
await migrateFromLegacyStorage(legacyData);
|
||||||
|
localStorage.setItem(ENCRYPTED_PREFIX + 'migrated', 'true');
|
||||||
|
console.log('[EncryptedChatStorage] Migrated legacy data');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[EncryptedChatStorage] Initialized successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EncryptedChatStorage] Initialization failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate data from legacy unencrypted storage
|
||||||
|
*/
|
||||||
|
async function migrateFromLegacyStorage(legacyData: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(legacyData);
|
||||||
|
if (parsed?.state?.conversations) {
|
||||||
|
await saveConversations(parsed.state.conversations);
|
||||||
|
console.log(`[EncryptedChatStorage] Migrated ${parsed.state.conversations.length} conversations`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EncryptedChatStorage] Migration failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save conversations to encrypted storage
|
||||||
|
*
|
||||||
|
* @param conversations - Array of conversation objects
|
||||||
|
*/
|
||||||
|
export async function saveConversations(conversations: unknown[]): Promise<void> {
|
||||||
|
if (!conversations || conversations.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = await getChatEncryptionKey();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Create container with metadata
|
||||||
|
const container: EncryptedContainer = {
|
||||||
|
metadata: {
|
||||||
|
version: STORAGE_VERSION,
|
||||||
|
keyHash: keyHash || '',
|
||||||
|
createdAt: now,
|
||||||
|
lastAccessedAt: now,
|
||||||
|
encryptedAt: now,
|
||||||
|
},
|
||||||
|
data: '', // Will be set after encryption
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encrypt the conversations array
|
||||||
|
const encrypted = await encryptObject(conversations, key);
|
||||||
|
container.data = JSON.stringify(encrypted);
|
||||||
|
|
||||||
|
// Store the encrypted container
|
||||||
|
localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(container));
|
||||||
|
|
||||||
|
console.log(`[EncryptedChatStorage] Saved ${conversations.length} conversations`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EncryptedChatStorage] Failed to save conversations:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load conversations from encrypted storage
|
||||||
|
*
|
||||||
|
* @returns Array of conversation objects or empty array if none exist
|
||||||
|
*/
|
||||||
|
export async function loadConversations<T = unknown>(): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||||
|
if (!stored) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const container: EncryptedContainer = JSON.parse(stored);
|
||||||
|
|
||||||
|
// Validate container structure
|
||||||
|
if (!container.data || !container.metadata) {
|
||||||
|
console.warn('[EncryptedChatStorage] Invalid container structure');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check version compatibility
|
||||||
|
if (container.metadata.version > STORAGE_VERSION) {
|
||||||
|
console.error('[EncryptedChatStorage] Incompatible storage version');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and decrypt the data
|
||||||
|
const encryptedData = JSON.parse(container.data);
|
||||||
|
if (!isValidEncryptedData(encryptedData)) {
|
||||||
|
console.error('[EncryptedChatStorage] Invalid encrypted data');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await getChatEncryptionKey();
|
||||||
|
const conversations = await decryptObject<T[]>(encryptedData, key);
|
||||||
|
|
||||||
|
// Update last accessed time
|
||||||
|
container.metadata.lastAccessedAt = Date.now();
|
||||||
|
localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(container));
|
||||||
|
|
||||||
|
console.log(`[EncryptedChatStorage] Loaded ${conversations.length} conversations`);
|
||||||
|
return conversations;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EncryptedChatStorage] Failed to load conversations:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all chat data from storage
|
||||||
|
*/
|
||||||
|
export async function clearAllChatData(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Clear encrypted data
|
||||||
|
localStorage.removeItem(CHAT_DATA_KEY);
|
||||||
|
localStorage.removeItem(ENCRYPTED_PREFIX + 'migrated');
|
||||||
|
|
||||||
|
// Clear the master key from secure storage
|
||||||
|
await secureStorage.delete(CHAT_KEY_IDENTIFIER);
|
||||||
|
localStorage.removeItem(CHAT_KEY_HASH_KEY);
|
||||||
|
|
||||||
|
// Clear cached key
|
||||||
|
cachedChatKey = null;
|
||||||
|
keyHash = null;
|
||||||
|
clearKeyCache();
|
||||||
|
|
||||||
|
console.log('[EncryptedChatStorage] Cleared all chat data');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EncryptedChatStorage] Failed to clear chat data:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export encrypted chat data for backup
|
||||||
|
* Returns encrypted blob that can be imported later
|
||||||
|
*
|
||||||
|
* @returns Base64-encoded encrypted backup
|
||||||
|
*/
|
||||||
|
export async function exportEncryptedBackup(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||||
|
if (!stored) {
|
||||||
|
throw new Error('No chat data to export');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The data is already encrypted, just return it
|
||||||
|
const container: EncryptedContainer = JSON.parse(stored);
|
||||||
|
const exportData = {
|
||||||
|
type: 'zclaw_chat_backup',
|
||||||
|
version: STORAGE_VERSION,
|
||||||
|
exportedAt: Date.now(),
|
||||||
|
container,
|
||||||
|
};
|
||||||
|
|
||||||
|
return btoa(JSON.stringify(exportData));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EncryptedChatStorage] Export failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import encrypted chat data from backup
|
||||||
|
*
|
||||||
|
* @param backupData - Base64-encoded encrypted backup
|
||||||
|
* @param merge - Whether to merge with existing data (default: false, replaces)
|
||||||
|
*/
|
||||||
|
export async function importEncryptedBackup(
|
||||||
|
backupData: string,
|
||||||
|
merge: boolean = false
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const decoded = JSON.parse(atob(backupData));
|
||||||
|
|
||||||
|
// Validate backup format
|
||||||
|
if (decoded.type !== 'zclaw_chat_backup') {
|
||||||
|
throw new Error('Invalid backup format');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded.version > STORAGE_VERSION) {
|
||||||
|
throw new Error('Incompatible backup version');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merge) {
|
||||||
|
// Load existing conversations and merge
|
||||||
|
const existing = await loadConversations();
|
||||||
|
const imported = await decryptObject<unknown[]>(
|
||||||
|
JSON.parse(decoded.container.data),
|
||||||
|
await getChatEncryptionKey()
|
||||||
|
);
|
||||||
|
const merged = [...existing, ...imported];
|
||||||
|
await saveConversations(merged);
|
||||||
|
} else {
|
||||||
|
// Replace existing data
|
||||||
|
localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(decoded.container));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[EncryptedChatStorage] Import completed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EncryptedChatStorage] Import failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if encrypted storage is being used
|
||||||
|
*/
|
||||||
|
export async function isEncryptedStorageActive(): Promise<boolean> {
|
||||||
|
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||||
|
if (!stored) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const container: EncryptedContainer = JSON.parse(stored);
|
||||||
|
return container.metadata?.version === STORAGE_VERSION;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage statistics
|
||||||
|
*/
|
||||||
|
export async function getStorageStats(): Promise<{
|
||||||
|
encrypted: boolean;
|
||||||
|
usingKeychain: boolean;
|
||||||
|
conversationCount: number;
|
||||||
|
storageSize: number;
|
||||||
|
}> {
|
||||||
|
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||||
|
let conversationCount = 0;
|
||||||
|
let encrypted = false;
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const container: EncryptedContainer = JSON.parse(stored);
|
||||||
|
encrypted = container.metadata?.version === STORAGE_VERSION;
|
||||||
|
|
||||||
|
// Count conversations without full decryption
|
||||||
|
const conversations = await loadConversations();
|
||||||
|
conversationCount = conversations.length;
|
||||||
|
} catch {
|
||||||
|
// Ignore parsing errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
encrypted,
|
||||||
|
usingKeychain: await isSecureStorageAvailable(),
|
||||||
|
conversationCount,
|
||||||
|
storageSize: stored ? new Blob([stored]).size : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate encryption key
|
||||||
|
* Re-encrypts all data with a new key
|
||||||
|
*/
|
||||||
|
export async function rotateEncryptionKey(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Load existing data
|
||||||
|
const conversations = await loadConversations();
|
||||||
|
|
||||||
|
// Clear old key
|
||||||
|
await secureStorage.delete(CHAT_KEY_IDENTIFIER);
|
||||||
|
localStorage.removeItem(CHAT_KEY_HASH_KEY);
|
||||||
|
cachedChatKey = null;
|
||||||
|
keyHash = null;
|
||||||
|
clearKeyCache();
|
||||||
|
|
||||||
|
// Generate new key (will be created on next getChatEncryptionKey call)
|
||||||
|
const newKey = generateMasterKey();
|
||||||
|
await secureStorage.set(CHAT_KEY_IDENTIFIER, newKey);
|
||||||
|
const newKeyHash = await hashSha256(newKey);
|
||||||
|
localStorage.setItem(CHAT_KEY_HASH_KEY, newKeyHash);
|
||||||
|
|
||||||
|
// Re-save all data with new key
|
||||||
|
await saveConversations(conversations);
|
||||||
|
|
||||||
|
console.log('[EncryptedChatStorage] Encryption key rotated successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EncryptedChatStorage] Key rotation failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,47 @@ export class SecurityError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection error for WebSocket/HTTP connection failures.
|
||||||
|
*/
|
||||||
|
export class ConnectionError extends Error {
|
||||||
|
public readonly code?: string;
|
||||||
|
public readonly recoverable: boolean;
|
||||||
|
|
||||||
|
constructor(message: string, code?: string, recoverable: boolean = true) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ConnectionError';
|
||||||
|
this.code = code;
|
||||||
|
this.recoverable = recoverable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeout error for request/response timeouts.
|
||||||
|
*/
|
||||||
|
export class TimeoutError extends Error {
|
||||||
|
public readonly timeout: number;
|
||||||
|
|
||||||
|
constructor(message: string, timeout: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'TimeoutError';
|
||||||
|
this.timeout = timeout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication error for handshake/token failures.
|
||||||
|
*/
|
||||||
|
export class AuthenticationError extends Error {
|
||||||
|
public readonly code?: string;
|
||||||
|
|
||||||
|
constructor(message: string, code?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AuthenticationError';
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate WebSocket URL security.
|
* Validate WebSocket URL security.
|
||||||
* Ensures non-localhost connections use WSS protocol.
|
* Ensures non-localhost connections use WSS protocol.
|
||||||
|
|||||||
@@ -3,9 +3,14 @@
|
|||||||
*
|
*
|
||||||
* Extracted from gateway-client.ts for modularity.
|
* Extracted from gateway-client.ts for modularity.
|
||||||
* Manages WSS configuration, URL normalization, and
|
* Manages WSS configuration, URL normalization, and
|
||||||
* localStorage persistence for gateway URL and token.
|
* secure storage persistence for gateway URL and token.
|
||||||
|
*
|
||||||
|
* Security: Token is now stored using secure storage (keychain or encrypted localStorage)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { secureStorage } from './secure-storage';
|
||||||
|
import { logKeyEvent, logSecurityEvent } from './security-audit';
|
||||||
|
|
||||||
// === WSS Configuration ===
|
// === WSS Configuration ===
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,18 +100,104 @@ export function setStoredGatewayUrl(url: string): string {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStoredGatewayToken(): string {
|
/**
|
||||||
|
* Get the stored gateway token from secure storage
|
||||||
|
* Uses OS keychain when available, falls back to encrypted localStorage
|
||||||
|
*
|
||||||
|
* @returns The stored token or empty string if not found
|
||||||
|
*/
|
||||||
|
export async function getStoredGatewayTokenAsync(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(GATEWAY_TOKEN_STORAGE_KEY) || '';
|
const token = await secureStorage.get(GATEWAY_TOKEN_STORAGE_KEY);
|
||||||
|
if (token) {
|
||||||
|
logKeyEvent('key_accessed', 'Retrieved gateway token', { source: 'secure_storage' });
|
||||||
|
}
|
||||||
|
return token || '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GatewayStorage] Failed to get gateway token:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous version for backward compatibility
|
||||||
|
* @deprecated Use getStoredGatewayTokenAsync() instead
|
||||||
|
*/
|
||||||
|
export function getStoredGatewayToken(): string {
|
||||||
|
// This returns empty string and logs a warning in dev mode
|
||||||
|
// Real code should use the async version
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.warn('[GatewayStorage] Using synchronous token access - consider using async version');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get from localStorage as fallback (may be encrypted)
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(GATEWAY_TOKEN_STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
// Check if it's encrypted (has iv and data fields)
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (parsed && typeof parsed.iv === 'string' && typeof parsed.data === 'string') {
|
||||||
|
// Data is encrypted - cannot decrypt synchronously
|
||||||
|
console.warn('[GatewayStorage] Token is encrypted - use async version');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON, so it's plaintext (legacy format)
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setStoredGatewayToken(token: string): string {
|
/**
|
||||||
|
* Store the gateway token securely
|
||||||
|
* Uses OS keychain when available, falls back to encrypted localStorage
|
||||||
|
*
|
||||||
|
* @param token - The token to store
|
||||||
|
* @returns The normalized token
|
||||||
|
*/
|
||||||
|
export async function setStoredGatewayTokenAsync(token: string): Promise<string> {
|
||||||
const normalized = token.trim();
|
const normalized = token.trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
|
await secureStorage.set(GATEWAY_TOKEN_STORAGE_KEY, normalized);
|
||||||
|
logKeyEvent('key_stored', 'Stored gateway token', { source: 'secure_storage' });
|
||||||
|
} else {
|
||||||
|
await secureStorage.delete(GATEWAY_TOKEN_STORAGE_KEY);
|
||||||
|
logKeyEvent('key_deleted', 'Deleted gateway token', { source: 'secure_storage' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear legacy localStorage token if it exists
|
||||||
|
localStorage.removeItem(GATEWAY_TOKEN_STORAGE_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GatewayStorage] Failed to store gateway token:', error);
|
||||||
|
logSecurityEvent('security_violation', 'Failed to store gateway token securely', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous version for backward compatibility
|
||||||
|
* @deprecated Use setStoredGatewayTokenAsync() instead
|
||||||
|
*/
|
||||||
|
export function setStoredGatewayToken(token: string): string {
|
||||||
|
const normalized = token.trim();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.warn('[GatewayStorage] Using synchronous token storage - consider using async version');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (normalized) {
|
||||||
|
// Store in localStorage as fallback (not secure, but better than nothing)
|
||||||
localStorage.setItem(GATEWAY_TOKEN_STORAGE_KEY, normalized);
|
localStorage.setItem(GATEWAY_TOKEN_STORAGE_KEY, normalized);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(GATEWAY_TOKEN_STORAGE_KEY);
|
localStorage.removeItem(GATEWAY_TOKEN_STORAGE_KEY);
|
||||||
@@ -114,5 +205,6 @@ export function setStoredGatewayToken(token: string): string {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore localStorage failures */
|
/* ignore localStorage failures */
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|||||||
564
desktop/src/lib/security-audit.ts
Normal file
564
desktop/src/lib/security-audit.ts
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
/**
|
||||||
|
* Security Audit Logging Module
|
||||||
|
*
|
||||||
|
* Provides comprehensive security event logging for ZCLAW application.
|
||||||
|
* All security-relevant events are logged with timestamps and details.
|
||||||
|
*
|
||||||
|
* Security events logged:
|
||||||
|
* - Authentication events (login, logout, failed attempts)
|
||||||
|
* - API key operations (access, rotation, deletion)
|
||||||
|
* - Data access events (encrypted data read/write)
|
||||||
|
* - Security violations (failed decryption, tampering attempts)
|
||||||
|
* - Configuration changes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { hashSha256 } from './crypto-utils';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type SecurityEventType =
|
||||||
|
| 'auth_login'
|
||||||
|
| 'auth_logout'
|
||||||
|
| 'auth_failed'
|
||||||
|
| 'auth_token_refresh'
|
||||||
|
| 'key_accessed'
|
||||||
|
| 'key_stored'
|
||||||
|
| 'key_deleted'
|
||||||
|
| 'key_rotated'
|
||||||
|
| 'data_encrypted'
|
||||||
|
| 'data_decrypted'
|
||||||
|
| 'data_access'
|
||||||
|
| 'data_export'
|
||||||
|
| 'data_import'
|
||||||
|
| 'security_violation'
|
||||||
|
| 'decryption_failed'
|
||||||
|
| 'integrity_check_failed'
|
||||||
|
| 'config_changed'
|
||||||
|
| 'permission_granted'
|
||||||
|
| 'permission_denied'
|
||||||
|
| 'session_started'
|
||||||
|
| 'session_ended'
|
||||||
|
| 'rate_limit_exceeded'
|
||||||
|
| 'suspicious_activity';
|
||||||
|
|
||||||
|
export type SecurityEventSeverity = 'info' | 'warning' | 'error' | 'critical';
|
||||||
|
|
||||||
|
export interface SecurityEvent {
|
||||||
|
id: string;
|
||||||
|
type: SecurityEventType;
|
||||||
|
severity: SecurityEventSeverity;
|
||||||
|
timestamp: string;
|
||||||
|
message: string;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
userAgent?: string;
|
||||||
|
ip?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
agentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityAuditReport {
|
||||||
|
generatedAt: string;
|
||||||
|
totalEvents: number;
|
||||||
|
eventsByType: Record<SecurityEventType, number>;
|
||||||
|
eventsBySeverity: Record<SecurityEventSeverity, number>;
|
||||||
|
recentCriticalEvents: SecurityEvent[];
|
||||||
|
recommendations: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const SECURITY_LOG_KEY = 'zclaw_security_audit_log';
|
||||||
|
const MAX_LOG_ENTRIES = 2000;
|
||||||
|
const AUDIT_VERSION = 1;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Internal State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let isAuditEnabled: boolean = true;
|
||||||
|
let currentSessionId: string | null = null;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Core Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique event ID
|
||||||
|
*/
|
||||||
|
function generateEventId(): string {
|
||||||
|
return `evt_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current session ID
|
||||||
|
*/
|
||||||
|
export function getCurrentSessionId(): string | null {
|
||||||
|
return currentSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the current session ID
|
||||||
|
*/
|
||||||
|
export function setCurrentSessionId(sessionId: string | null): void {
|
||||||
|
currentSessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable audit logging
|
||||||
|
*/
|
||||||
|
export function setAuditEnabled(enabled: boolean): void {
|
||||||
|
isAuditEnabled = enabled;
|
||||||
|
logSecurityEventInternal('config_changed', 'info', `Audit logging ${enabled ? 'enabled' : 'disabled'}`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if audit logging is enabled
|
||||||
|
*/
|
||||||
|
export function isAuditEnabledState(): boolean {
|
||||||
|
return isAuditEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal function to persist security events
|
||||||
|
*/
|
||||||
|
function persistEvent(event: SecurityEvent): void {
|
||||||
|
try {
|
||||||
|
const events = getStoredEvents();
|
||||||
|
events.push(event);
|
||||||
|
|
||||||
|
// Trim old entries if needed
|
||||||
|
if (events.length > MAX_LOG_ENTRIES) {
|
||||||
|
events.splice(0, events.length - MAX_LOG_ENTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(events));
|
||||||
|
} catch {
|
||||||
|
// Ignore persistence failures to prevent application disruption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored security events
|
||||||
|
*/
|
||||||
|
function getStoredEvents(): SecurityEvent[] {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SECURITY_LOG_KEY);
|
||||||
|
if (!stored) return [];
|
||||||
|
return JSON.parse(stored) as SecurityEvent[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine severity based on event type
|
||||||
|
*/
|
||||||
|
function getDefaultSeverity(type: SecurityEventType): SecurityEventSeverity {
|
||||||
|
const severityMap: Record<SecurityEventType, SecurityEventSeverity> = {
|
||||||
|
auth_login: 'info',
|
||||||
|
auth_logout: 'info',
|
||||||
|
auth_failed: 'warning',
|
||||||
|
auth_token_refresh: 'info',
|
||||||
|
key_accessed: 'info',
|
||||||
|
key_stored: 'info',
|
||||||
|
key_deleted: 'warning',
|
||||||
|
key_rotated: 'info',
|
||||||
|
data_encrypted: 'info',
|
||||||
|
data_decrypted: 'info',
|
||||||
|
data_access: 'info',
|
||||||
|
data_export: 'warning',
|
||||||
|
data_import: 'warning',
|
||||||
|
security_violation: 'critical',
|
||||||
|
decryption_failed: 'error',
|
||||||
|
integrity_check_failed: 'critical',
|
||||||
|
config_changed: 'warning',
|
||||||
|
permission_granted: 'info',
|
||||||
|
permission_denied: 'warning',
|
||||||
|
session_started: 'info',
|
||||||
|
session_ended: 'info',
|
||||||
|
rate_limit_exceeded: 'warning',
|
||||||
|
suspicious_activity: 'critical',
|
||||||
|
};
|
||||||
|
|
||||||
|
return severityMap[type] || 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal function to log security events
|
||||||
|
*/
|
||||||
|
function logSecurityEventInternal(
|
||||||
|
type: SecurityEventType,
|
||||||
|
severity: SecurityEventSeverity,
|
||||||
|
message: string,
|
||||||
|
details: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
if (!isAuditEnabled && type !== 'config_changed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event: SecurityEvent = {
|
||||||
|
id: generateEventId(),
|
||||||
|
type,
|
||||||
|
severity,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
sessionId: currentSessionId || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add user agent if in browser
|
||||||
|
if (typeof navigator !== 'undefined') {
|
||||||
|
event.userAgent = navigator.userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistEvent(event);
|
||||||
|
|
||||||
|
// Log to console for development
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
const logMethod = severity === 'critical' || severity === 'error' ? 'error' :
|
||||||
|
severity === 'warning' ? 'warn' : 'log';
|
||||||
|
console[logMethod](`[SecurityAudit] ${type}: ${message}`, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a security event
|
||||||
|
*/
|
||||||
|
export function logSecurityEvent(
|
||||||
|
type: SecurityEventType,
|
||||||
|
message: string,
|
||||||
|
details: Record<string, unknown> = {},
|
||||||
|
severity?: SecurityEventSeverity
|
||||||
|
): void {
|
||||||
|
const eventSeverity = severity || getDefaultSeverity(type);
|
||||||
|
logSecurityEventInternal(type, eventSeverity, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log authentication event
|
||||||
|
*/
|
||||||
|
export function logAuthEvent(
|
||||||
|
type: 'auth_login' | 'auth_logout' | 'auth_failed' | 'auth_token_refresh',
|
||||||
|
message: string,
|
||||||
|
details: Record<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
logSecurityEvent(type, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log key management event
|
||||||
|
*/
|
||||||
|
export function logKeyEvent(
|
||||||
|
type: 'key_accessed' | 'key_stored' | 'key_deleted' | 'key_rotated',
|
||||||
|
message: string,
|
||||||
|
details: Record<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
logSecurityEvent(type, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log data access event
|
||||||
|
*/
|
||||||
|
export function logDataEvent(
|
||||||
|
type: 'data_encrypted' | 'data_decrypted' | 'data_access' | 'data_export' | 'data_import',
|
||||||
|
message: string,
|
||||||
|
details: Record<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
logSecurityEvent(type, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log security violation
|
||||||
|
*/
|
||||||
|
export function logSecurityViolation(
|
||||||
|
message: string,
|
||||||
|
details: Record<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
logSecurityEvent('security_violation', message, details, 'critical');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log decryption failure
|
||||||
|
*/
|
||||||
|
export function logDecryptionFailure(
|
||||||
|
message: string,
|
||||||
|
details: Record<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
logSecurityEvent('decryption_failed', message, details, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log integrity check failure
|
||||||
|
*/
|
||||||
|
export function logIntegrityFailure(
|
||||||
|
message: string,
|
||||||
|
details: Record<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
logSecurityEvent('integrity_check_failed', message, details, 'critical');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log permission event
|
||||||
|
*/
|
||||||
|
export function logPermissionEvent(
|
||||||
|
type: 'permission_granted' | 'permission_denied',
|
||||||
|
message: string,
|
||||||
|
details: Record<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
logSecurityEvent(type, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log session event
|
||||||
|
*/
|
||||||
|
export function logSessionEvent(
|
||||||
|
type: 'session_started' | 'session_ended',
|
||||||
|
message: string,
|
||||||
|
details: Record<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
logSecurityEvent(type, message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log suspicious activity
|
||||||
|
*/
|
||||||
|
export function logSuspiciousActivity(
|
||||||
|
message: string,
|
||||||
|
details: Record<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
logSecurityEvent('suspicious_activity', message, details, 'critical');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log rate limit event
|
||||||
|
*/
|
||||||
|
export function logRateLimitEvent(
|
||||||
|
message: string,
|
||||||
|
details: Record<string, unknown> = {}
|
||||||
|
): void {
|
||||||
|
logSecurityEvent('rate_limit_exceeded', message, details, 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Query Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all security events
|
||||||
|
*/
|
||||||
|
export function getSecurityEvents(): SecurityEvent[] {
|
||||||
|
return getStoredEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get security events by type
|
||||||
|
*/
|
||||||
|
export function getSecurityEventsByType(type: SecurityEventType): SecurityEvent[] {
|
||||||
|
return getStoredEvents().filter(event => event.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get security events by severity
|
||||||
|
*/
|
||||||
|
export function getSecurityEventsBySeverity(severity: SecurityEventSeverity): SecurityEvent[] {
|
||||||
|
return getStoredEvents().filter(event => event.severity === severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get security events within a time range
|
||||||
|
*/
|
||||||
|
export function getSecurityEventsByTimeRange(start: Date, end: Date): SecurityEvent[] {
|
||||||
|
const startTime = start.getTime();
|
||||||
|
const endTime = end.getTime();
|
||||||
|
|
||||||
|
return getStoredEvents().filter(event => {
|
||||||
|
const eventTime = new Date(event.timestamp).getTime();
|
||||||
|
return eventTime >= startTime && eventTime <= endTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent critical events
|
||||||
|
*/
|
||||||
|
export function getRecentCriticalEvents(count: number = 10): SecurityEvent[] {
|
||||||
|
return getStoredEvents()
|
||||||
|
.filter(event => event.severity === 'critical' || event.severity === 'error')
|
||||||
|
.slice(-count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get events for a specific session
|
||||||
|
*/
|
||||||
|
export function getSecurityEventsBySession(sessionId: string): SecurityEvent[] {
|
||||||
|
return getStoredEvents().filter(event => event.sessionId === sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Report Generation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a security audit report
|
||||||
|
*/
|
||||||
|
export function generateSecurityAuditReport(): SecurityAuditReport {
|
||||||
|
const events = getStoredEvents();
|
||||||
|
|
||||||
|
const eventsByType = Object.create(null) as Record<SecurityEventType, number>;
|
||||||
|
const eventsBySeverity: Record<SecurityEventSeverity, number> = {
|
||||||
|
info: 0,
|
||||||
|
warning: 0,
|
||||||
|
error: 0,
|
||||||
|
critical: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
eventsByType[event.type] = (eventsByType[event.type] || 0) + 1;
|
||||||
|
eventsBySeverity[event.severity]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentCriticalEvents = getRecentCriticalEvents(10);
|
||||||
|
|
||||||
|
const recommendations: string[] = [];
|
||||||
|
|
||||||
|
// Generate recommendations based on findings
|
||||||
|
if (eventsBySeverity.critical > 0) {
|
||||||
|
recommendations.push('Investigate critical security events immediately');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((eventsByType.auth_failed || 0) > 5) {
|
||||||
|
recommendations.push('Multiple failed authentication attempts detected - consider rate limiting');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((eventsByType.decryption_failed || 0) > 3) {
|
||||||
|
recommendations.push('Multiple decryption failures - check key integrity');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((eventsByType.suspicious_activity || 0) > 0) {
|
||||||
|
recommendations.push('Suspicious activity detected - review access logs');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
recommendations.push('No security events recorded - ensure audit logging is enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
totalEvents: events.length,
|
||||||
|
eventsByType,
|
||||||
|
eventsBySeverity,
|
||||||
|
recentCriticalEvents,
|
||||||
|
recommendations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Maintenance Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all security events
|
||||||
|
*/
|
||||||
|
export function clearSecurityAuditLog(): void {
|
||||||
|
localStorage.removeItem(SECURITY_LOG_KEY);
|
||||||
|
logSecurityEventInternal('config_changed', 'warning', 'Security audit log cleared', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export security events for external analysis
|
||||||
|
*/
|
||||||
|
export function exportSecurityEvents(): string {
|
||||||
|
const events = getStoredEvents();
|
||||||
|
return JSON.stringify({
|
||||||
|
version: AUDIT_VERSION,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
events,
|
||||||
|
}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import security events from external source
|
||||||
|
*/
|
||||||
|
export function importSecurityEvents(jsonData: string, merge: boolean = false): void {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonData);
|
||||||
|
const importedEvents = data.events as SecurityEvent[];
|
||||||
|
|
||||||
|
if (!importedEvents || !Array.isArray(importedEvents)) {
|
||||||
|
throw new Error('Invalid import data format');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merge) {
|
||||||
|
const existingEvents = getStoredEvents();
|
||||||
|
const mergedEvents = [...existingEvents, ...importedEvents];
|
||||||
|
localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(mergedEvents.slice(-MAX_LOG_ENTRIES)));
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(importedEvents.slice(-MAX_LOG_ENTRIES)));
|
||||||
|
}
|
||||||
|
|
||||||
|
logSecurityEventInternal('data_import', 'warning', `Imported ${importedEvents.length} security events`, {
|
||||||
|
merge,
|
||||||
|
sourceVersion: data.version,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logSecurityEventInternal('security_violation', 'error', 'Failed to import security events', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify audit log integrity
|
||||||
|
*/
|
||||||
|
export async function verifyAuditLogIntegrity(): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
eventCount: number;
|
||||||
|
hash: string;
|
||||||
|
}> {
|
||||||
|
const events = getStoredEvents();
|
||||||
|
const data = JSON.stringify(events);
|
||||||
|
const hash = await hashSha256(data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: events.length > 0,
|
||||||
|
eventCount: events.length,
|
||||||
|
hash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Initialization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the security audit module
|
||||||
|
*/
|
||||||
|
export function initializeSecurityAudit(sessionId?: string): void {
|
||||||
|
if (sessionId) {
|
||||||
|
currentSessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSecurityEventInternal('session_started', 'info', 'Security audit session started', {
|
||||||
|
sessionId: currentSessionId,
|
||||||
|
auditEnabled: isAuditEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown the security audit module
|
||||||
|
*/
|
||||||
|
export function shutdownSecurityAudit(): void {
|
||||||
|
logSecurityEventInternal('session_ended', 'info', 'Security audit session ended', {
|
||||||
|
sessionId: currentSessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentSessionId = null;
|
||||||
|
}
|
||||||
241
desktop/src/lib/security-index.ts
Normal file
241
desktop/src/lib/security-index.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Security Module Index
|
||||||
|
*
|
||||||
|
* Central export point for all security-related functionality in ZCLAW.
|
||||||
|
*
|
||||||
|
* Modules:
|
||||||
|
* - crypto-utils: AES-256-GCM encryption, key derivation, hashing
|
||||||
|
* - secure-storage: OS keychain integration with encrypted localStorage fallback
|
||||||
|
* - api-key-storage: Secure API key management
|
||||||
|
* - encrypted-chat-storage: Encrypted chat history persistence
|
||||||
|
* - security-audit: Security event logging and reporting
|
||||||
|
* - security-utils: Input validation, XSS prevention, rate limiting
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export crypto utilities
|
||||||
|
export {
|
||||||
|
// Core encryption
|
||||||
|
encrypt,
|
||||||
|
decrypt,
|
||||||
|
encryptObject,
|
||||||
|
decryptObject,
|
||||||
|
deriveKey,
|
||||||
|
generateMasterKey,
|
||||||
|
generateSalt,
|
||||||
|
|
||||||
|
// Hashing
|
||||||
|
hashSha256,
|
||||||
|
hashSha512,
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
arrayToBase64,
|
||||||
|
base64ToArray,
|
||||||
|
constantTimeEqual,
|
||||||
|
generateRandomString,
|
||||||
|
secureWipe,
|
||||||
|
clearKeyCache,
|
||||||
|
isCryptoAvailable,
|
||||||
|
isValidEncryptedData,
|
||||||
|
} from './crypto-utils';
|
||||||
|
|
||||||
|
export type { EncryptedData } from './crypto-utils';
|
||||||
|
|
||||||
|
// Re-export secure storage
|
||||||
|
export {
|
||||||
|
secureStorage,
|
||||||
|
secureStorageSync,
|
||||||
|
isSecureStorageAvailable,
|
||||||
|
storeDeviceKeys,
|
||||||
|
getDeviceKeys,
|
||||||
|
deleteDeviceKeys,
|
||||||
|
hasDeviceKeys,
|
||||||
|
getDeviceKeysCreatedAt,
|
||||||
|
} from './secure-storage';
|
||||||
|
|
||||||
|
export type { Ed25519KeyPair } from './secure-storage';
|
||||||
|
|
||||||
|
// Re-export API key storage
|
||||||
|
export {
|
||||||
|
// Types
|
||||||
|
type ApiKeyType,
|
||||||
|
type ApiKeyMetadata,
|
||||||
|
|
||||||
|
// Core functions
|
||||||
|
storeApiKey,
|
||||||
|
getApiKey,
|
||||||
|
deleteApiKey,
|
||||||
|
listApiKeyMetadata,
|
||||||
|
updateApiKeyMetadata,
|
||||||
|
hasApiKey,
|
||||||
|
validateStoredApiKey,
|
||||||
|
rotateApiKey,
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
validateApiKeyFormat,
|
||||||
|
exportApiKeyConfig,
|
||||||
|
isUsingKeychain,
|
||||||
|
generateTestApiKey,
|
||||||
|
} from './api-key-storage';
|
||||||
|
|
||||||
|
// Re-export encrypted chat storage
|
||||||
|
export {
|
||||||
|
initializeEncryptedChatStorage,
|
||||||
|
saveConversations,
|
||||||
|
loadConversations,
|
||||||
|
clearAllChatData,
|
||||||
|
exportEncryptedBackup,
|
||||||
|
importEncryptedBackup,
|
||||||
|
isEncryptedStorageActive,
|
||||||
|
getStorageStats,
|
||||||
|
rotateEncryptionKey,
|
||||||
|
} from './encrypted-chat-storage';
|
||||||
|
|
||||||
|
// Re-export security audit
|
||||||
|
export {
|
||||||
|
// Core logging
|
||||||
|
logSecurityEvent,
|
||||||
|
logAuthEvent,
|
||||||
|
logKeyEvent,
|
||||||
|
logDataEvent,
|
||||||
|
logSecurityViolation,
|
||||||
|
logDecryptionFailure,
|
||||||
|
logIntegrityFailure,
|
||||||
|
logPermissionEvent,
|
||||||
|
logSessionEvent,
|
||||||
|
logSuspiciousActivity,
|
||||||
|
logRateLimitEvent,
|
||||||
|
|
||||||
|
// Query functions
|
||||||
|
getSecurityEvents,
|
||||||
|
getSecurityEventsByType,
|
||||||
|
getSecurityEventsBySeverity,
|
||||||
|
getSecurityEventsByTimeRange,
|
||||||
|
getRecentCriticalEvents,
|
||||||
|
getSecurityEventsBySession,
|
||||||
|
|
||||||
|
// Report generation
|
||||||
|
generateSecurityAuditReport,
|
||||||
|
|
||||||
|
// Maintenance
|
||||||
|
clearSecurityAuditLog,
|
||||||
|
exportSecurityEvents,
|
||||||
|
importSecurityEvents,
|
||||||
|
verifyAuditLogIntegrity,
|
||||||
|
|
||||||
|
// Session management
|
||||||
|
getCurrentSessionId,
|
||||||
|
setCurrentSessionId,
|
||||||
|
setAuditEnabled,
|
||||||
|
isAuditEnabledState,
|
||||||
|
initializeSecurityAudit,
|
||||||
|
shutdownSecurityAudit,
|
||||||
|
} from './security-audit';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
SecurityEventType,
|
||||||
|
SecurityEventSeverity,
|
||||||
|
SecurityEvent,
|
||||||
|
SecurityAuditReport,
|
||||||
|
} from './security-audit';
|
||||||
|
|
||||||
|
// Re-export security utilities
|
||||||
|
export {
|
||||||
|
// HTML sanitization
|
||||||
|
escapeHtml,
|
||||||
|
unescapeHtml,
|
||||||
|
sanitizeHtml,
|
||||||
|
|
||||||
|
// URL validation
|
||||||
|
validateUrl,
|
||||||
|
isSafeRedirectUrl,
|
||||||
|
|
||||||
|
// Path validation
|
||||||
|
validatePath,
|
||||||
|
|
||||||
|
// Input validation
|
||||||
|
isValidEmail,
|
||||||
|
isValidUsername,
|
||||||
|
validatePasswordStrength,
|
||||||
|
sanitizeFilename,
|
||||||
|
sanitizeJson,
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
isRateLimited,
|
||||||
|
resetRateLimit,
|
||||||
|
getRemainingAttempts,
|
||||||
|
|
||||||
|
// CSP helpers
|
||||||
|
generateCspNonce,
|
||||||
|
buildCspHeader,
|
||||||
|
DEFAULT_CSP_DIRECTIVES,
|
||||||
|
|
||||||
|
// Security checks
|
||||||
|
checkSecurityHeaders,
|
||||||
|
|
||||||
|
// Random generation
|
||||||
|
generateSecureToken,
|
||||||
|
generateSecureId,
|
||||||
|
} from './security-utils';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Security Initialization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all security modules
|
||||||
|
* Call this during application startup
|
||||||
|
*/
|
||||||
|
export async function initializeSecurity(sessionId?: string): Promise<void> {
|
||||||
|
// Initialize security audit first
|
||||||
|
const { initializeSecurityAudit } = await import('./security-audit');
|
||||||
|
initializeSecurityAudit(sessionId);
|
||||||
|
|
||||||
|
// Initialize encrypted chat storage
|
||||||
|
const { initializeEncryptedChatStorage } = await import('./encrypted-chat-storage');
|
||||||
|
await initializeEncryptedChatStorage();
|
||||||
|
|
||||||
|
console.log('[Security] All security modules initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown all security modules
|
||||||
|
* Call this during application shutdown
|
||||||
|
*/
|
||||||
|
export async function shutdownSecurity(): Promise<void> {
|
||||||
|
const { shutdownSecurityAudit } = await import('./security-audit');
|
||||||
|
shutdownSecurityAudit();
|
||||||
|
|
||||||
|
const { clearKeyCache } = await import('./crypto-utils');
|
||||||
|
clearKeyCache();
|
||||||
|
|
||||||
|
console.log('[Security] All security modules shut down');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a comprehensive security status report
|
||||||
|
*/
|
||||||
|
export async function getSecurityStatus(): Promise<{
|
||||||
|
auditEnabled: boolean;
|
||||||
|
keychainAvailable: boolean;
|
||||||
|
chatStorageInitialized: boolean;
|
||||||
|
storedApiKeys: number;
|
||||||
|
recentEvents: number;
|
||||||
|
criticalEvents: number;
|
||||||
|
}> {
|
||||||
|
const { isAuditEnabledState, getSecurityEventsBySeverity } = await import('./security-audit');
|
||||||
|
const { isSecureStorageAvailable } = await import('./secure-storage');
|
||||||
|
const { isEncryptedStorageActive: isChatStorageInitialized } = await import('./encrypted-chat-storage');
|
||||||
|
const { listApiKeyMetadata } = await import('./api-key-storage');
|
||||||
|
|
||||||
|
const criticalEvents = getSecurityEventsBySeverity('critical').length;
|
||||||
|
const errorEvents = getSecurityEventsBySeverity('error').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
auditEnabled: isAuditEnabledState(),
|
||||||
|
keychainAvailable: await isSecureStorageAvailable(),
|
||||||
|
chatStorageInitialized: await isChatStorageInitialized(),
|
||||||
|
storedApiKeys: (await listApiKeyMetadata()).length,
|
||||||
|
recentEvents: criticalEvents + errorEvents,
|
||||||
|
criticalEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
729
desktop/src/lib/security-utils.ts
Normal file
729
desktop/src/lib/security-utils.ts
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
/**
|
||||||
|
* Security Utilities for Input Validation and XSS Prevention
|
||||||
|
*
|
||||||
|
* Provides comprehensive input validation, sanitization, and XSS prevention
|
||||||
|
* for the ZCLAW application.
|
||||||
|
*
|
||||||
|
* Security features:
|
||||||
|
* - HTML sanitization
|
||||||
|
* - URL validation
|
||||||
|
* - Path traversal prevention
|
||||||
|
* - Input validation helpers
|
||||||
|
* - Content Security Policy helpers
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HTML Sanitization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML entity encoding map
|
||||||
|
*/
|
||||||
|
const HTML_ENTITIES: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
'/': '/',
|
||||||
|
'`': '`',
|
||||||
|
'=': '=',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML entities in a string
|
||||||
|
* Prevents XSS attacks by encoding dangerous characters
|
||||||
|
*
|
||||||
|
* @param input - The string to escape
|
||||||
|
* @returns The escaped string
|
||||||
|
*/
|
||||||
|
export function escapeHtml(input: string): string {
|
||||||
|
if (typeof input !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return input.replace(/[&<>"'`=\/]/g, char => HTML_ENTITIES[char] || char);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unescape HTML entities in a string
|
||||||
|
*
|
||||||
|
* @param input - The string to unescape
|
||||||
|
* @returns The unescaped string
|
||||||
|
*/
|
||||||
|
export function unescapeHtml(input: string): string {
|
||||||
|
if (typeof input !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.innerHTML = input;
|
||||||
|
return textarea.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed HTML tags for safe rendering
|
||||||
|
*/
|
||||||
|
const ALLOWED_TAGS = new Set([
|
||||||
|
'p', 'br', 'b', 'i', 'u', 'strong', 'em',
|
||||||
|
'ul', 'ol', 'li', 'blockquote', 'code', 'pre',
|
||||||
|
'a', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed HTML attributes
|
||||||
|
*/
|
||||||
|
const ALLOWED_ATTRIBUTES = new Set([
|
||||||
|
'href', 'title', 'class', 'id', 'target', 'rel',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize HTML content for safe rendering
|
||||||
|
* Removes dangerous tags and attributes while preserving safe content
|
||||||
|
*
|
||||||
|
* @param html - The HTML string to sanitize
|
||||||
|
* @param options - Sanitization options
|
||||||
|
* @returns The sanitized HTML
|
||||||
|
*/
|
||||||
|
export function sanitizeHtml(
|
||||||
|
html: string,
|
||||||
|
options: {
|
||||||
|
allowedTags?: string[];
|
||||||
|
allowedAttributes?: string[];
|
||||||
|
allowDataAttributes?: boolean;
|
||||||
|
} = {}
|
||||||
|
): string {
|
||||||
|
if (typeof html !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedTags = new Set(options.allowedTags || ALLOWED_TAGS);
|
||||||
|
const allowedAttributes = new Set(options.allowedAttributes || ALLOWED_ATTRIBUTES);
|
||||||
|
|
||||||
|
// Create a temporary container
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Recursively clean elements
|
||||||
|
function cleanElement(element: Element): void {
|
||||||
|
// Remove script tags entirely
|
||||||
|
if (element.tagName.toLowerCase() === 'script') {
|
||||||
|
element.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove style tags entirely
|
||||||
|
if (element.tagName.toLowerCase() === 'style') {
|
||||||
|
element.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove event handlers and dangerous attributes
|
||||||
|
const attributes = Array.from(element.attributes);
|
||||||
|
for (const attr of attributes) {
|
||||||
|
const attrName = attr.name.toLowerCase();
|
||||||
|
|
||||||
|
// Remove event handlers (onclick, onload, etc.)
|
||||||
|
if (attrName.startsWith('on')) {
|
||||||
|
element.removeAttribute(attr.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove javascript: URLs
|
||||||
|
if (attrName === 'href' || attrName === 'src') {
|
||||||
|
const value = attr.value.toLowerCase().trim();
|
||||||
|
if (value.startsWith('javascript:') || value.startsWith('data:text/html')) {
|
||||||
|
element.removeAttribute(attr.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove data attributes if not allowed
|
||||||
|
if (attrName.startsWith('data-') && !options.allowDataAttributes) {
|
||||||
|
element.removeAttribute(attr.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove non-allowed attributes
|
||||||
|
if (!allowedAttributes.has(attrName)) {
|
||||||
|
element.removeAttribute(attr.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove non-allowed tags (but keep their content)
|
||||||
|
if (!allowedTags.has(element.tagName.toLowerCase())) {
|
||||||
|
const parent = element.parentNode;
|
||||||
|
while (element.firstChild) {
|
||||||
|
parent?.insertBefore(element.firstChild, element);
|
||||||
|
}
|
||||||
|
parent?.removeChild(element);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively clean child elements
|
||||||
|
Array.from(element.children).forEach(cleanElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean all elements
|
||||||
|
Array.from(container.children).forEach(cleanElement);
|
||||||
|
|
||||||
|
return container.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// URL Validation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed URL schemes
|
||||||
|
*/
|
||||||
|
const ALLOWED_SCHEMES = new Set([
|
||||||
|
'http', 'https', 'mailto', 'tel', 'ftp', 'file',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and sanitize a URL
|
||||||
|
*
|
||||||
|
* @param url - The URL to validate
|
||||||
|
* @param options - Validation options
|
||||||
|
* @returns The validated URL or null if invalid
|
||||||
|
*/
|
||||||
|
export function validateUrl(
|
||||||
|
url: string,
|
||||||
|
options: {
|
||||||
|
allowedSchemes?: string[];
|
||||||
|
allowLocalhost?: boolean;
|
||||||
|
allowPrivateIp?: boolean;
|
||||||
|
maxLength?: number;
|
||||||
|
} = {}
|
||||||
|
): string | null {
|
||||||
|
if (typeof url !== 'string' || url.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLength = options.maxLength || 2048;
|
||||||
|
if (url.length > maxLength) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
|
||||||
|
// Check scheme
|
||||||
|
const allowedSchemes = new Set(options.allowedSchemes || ALLOWED_SCHEMES);
|
||||||
|
if (!allowedSchemes.has(parsed.protocol.replace(':', ''))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for localhost
|
||||||
|
if (!options.allowLocalhost) {
|
||||||
|
if (parsed.hostname === 'localhost' ||
|
||||||
|
parsed.hostname === '127.0.0.1' ||
|
||||||
|
parsed.hostname === '[::1]') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for private IP ranges
|
||||||
|
if (!options.allowPrivateIp) {
|
||||||
|
const privateIpRegex = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
|
||||||
|
if (privateIpRegex.test(parsed.hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is safe for redirect
|
||||||
|
* Prevents open redirect vulnerabilities
|
||||||
|
*
|
||||||
|
* @param url - The URL to check
|
||||||
|
* @returns True if the URL is safe for redirect
|
||||||
|
*/
|
||||||
|
export function isSafeRedirectUrl(url: string): boolean {
|
||||||
|
if (typeof url !== 'string' || url.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative URLs are generally safe
|
||||||
|
if (url.startsWith('/') && !url.startsWith('//')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for javascript: protocol
|
||||||
|
const lowerUrl = url.toLowerCase().trim();
|
||||||
|
if (lowerUrl.startsWith('javascript:')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for data: protocol
|
||||||
|
if (lowerUrl.startsWith('data:')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate as absolute URL
|
||||||
|
const validated = validateUrl(url, { allowLocalhost: false });
|
||||||
|
return validated !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Path Validation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a file path to prevent path traversal attacks
|
||||||
|
*
|
||||||
|
* @param path - The path to validate
|
||||||
|
* @param options - Validation options
|
||||||
|
* @returns The validated path or null if invalid
|
||||||
|
*/
|
||||||
|
export function validatePath(
|
||||||
|
path: string,
|
||||||
|
options: {
|
||||||
|
allowAbsolute?: boolean;
|
||||||
|
allowParentDir?: boolean;
|
||||||
|
maxLength?: number;
|
||||||
|
allowedExtensions?: string[];
|
||||||
|
baseDir?: string;
|
||||||
|
} = {}
|
||||||
|
): string | null {
|
||||||
|
if (typeof path !== 'string' || path.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLength = options.maxLength || 4096;
|
||||||
|
if (path.length > maxLength) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize path separators
|
||||||
|
let normalized = path.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
// Check for null bytes
|
||||||
|
if (normalized.includes('\0')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for path traversal
|
||||||
|
if (!options.allowParentDir) {
|
||||||
|
if (normalized.includes('..') || normalized.includes('./')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for absolute paths
|
||||||
|
if (!options.allowAbsolute) {
|
||||||
|
if (normalized.startsWith('/') || /^[a-zA-Z]:/.test(normalized)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check extensions
|
||||||
|
if (options.allowedExtensions && options.allowedExtensions.length > 0) {
|
||||||
|
const ext = normalized.split('.').pop()?.toLowerCase();
|
||||||
|
if (!ext || !options.allowedExtensions.includes(ext)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If baseDir is specified, ensure path is within it
|
||||||
|
if (options.baseDir) {
|
||||||
|
const baseDir = options.baseDir.replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
|
if (!normalized.startsWith(baseDir)) {
|
||||||
|
// Try to resolve relative to baseDir
|
||||||
|
try {
|
||||||
|
const resolved = new URL(normalized, `file://${baseDir}/`).pathname;
|
||||||
|
if (!resolved.startsWith(baseDir)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
normalized = resolved;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Input Validation Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an email address
|
||||||
|
*
|
||||||
|
* @param email - The email to validate
|
||||||
|
* @returns True if valid
|
||||||
|
*/
|
||||||
|
export function isValidEmail(email: string): boolean {
|
||||||
|
if (typeof email !== 'string' || email.length === 0 || email.length > 254) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 5322 compliant regex (simplified)
|
||||||
|
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a username
|
||||||
|
*
|
||||||
|
* @param username - The username to validate
|
||||||
|
* @param options - Validation options
|
||||||
|
* @returns True if valid
|
||||||
|
*/
|
||||||
|
export function isValidUsername(
|
||||||
|
username: string,
|
||||||
|
options: {
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
allowedChars?: RegExp;
|
||||||
|
} = {}
|
||||||
|
): boolean {
|
||||||
|
const minLength = options.minLength || 3;
|
||||||
|
const maxLength = options.maxLength || 30;
|
||||||
|
const allowedChars = options.allowedChars || /^[a-zA-Z0-9_-]+$/;
|
||||||
|
|
||||||
|
if (typeof username !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length < minLength || username.length > maxLength) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allowedChars.test(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a password strength
|
||||||
|
*
|
||||||
|
* @param password - The password to validate
|
||||||
|
* @param options - Validation options
|
||||||
|
* @returns Validation result with strength score
|
||||||
|
*/
|
||||||
|
export function validatePasswordStrength(
|
||||||
|
password: string,
|
||||||
|
options: {
|
||||||
|
minLength?: number;
|
||||||
|
requireUppercase?: boolean;
|
||||||
|
requireLowercase?: boolean;
|
||||||
|
requireNumber?: boolean;
|
||||||
|
requireSpecial?: boolean;
|
||||||
|
maxLength?: number;
|
||||||
|
} = {}
|
||||||
|
): {
|
||||||
|
valid: boolean;
|
||||||
|
score: number;
|
||||||
|
issues: string[];
|
||||||
|
} {
|
||||||
|
const minLength = options.minLength || 8;
|
||||||
|
const maxLength = options.maxLength || 128;
|
||||||
|
const issues: string[] = [];
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (typeof password !== 'string') {
|
||||||
|
return { valid: false, score: 0, issues: ['Password must be a string'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < minLength) {
|
||||||
|
issues.push(`Password must be at least ${minLength} characters`);
|
||||||
|
} else {
|
||||||
|
score += Math.min(password.length / 8, 3) * 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length > maxLength) {
|
||||||
|
issues.push(`Password must be at most ${maxLength} characters`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.requireUppercase !== false && !/[A-Z]/.test(password)) {
|
||||||
|
issues.push('Password must contain an uppercase letter');
|
||||||
|
} else if (/[A-Z]/.test(password)) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.requireLowercase !== false && !/[a-z]/.test(password)) {
|
||||||
|
issues.push('Password must contain a lowercase letter');
|
||||||
|
} else if (/[a-z]/.test(password)) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.requireNumber !== false && !/[0-9]/.test(password)) {
|
||||||
|
issues.push('Password must contain a number');
|
||||||
|
} else if (/[0-9]/.test(password)) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.requireSpecial !== false && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
|
||||||
|
issues.push('Password must contain a special character');
|
||||||
|
} else if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
|
||||||
|
score += 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for common patterns
|
||||||
|
const commonPatterns = [
|
||||||
|
/123/,
|
||||||
|
/abc/i,
|
||||||
|
/qwe/i,
|
||||||
|
/password/i,
|
||||||
|
/admin/i,
|
||||||
|
/letmein/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of commonPatterns) {
|
||||||
|
if (pattern.test(password)) {
|
||||||
|
issues.push('Password contains a common pattern');
|
||||||
|
score -= 10;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: issues.length === 0,
|
||||||
|
score: Math.max(0, Math.min(100, score)),
|
||||||
|
issues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a filename
|
||||||
|
*
|
||||||
|
* @param filename - The filename to sanitize
|
||||||
|
* @returns The sanitized filename
|
||||||
|
*/
|
||||||
|
export function sanitizeFilename(filename: string): string {
|
||||||
|
if (typeof filename !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove path separators
|
||||||
|
let sanitized = filename.replace(/[\/\\]/g, '_');
|
||||||
|
|
||||||
|
// Remove null bytes
|
||||||
|
sanitized = sanitized.replace(/\0/g, '');
|
||||||
|
|
||||||
|
// Remove control characters
|
||||||
|
sanitized = sanitized.replace(/[\x00-\x1f\x7f]/g, '');
|
||||||
|
|
||||||
|
// Remove dangerous characters
|
||||||
|
sanitized = sanitized.replace(/[<>:"|?*]/g, '_');
|
||||||
|
|
||||||
|
// Trim whitespace and dots
|
||||||
|
sanitized = sanitized.trim().replace(/^\.+|\.+$/g, '');
|
||||||
|
|
||||||
|
// Limit length
|
||||||
|
if (sanitized.length > 255) {
|
||||||
|
const ext = sanitized.split('.').pop();
|
||||||
|
const name = sanitized.slice(0, -(ext?.length || 0) - 1);
|
||||||
|
sanitized = name.slice(0, 250 - (ext?.length || 0)) + (ext ? `.${ext}` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize JSON input
|
||||||
|
* Prevents prototype pollution and other JSON-based attacks
|
||||||
|
*
|
||||||
|
* @param json - The JSON string to sanitize
|
||||||
|
* @returns The parsed and sanitized object or null if invalid
|
||||||
|
*/
|
||||||
|
export function sanitizeJson<T = unknown>(json: string): T | null {
|
||||||
|
if (typeof json !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
|
||||||
|
// Check for prototype pollution
|
||||||
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
|
const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
|
||||||
|
for (const key of dangerousKeys) {
|
||||||
|
if (key in parsed) {
|
||||||
|
delete (parsed as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Rate Limiting
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an action is rate limited
|
||||||
|
*
|
||||||
|
* @param key - The rate limit key (e.g., 'api:username')
|
||||||
|
* @param maxAttempts - Maximum attempts allowed
|
||||||
|
* @param windowMs - Time window in milliseconds
|
||||||
|
* @returns True if rate limited (should block), false otherwise
|
||||||
|
*/
|
||||||
|
export function isRateLimited(
|
||||||
|
key: string,
|
||||||
|
maxAttempts: number,
|
||||||
|
windowMs: number
|
||||||
|
): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = rateLimitStore.get(key);
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetAt) {
|
||||||
|
rateLimitStore.set(key, {
|
||||||
|
count: 1,
|
||||||
|
resetAt: now + windowMs,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= maxAttempts) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset rate limit for a key
|
||||||
|
*
|
||||||
|
* @param key - The rate limit key to reset
|
||||||
|
*/
|
||||||
|
export function resetRateLimit(key: string): void {
|
||||||
|
rateLimitStore.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining attempts for a rate-limited action
|
||||||
|
*
|
||||||
|
* @param key - The rate limit key
|
||||||
|
* @param maxAttempts - Maximum attempts allowed
|
||||||
|
* @returns Number of remaining attempts
|
||||||
|
*/
|
||||||
|
export function getRemainingAttempts(key: string, maxAttempts: number): number {
|
||||||
|
const entry = rateLimitStore.get(key);
|
||||||
|
if (!entry || Date.now() > entry.resetAt) {
|
||||||
|
return maxAttempts;
|
||||||
|
}
|
||||||
|
return Math.max(0, maxAttempts - entry.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Content Security Policy Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a nonce for CSP
|
||||||
|
*
|
||||||
|
* @returns A base64-encoded nonce
|
||||||
|
*/
|
||||||
|
export function generateCspNonce(): string {
|
||||||
|
const array = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
return btoa(String.fromCharCode(...array));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSP directives for secure applications
|
||||||
|
*/
|
||||||
|
export const DEFAULT_CSP_DIRECTIVES = {
|
||||||
|
'default-src': "'self'",
|
||||||
|
'script-src': "'self' 'unsafe-inline'", // Note: unsafe-inline should be avoided in production
|
||||||
|
'style-src': "'self' 'unsafe-inline'",
|
||||||
|
'img-src': "'self' data: https:",
|
||||||
|
'font-src': "'self'",
|
||||||
|
'connect-src': "'self' ws: wss:",
|
||||||
|
'frame-ancestors': "'none'",
|
||||||
|
'base-uri': "'self'",
|
||||||
|
'form-action': "'self'",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Content Security Policy header value
|
||||||
|
*
|
||||||
|
* @param directives - CSP directives
|
||||||
|
* @returns The CSP header value
|
||||||
|
*/
|
||||||
|
export function buildCspHeader(
|
||||||
|
directives: Partial<typeof DEFAULT_CSP_DIRECTIVES> = DEFAULT_CSP_DIRECTIVES
|
||||||
|
): string {
|
||||||
|
const merged = { ...DEFAULT_CSP_DIRECTIVES, ...directives };
|
||||||
|
return Object.entries(merged)
|
||||||
|
.map(([key, value]) => `${key} ${value}`)
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Security Headers Validation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if security headers are properly set (for browser environments)
|
||||||
|
*/
|
||||||
|
export function checkSecurityHeaders(): {
|
||||||
|
secure: boolean;
|
||||||
|
issues: string[];
|
||||||
|
} {
|
||||||
|
const issues: string[] = [];
|
||||||
|
|
||||||
|
// Check if running over HTTPS
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') {
|
||||||
|
issues.push('Application is not running over HTTPS');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mixed content
|
||||||
|
if (window.location.protocol === 'https:') {
|
||||||
|
// This would require DOM inspection to detect mixed content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
secure: issues.length === 0,
|
||||||
|
issues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Secure Random Generation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a secure random token
|
||||||
|
*
|
||||||
|
* @param length - Token length in bytes
|
||||||
|
* @returns Hex-encoded random token
|
||||||
|
*/
|
||||||
|
export function generateSecureToken(length: number = 32): string {
|
||||||
|
const array = crypto.getRandomValues(new Uint8Array(length));
|
||||||
|
return Array.from(array)
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a secure random ID
|
||||||
|
*
|
||||||
|
* @param prefix - Optional prefix
|
||||||
|
* @returns A secure random ID
|
||||||
|
*/
|
||||||
|
export function generateSecureId(prefix: string = ''): string {
|
||||||
|
const timestamp = Date.now().toString(36);
|
||||||
|
const random = generateSecureToken(8);
|
||||||
|
return prefix ? `${prefix}_${timestamp}_${random}` : `${timestamp}_${random}`;
|
||||||
|
}
|
||||||
@@ -3,11 +3,38 @@ import ReactDOM from 'react-dom/client';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import { ToastProvider } from './components/ui/Toast';
|
import { ToastProvider } from './components/ui/Toast';
|
||||||
|
import { GlobalErrorBoundary } from './components/ui/ErrorBoundary';
|
||||||
|
|
||||||
|
// Global error handler for uncaught errors
|
||||||
|
const handleGlobalError = (error: Error, errorInfo: React.ErrorInfo) => {
|
||||||
|
console.error('[GlobalErrorHandler] Uncaught error:', error);
|
||||||
|
console.error('[GlobalErrorHandler] Component stack:', errorInfo.componentStack);
|
||||||
|
|
||||||
|
// In production, you could send this to an error reporting service
|
||||||
|
// e.g., Sentry, LogRocket, etc.
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
// sendToErrorReportingService(error, errorInfo);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global reset handler - reload the page
|
||||||
|
const handleGlobalReset = () => {
|
||||||
|
console.log('[GlobalErrorHandler] Resetting application...');
|
||||||
|
// Clear any cached state
|
||||||
|
localStorage.removeItem('app-state');
|
||||||
|
sessionStorage.clear();
|
||||||
|
};
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<GlobalErrorBoundary
|
||||||
|
onError={handleGlobalError}
|
||||||
|
onReset={handleGlobalReset}
|
||||||
|
showConnectionStatus={true}
|
||||||
|
>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<App />
|
<App />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
</GlobalErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { getGatewayClient, AgentStreamDelta } from '../lib/gateway-client';
|
import { getGatewayClient, AgentStreamDelta } from '../lib/gateway-client';
|
||||||
import { intelligenceClient } from '../lib/intelligence-client';
|
import { intelligenceClient } from '../lib/intelligence-client';
|
||||||
import { getMemoryExtractor } from '../lib/memory-extractor';
|
import { getMemoryExtractor } from '../lib/memory-extractor';
|
||||||
import { getAgentSwarm } from '../lib/agent-swarm';
|
import { getAgentSwarm } from '../lib/agent-swarm';
|
||||||
import { getSkillDiscovery } from '../lib/skill-discovery';
|
import { getSkillDiscovery } from '../lib/skill-discovery';
|
||||||
|
import { useOfflineStore, isOffline } from './offlineStore';
|
||||||
|
import { useConnectionStore } from './connectionStore';
|
||||||
|
|
||||||
export interface MessageFile {
|
export interface MessageFile {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -21,7 +23,7 @@ export interface CodeBlock {
|
|||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow';
|
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow' | 'system';
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
@@ -77,11 +79,13 @@ interface ChatState {
|
|||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
currentAgent: Agent | null;
|
currentAgent: Agent | null;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
currentModel: string;
|
currentModel: string;
|
||||||
sessionKey: string | null;
|
sessionKey: string | null;
|
||||||
|
|
||||||
addMessage: (message: Message) => void;
|
addMessage: (message: Message) => void;
|
||||||
updateMessage: (id: string, updates: Partial<Message>) => void;
|
updateMessage: (id: string, updates: Partial<Message>) => void;
|
||||||
|
setIsLoading: (loading: boolean) => void;
|
||||||
setCurrentAgent: (agent: Agent) => void;
|
setCurrentAgent: (agent: Agent) => void;
|
||||||
syncAgents: (profiles: AgentProfileLike[]) => void;
|
syncAgents: (profiles: AgentProfileLike[]) => void;
|
||||||
setCurrentModel: (model: string) => void;
|
setCurrentModel: (model: string) => void;
|
||||||
@@ -185,6 +189,7 @@ export const useChatStore = create<ChatState>()(
|
|||||||
agents: [DEFAULT_AGENT],
|
agents: [DEFAULT_AGENT],
|
||||||
currentAgent: DEFAULT_AGENT,
|
currentAgent: DEFAULT_AGENT,
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
|
isLoading: false,
|
||||||
currentModel: 'glm-5',
|
currentModel: 'glm-5',
|
||||||
sessionKey: null,
|
sessionKey: null,
|
||||||
|
|
||||||
@@ -198,6 +203,8 @@ export const useChatStore = create<ChatState>()(
|
|||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
setIsLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
|
||||||
setCurrentAgent: (agent) =>
|
setCurrentAgent: (agent) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.currentAgent?.id === agent.id) {
|
if (state.currentAgent?.id === agent.id) {
|
||||||
@@ -295,6 +302,32 @@ export const useChatStore = create<ChatState>()(
|
|||||||
const effectiveAgentId = resolveGatewayAgentId(currentAgent);
|
const effectiveAgentId = resolveGatewayAgentId(currentAgent);
|
||||||
const agentId = currentAgent?.id || 'zclaw-main';
|
const agentId = currentAgent?.id || 'zclaw-main';
|
||||||
|
|
||||||
|
// Check if offline - queue message instead of sending
|
||||||
|
if (isOffline()) {
|
||||||
|
const { queueMessage } = useOfflineStore.getState();
|
||||||
|
const queueId = queueMessage(content, effectiveAgentId, effectiveSessionKey);
|
||||||
|
console.log(`[Chat] Offline - message queued: ${queueId}`);
|
||||||
|
|
||||||
|
// Show a system message about offline queueing
|
||||||
|
const systemMsg: Message = {
|
||||||
|
id: `system_${Date.now()}`,
|
||||||
|
role: 'system',
|
||||||
|
content: `后端服务不可用,消息已保存到本地队列。恢复连接后将自动发送。`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
addMessage(systemMsg);
|
||||||
|
|
||||||
|
// Add user message for display
|
||||||
|
const userMsg: Message = {
|
||||||
|
id: `user_${Date.now()}`,
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
addMessage(userMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check context compaction threshold before adding new message
|
// Check context compaction threshold before adding new message
|
||||||
try {
|
try {
|
||||||
const messages = get().messages.map(m => ({ role: m.role, content: m.content }));
|
const messages = get().messages.map(m => ({ role: m.role, content: m.content }));
|
||||||
@@ -368,9 +401,15 @@ export const useChatStore = create<ChatState>()(
|
|||||||
try {
|
try {
|
||||||
const client = getGatewayClient();
|
const client = getGatewayClient();
|
||||||
|
|
||||||
|
// Check connection state first
|
||||||
|
const connectionState = useConnectionStore.getState().connectionState;
|
||||||
|
|
||||||
|
if (connectionState !== 'connected') {
|
||||||
|
// Connection lost during send - update error
|
||||||
|
throw new Error(`Not connected (state: ${connectionState})`);
|
||||||
|
}
|
||||||
|
|
||||||
// Try streaming first (OpenFang WebSocket)
|
// Try streaming first (OpenFang WebSocket)
|
||||||
// Note: onDelta is empty - stream updates handled by initStreamListener to avoid duplication
|
|
||||||
if (client.getState() === 'connected') {
|
|
||||||
const { runId } = await client.chatStream(
|
const { runId } = await client.chatStream(
|
||||||
enhancedContent,
|
enhancedContent,
|
||||||
{
|
{
|
||||||
@@ -423,18 +462,18 @@ export const useChatStore = create<ChatState>()(
|
|||||||
const msgs = get().messages
|
const msgs = get().messages
|
||||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||||
.map(m => ({ role: m.role, content: m.content }));
|
.map(m => ({ role: m.role, content: m.content }));
|
||||||
getMemoryExtractor().extractFromConversation(msgs, agentId, get().currentConversationId ?? undefined).catch(err =>
|
getMemoryExtractor().extractFromConversation(msgs, agentId, get().currentConversationId ?? undefined).catch(err => {
|
||||||
console.warn('[Chat] Memory extraction failed:', err)
|
console.warn('[Chat] Memory extraction failed:', err);
|
||||||
);
|
});
|
||||||
// Track conversation for reflection trigger
|
// Track conversation for reflection trigger
|
||||||
intelligenceClient.reflection.recordConversation().catch(err =>
|
intelligenceClient.reflection.recordConversation().catch(err => {
|
||||||
console.warn('[Chat] Recording conversation failed:', err)
|
console.warn('[Chat] Recording conversation failed:', err);
|
||||||
);
|
});
|
||||||
intelligenceClient.reflection.shouldReflect().then(shouldReflect => {
|
intelligenceClient.reflection.shouldReflect().then(shouldReflect => {
|
||||||
if (shouldReflect) {
|
if (shouldReflect) {
|
||||||
intelligenceClient.reflection.reflect(agentId, []).catch(err =>
|
intelligenceClient.reflection.reflect(agentId, []).catch(err => {
|
||||||
console.warn('[Chat] Reflection failed:', err)
|
console.warn('[Chat] Reflection failed:', err);
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -465,39 +504,6 @@ export const useChatStore = create<ChatState>()(
|
|||||||
m.id === assistantId ? { ...m, runId } : m
|
m.id === assistantId ? { ...m, runId } : m
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to REST API (non-streaming)
|
|
||||||
const result = await client.chat(enhancedContent, {
|
|
||||||
sessionKey: effectiveSessionKey,
|
|
||||||
agentId: effectiveAgentId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!sessionKey) {
|
|
||||||
set({ sessionKey: effectiveSessionKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenFang returns response directly (no WebSocket streaming)
|
|
||||||
if (result.response) {
|
|
||||||
set((state) => ({
|
|
||||||
isStreaming: false,
|
|
||||||
messages: state.messages.map((m) =>
|
|
||||||
m.id === assistantId
|
|
||||||
? { ...m, content: result.response || '', streaming: false }
|
|
||||||
: m
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The actual streaming content comes via the 'agent' event listener
|
|
||||||
// set in initStreamListener(). The runId links events to this message.
|
|
||||||
set((state) => ({
|
|
||||||
messages: state.messages.map((m) =>
|
|
||||||
m.id === assistantId ? { ...m, runId: result.runId } : m
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Gateway not connected — show error in the assistant bubble
|
// Gateway not connected — show error in the assistant bubble
|
||||||
const errorMessage = err instanceof Error ? err.message : '无法连接 Gateway';
|
const errorMessage = err instanceof Error ? err.message : '无法连接 Gateway';
|
||||||
@@ -686,3 +692,9 @@ export const useChatStore = create<ChatState>()(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Dev-only: Expose chatStore to window for E2E testing
|
||||||
|
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||||
|
(window as any).__ZCLAW_STORES__ = (window as any).__ZCLAW_STORES__ || {};
|
||||||
|
(window as any).__ZCLAW_STORES__.chat = useChatStore;
|
||||||
|
}
|
||||||
|
|||||||
@@ -347,5 +347,11 @@ if (import.meta.env.DEV && typeof window !== 'undefined') {
|
|||||||
(window as any).__ZCLAW_STORES__.config = useConfigStore;
|
(window as any).__ZCLAW_STORES__.config = useConfigStore;
|
||||||
(window as any).__ZCLAW_STORES__.security = useSecurityStore;
|
(window as any).__ZCLAW_STORES__.security = useSecurityStore;
|
||||||
(window as any).__ZCLAW_STORES__.session = useSessionStore;
|
(window as any).__ZCLAW_STORES__.session = useSessionStore;
|
||||||
|
// Dynamically import chatStore to avoid circular dependency
|
||||||
|
import('./chatStore').then(({ useChatStore }) => {
|
||||||
|
(window as any).__ZCLAW_STORES__.chat = useChatStore;
|
||||||
|
}).catch(() => {
|
||||||
|
// Ignore if chatStore is not available
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
358
desktop/src/store/offlineStore.ts
Normal file
358
desktop/src/store/offlineStore.ts
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
/**
|
||||||
|
* Offline Store
|
||||||
|
*
|
||||||
|
* Manages offline state, message queue, and reconnection logic.
|
||||||
|
* Provides graceful degradation when backend is unavailable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { useConnectionStore, getConnectionState } from './connectionStore';
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
export interface QueuedMessage {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
agentId?: string;
|
||||||
|
sessionKey?: string;
|
||||||
|
timestamp: number;
|
||||||
|
retryCount: number;
|
||||||
|
lastError?: string;
|
||||||
|
status: 'pending' | 'sending' | 'failed' | 'sent';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfflineState {
|
||||||
|
isOffline: boolean;
|
||||||
|
isReconnecting: boolean;
|
||||||
|
reconnectAttempt: number;
|
||||||
|
nextReconnectDelay: number;
|
||||||
|
lastOnlineTime: number | null;
|
||||||
|
queuedMessages: QueuedMessage[];
|
||||||
|
maxRetryCount: number;
|
||||||
|
maxQueueSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfflineActions {
|
||||||
|
// State management
|
||||||
|
setOffline: (offline: boolean) => void;
|
||||||
|
setReconnecting: (reconnecting: boolean, attempt?: number) => void;
|
||||||
|
|
||||||
|
// Message queue operations
|
||||||
|
queueMessage: (content: string, agentId?: string, sessionKey?: string) => string;
|
||||||
|
updateMessageStatus: (id: string, status: QueuedMessage['status'], error?: string) => void;
|
||||||
|
removeMessage: (id: string) => void;
|
||||||
|
clearQueue: () => void;
|
||||||
|
retryAllMessages: () => Promise<void>;
|
||||||
|
|
||||||
|
// Reconnection
|
||||||
|
scheduleReconnect: () => void;
|
||||||
|
cancelReconnect: () => void;
|
||||||
|
attemptReconnect: () => Promise<boolean>;
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
getPendingMessages: () => QueuedMessage[];
|
||||||
|
hasPendingMessages: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OfflineStore = OfflineState & OfflineActions;
|
||||||
|
|
||||||
|
// === Constants ===
|
||||||
|
|
||||||
|
const INITIAL_RECONNECT_DELAY = 1000; // 1 second
|
||||||
|
const MAX_RECONNECT_DELAY = 60000; // 60 seconds
|
||||||
|
const RECONNECT_BACKOFF_FACTOR = 1.5;
|
||||||
|
const MAX_RETRY_COUNT = 5;
|
||||||
|
const MAX_QUEUE_SIZE = 100;
|
||||||
|
|
||||||
|
// === Helper Functions ===
|
||||||
|
|
||||||
|
function calculateNextDelay(currentDelay: number): number {
|
||||||
|
return Math.min(
|
||||||
|
currentDelay * RECONNECT_BACKOFF_FACTOR,
|
||||||
|
MAX_RECONNECT_DELAY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMessageId(): string {
|
||||||
|
return `queued_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Store Implementation ===
|
||||||
|
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let healthCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
export const useOfflineStore = create<OfflineStore>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// === Initial State ===
|
||||||
|
isOffline: false,
|
||||||
|
isReconnecting: false,
|
||||||
|
reconnectAttempt: 0,
|
||||||
|
nextReconnectDelay: INITIAL_RECONNECT_DELAY,
|
||||||
|
lastOnlineTime: null,
|
||||||
|
queuedMessages: [],
|
||||||
|
maxRetryCount: MAX_RETRY_COUNT,
|
||||||
|
maxQueueSize: MAX_QUEUE_SIZE,
|
||||||
|
|
||||||
|
// === State Management ===
|
||||||
|
|
||||||
|
setOffline: (offline: boolean) => {
|
||||||
|
const wasOffline = get().isOffline;
|
||||||
|
set({
|
||||||
|
isOffline: offline,
|
||||||
|
lastOnlineTime: offline ? get().lastOnlineTime : Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start reconnect process when going offline
|
||||||
|
if (offline && !wasOffline) {
|
||||||
|
get().scheduleReconnect();
|
||||||
|
} else if (!offline && wasOffline) {
|
||||||
|
// Back online - try to send queued messages
|
||||||
|
get().cancelReconnect();
|
||||||
|
set({ reconnectAttempt: 0, nextReconnectDelay: INITIAL_RECONNECT_DELAY });
|
||||||
|
get().retryAllMessages();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setReconnecting: (reconnecting: boolean, attempt?: number) => {
|
||||||
|
set({
|
||||||
|
isReconnecting: reconnecting,
|
||||||
|
reconnectAttempt: attempt ?? get().reconnectAttempt,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Message Queue Operations ===
|
||||||
|
|
||||||
|
queueMessage: (content: string, agentId?: string, sessionKey?: string) => {
|
||||||
|
const state = get();
|
||||||
|
|
||||||
|
// Check queue size limit
|
||||||
|
if (state.queuedMessages.length >= state.maxQueueSize) {
|
||||||
|
// Remove oldest pending message
|
||||||
|
const filtered = state.queuedMessages.filter((m, i) =>
|
||||||
|
i > 0 || m.status !== 'pending'
|
||||||
|
);
|
||||||
|
set({ queuedMessages: filtered });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = generateMessageId();
|
||||||
|
const message: QueuedMessage = {
|
||||||
|
id,
|
||||||
|
content,
|
||||||
|
agentId,
|
||||||
|
sessionKey,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
retryCount: 0,
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
|
||||||
|
set((s) => ({
|
||||||
|
queuedMessages: [...s.queuedMessages, message],
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(`[OfflineStore] Message queued: ${id}`);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMessageStatus: (id: string, status: QueuedMessage['status'], error?: string) => {
|
||||||
|
set((s) => ({
|
||||||
|
queuedMessages: s.queuedMessages.map((m) =>
|
||||||
|
m.id === id
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
status,
|
||||||
|
lastError: error,
|
||||||
|
retryCount: status === 'failed' ? m.retryCount + 1 : m.retryCount,
|
||||||
|
}
|
||||||
|
: m
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeMessage: (id: string) => {
|
||||||
|
set((s) => ({
|
||||||
|
queuedMessages: s.queuedMessages.filter((m) => m.id !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearQueue: () => {
|
||||||
|
set({ queuedMessages: [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
retryAllMessages: async () => {
|
||||||
|
const state = get();
|
||||||
|
const pending = state.queuedMessages.filter(
|
||||||
|
(m) => m.status === 'pending' || m.status === 'failed'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
|
||||||
|
// Check if connected
|
||||||
|
if (getConnectionState() !== 'connected') {
|
||||||
|
console.log('[OfflineStore] Not connected, cannot retry messages');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[OfflineStore] Retrying ${pending.length} queued messages`);
|
||||||
|
|
||||||
|
for (const msg of pending) {
|
||||||
|
if (msg.retryCount >= state.maxRetryCount) {
|
||||||
|
console.log(`[OfflineStore] Message ${msg.id} exceeded max retries`);
|
||||||
|
get().updateMessageStatus(msg.id, 'failed', 'Max retry count exceeded');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
get().updateMessageStatus(msg.id, 'sending');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Import gateway client dynamically to avoid circular dependency
|
||||||
|
const { getGatewayClient } = await import('../lib/gateway-client');
|
||||||
|
const client = getGatewayClient();
|
||||||
|
|
||||||
|
await client.chat(msg.content, {
|
||||||
|
sessionKey: msg.sessionKey,
|
||||||
|
agentId: msg.agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
get().updateMessageStatus(msg.id, 'sent');
|
||||||
|
// Remove sent message after a short delay
|
||||||
|
setTimeout(() => get().removeMessage(msg.id), 1000);
|
||||||
|
console.log(`[OfflineStore] Message ${msg.id} sent successfully`);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Send failed';
|
||||||
|
get().updateMessageStatus(msg.id, 'failed', errorMessage);
|
||||||
|
console.warn(`[OfflineStore] Message ${msg.id} failed:`, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Reconnection ===
|
||||||
|
|
||||||
|
scheduleReconnect: () => {
|
||||||
|
const state = get();
|
||||||
|
|
||||||
|
// Don't schedule if already online
|
||||||
|
if (!state.isOffline) return;
|
||||||
|
|
||||||
|
// Cancel any existing timer
|
||||||
|
get().cancelReconnect();
|
||||||
|
|
||||||
|
const attempt = state.reconnectAttempt + 1;
|
||||||
|
const delay = state.nextReconnectDelay;
|
||||||
|
|
||||||
|
console.log(`[OfflineStore] Scheduling reconnect attempt ${attempt} in ${delay}ms`);
|
||||||
|
|
||||||
|
set({
|
||||||
|
isReconnecting: true,
|
||||||
|
reconnectAttempt: attempt,
|
||||||
|
nextReconnectDelay: calculateNextDelay(delay),
|
||||||
|
});
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
get().attemptReconnect();
|
||||||
|
}, delay);
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelReconnect: () => {
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
set({ isReconnecting: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
attemptReconnect: async () => {
|
||||||
|
console.log('[OfflineStore] Attempting to reconnect...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to connect via connection store
|
||||||
|
await useConnectionStore.getState().connect();
|
||||||
|
|
||||||
|
// Check if now connected
|
||||||
|
if (getConnectionState() === 'connected') {
|
||||||
|
console.log('[OfflineStore] Reconnection successful');
|
||||||
|
get().setOffline(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[OfflineStore] Reconnection failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still offline, schedule next attempt
|
||||||
|
get().setReconnecting(false);
|
||||||
|
get().scheduleReconnect();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Getters ===
|
||||||
|
|
||||||
|
getPendingMessages: () => {
|
||||||
|
return get().queuedMessages.filter(
|
||||||
|
(m) => m.status === 'pending' || m.status === 'failed'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
hasPendingMessages: () => {
|
||||||
|
return get().queuedMessages.some(
|
||||||
|
(m) => m.status === 'pending' || m.status === 'failed'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'zclaw-offline-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
queuedMessages: state.queuedMessages.filter(
|
||||||
|
(m) => m.status === 'pending' || m.status === 'failed'
|
||||||
|
),
|
||||||
|
lastOnlineTime: state.lastOnlineTime,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// === Connection State Monitor ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start monitoring connection state and update offline store accordingly.
|
||||||
|
* Should be called once during app initialization.
|
||||||
|
*/
|
||||||
|
export function startOfflineMonitor(): () => void {
|
||||||
|
const checkConnection = () => {
|
||||||
|
const connectionState = getConnectionState();
|
||||||
|
const isOffline = connectionState !== 'connected';
|
||||||
|
|
||||||
|
if (isOffline !== useOfflineStore.getState().isOffline) {
|
||||||
|
useOfflineStore.getState().setOffline(isOffline);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
checkConnection();
|
||||||
|
|
||||||
|
// Subscribe to connection state changes
|
||||||
|
const unsubscribe = useConnectionStore.subscribe((state, prevState) => {
|
||||||
|
if (state.connectionState !== prevState.connectionState) {
|
||||||
|
const isOffline = state.connectionState !== 'connected';
|
||||||
|
useOfflineStore.getState().setOffline(isOffline);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Periodic health check (every 30 seconds)
|
||||||
|
healthCheckInterval = setInterval(checkConnection, 30000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
if (healthCheckInterval) {
|
||||||
|
clearInterval(healthCheckInterval);
|
||||||
|
healthCheckInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Exported Accessors ===
|
||||||
|
|
||||||
|
export const isOffline = () => useOfflineStore.getState().isOffline;
|
||||||
|
export const getQueuedMessages = () => useOfflineStore.getState().queuedMessages;
|
||||||
|
export const hasPendingMessages = () => useOfflineStore.getState().hasPendingMessages();
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* 基于实际 API 端点: http://127.0.0.1:50051
|
* 基于实际 API 端点: http://127.0.0.1:50051
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Page } from '@playwright/test';
|
import { Page, WebSocketRoute } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock 响应数据模板 - 基于实际 API 响应格式
|
* Mock 响应数据模板 - 基于实际 API 响应格式
|
||||||
@@ -440,7 +440,7 @@ export async function setupMockGateway(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mock Hand 运行状态 - GET /api/hands/{name}/runs/{runId}
|
// Mock Hand 运行状态 - GET /api/hands/{name}/runs/{runId}
|
||||||
await page.route('**/api/hands/*/runs/*', async (route) => {
|
await page.route('**/api/hands/*/runs/**', async (route) => {
|
||||||
if (simulateDelay) await delay(delayMs);
|
if (simulateDelay) await delay(delayMs);
|
||||||
const method = route.request().method();
|
const method = route.request().method();
|
||||||
const url = route.request().url();
|
const url = route.request().url();
|
||||||
@@ -468,6 +468,13 @@ export async function setupMockGateway(
|
|||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback for any other requests
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ status: 'ok' }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -481,6 +488,26 @@ export async function setupMockGateway(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mock Hand 审批 - POST /api/hands/{name}/runs/{runId}/approve
|
||||||
|
await page.route('**/api/hands/*/runs/*/approve', async (route) => {
|
||||||
|
if (simulateDelay) await delay(delayMs);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ status: 'approved' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock Hand 取消 - POST /api/hands/{name}/runs/{runId}/cancel
|
||||||
|
await page.route('**/api/hands/*/runs/*/cancel', async (route) => {
|
||||||
|
if (simulateDelay) await delay(delayMs);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ status: 'cancelled' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Workflow 端点
|
// Workflow 端点
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -777,6 +804,153 @@ export async function mockTimeout(page: Page, path: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket Mock 配置
|
||||||
|
*/
|
||||||
|
export interface MockWebSocketConfig {
|
||||||
|
/** 模拟响应内容 */
|
||||||
|
responseContent?: string;
|
||||||
|
/** 是否模拟流式响应 */
|
||||||
|
streaming?: boolean;
|
||||||
|
/** 流式响应的块延迟 (ms) */
|
||||||
|
chunkDelay?: number;
|
||||||
|
/** 是否模拟错误 */
|
||||||
|
simulateError?: boolean;
|
||||||
|
/** 错误消息 */
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储 WebSocket Mock 配置
|
||||||
|
*/
|
||||||
|
let wsConfig: MockWebSocketConfig = {
|
||||||
|
responseContent: 'This is a mock streaming response from the WebSocket server.',
|
||||||
|
streaming: true,
|
||||||
|
chunkDelay: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 WebSocket Mock 配置
|
||||||
|
*/
|
||||||
|
export function setWebSocketConfig(config: Partial<MockWebSocketConfig>): void {
|
||||||
|
wsConfig = { ...wsConfig, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Agent WebSocket 流式响应
|
||||||
|
* 使用 Playwright 的 routeWebSocket API 拦截 WebSocket 连接
|
||||||
|
*/
|
||||||
|
export async function mockAgentWebSocket(
|
||||||
|
page: Page,
|
||||||
|
config: Partial<MockWebSocketConfig> = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const finalConfig = { ...wsConfig, ...config };
|
||||||
|
|
||||||
|
await page.routeWebSocket('**/api/agents/*/ws', async (ws: WebSocketRoute) => {
|
||||||
|
// Handle incoming messages from the page
|
||||||
|
ws.onMessage(async (message) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message);
|
||||||
|
|
||||||
|
// Handle chat message
|
||||||
|
if (data.type === 'message' || data.content) {
|
||||||
|
// Send connected event first
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'connected',
|
||||||
|
agent_id: 'default-agent',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Simulate error if configured
|
||||||
|
if (finalConfig.simulateError) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
message: finalConfig.errorMessage || 'Mock WebSocket error',
|
||||||
|
}));
|
||||||
|
ws.close({ code: 1011, reason: 'Error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = finalConfig.responseContent || 'Mock response';
|
||||||
|
|
||||||
|
if (finalConfig.streaming) {
|
||||||
|
// Send typing indicator
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'typing',
|
||||||
|
state: 'start',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stream response in chunks
|
||||||
|
const words = responseText.split(' ');
|
||||||
|
let current = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < words.length; i++) {
|
||||||
|
current += (current ? ' ' : '') + words[i];
|
||||||
|
|
||||||
|
// Send text delta every few words
|
||||||
|
if (current.length >= 10 || i === words.length - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, finalConfig.chunkDelay || 50));
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'text_delta',
|
||||||
|
content: current,
|
||||||
|
}));
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send typing stop
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'typing',
|
||||||
|
state: 'stop',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Send phase done
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'phase',
|
||||||
|
phase: 'done',
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Non-streaming response
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'response',
|
||||||
|
content: responseText,
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: responseText.split(' ').length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close connection after response
|
||||||
|
ws.close({ code: 1000, reason: 'Stream complete' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('WebSocket mock error:', err);
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Failed to parse message',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle connection close from page
|
||||||
|
ws.onClose(() => {
|
||||||
|
// Clean up
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置完整的 Gateway Mock (包括 WebSocket)
|
||||||
|
*/
|
||||||
|
export async function setupMockGatewayWithWebSocket(
|
||||||
|
page: Page,
|
||||||
|
config: MockGatewayConfig & { wsConfig?: Partial<MockWebSocketConfig> } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
// Setup HTTP mocks
|
||||||
|
await setupMockGateway(page, config);
|
||||||
|
|
||||||
|
// Setup WebSocket mock
|
||||||
|
await mockAgentWebSocket(page, config.wsConfig || {});
|
||||||
|
}
|
||||||
|
|
||||||
// 辅助函数
|
// 辅助函数
|
||||||
function delay(ms: number): Promise<void> {
|
function delay(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|||||||
@@ -68,8 +68,23 @@ export const storeInspectors = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取持久化的 Chat Store 状态
|
* 获取持久化的 Chat Store 状态
|
||||||
|
* 优先从运行时获取,如果不可用则从 localStorage 获取
|
||||||
*/
|
*/
|
||||||
async getChatState<T = unknown>(page: Page): Promise<T | null> {
|
async getChatState<T = unknown>(page: Page): Promise<T | null> {
|
||||||
|
// First try to get runtime state (more reliable for E2E tests)
|
||||||
|
const runtimeState = await page.evaluate(() => {
|
||||||
|
const stores = (window as any).__ZCLAW_STORES__;
|
||||||
|
if (stores && stores.chat) {
|
||||||
|
return stores.chat.getState() as T;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (runtimeState) {
|
||||||
|
return runtimeState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to localStorage
|
||||||
return page.evaluate((key) => {
|
return page.evaluate((key) => {
|
||||||
const stored = localStorage.getItem(key);
|
const stored = localStorage.getItem(key);
|
||||||
if (!stored) return null;
|
if (!stored) return null;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { setupMockGateway, mockAgentMessageResponse, mockResponses, mockErrorRes
|
|||||||
import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors';
|
import { storeInspectors, STORE_NAMES } from '../fixtures/store-inspectors';
|
||||||
import { userActions, waitForAppReady, skipOnboarding, navigateToTab } from '../utils/user-actions';
|
import { userActions, waitForAppReady, skipOnboarding, navigateToTab } from '../utils/user-actions';
|
||||||
import { networkHelpers } from '../utils/network-helpers';
|
import { networkHelpers } from '../utils/network-helpers';
|
||||||
|
import { setupMockGatewayWithWebSocket, setWebSocketConfig } from '../fixtures/mock-gateway';
|
||||||
|
|
||||||
// Test configuration
|
// Test configuration
|
||||||
test.setTimeout(120000);
|
test.setTimeout(120000);
|
||||||
@@ -168,16 +169,15 @@ test.describe('Chat Message Tests', () => {
|
|||||||
test.describe.configure({ mode: 'parallel' }); // Parallel for isolation
|
test.describe.configure({ mode: 'parallel' }); // Parallel for isolation
|
||||||
|
|
||||||
test('CHAT-MSG-01: Send message and receive response', async ({ page }) => {
|
test('CHAT-MSG-01: Send message and receive response', async ({ page }) => {
|
||||||
// Setup mock gateway
|
// Setup mock gateway with WebSocket support
|
||||||
await setupMockGateway(page);
|
const mockResponse = 'This is a mock AI response for testing purposes.';
|
||||||
|
await setupMockGatewayWithWebSocket(page, {
|
||||||
|
wsConfig: { responseContent: mockResponse, streaming: true }
|
||||||
|
});
|
||||||
await skipOnboarding(page);
|
await skipOnboarding(page);
|
||||||
await page.goto(BASE_URL);
|
await page.goto(BASE_URL);
|
||||||
await waitForAppReady(page);
|
await waitForAppReady(page);
|
||||||
|
|
||||||
// Mock agent message response
|
|
||||||
const mockResponse = 'This is a mock AI response for testing purposes.';
|
|
||||||
await mockAgentMessageResponse(page, mockResponse);
|
|
||||||
|
|
||||||
// Find chat input
|
// Find chat input
|
||||||
const chatInput = page.locator('textarea').first();
|
const chatInput = page.locator('textarea').first();
|
||||||
await expect(chatInput).toBeVisible({ timeout: 10000 });
|
await expect(chatInput).toBeVisible({ timeout: 10000 });
|
||||||
@@ -209,12 +209,13 @@ test.describe('Chat Message Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('CHAT-MSG-02: Message updates store state', async ({ page }) => {
|
test('CHAT-MSG-02: Message updates store state', async ({ page }) => {
|
||||||
// Setup fresh page
|
// Setup fresh page with WebSocket support
|
||||||
await setupMockGateway(page);
|
await setupMockGatewayWithWebSocket(page, {
|
||||||
|
wsConfig: { responseContent: 'Store state test response', streaming: true }
|
||||||
|
});
|
||||||
await skipOnboarding(page);
|
await skipOnboarding(page);
|
||||||
await page.goto(BASE_URL);
|
await page.goto(BASE_URL);
|
||||||
await waitForAppReady(page);
|
await waitForAppReady(page);
|
||||||
await mockAgentMessageResponse(page, 'Store state test response');
|
|
||||||
|
|
||||||
// Clear any existing messages first
|
// Clear any existing messages first
|
||||||
await storeInspectors.clearStore(page, STORE_NAMES.CHAT);
|
await storeInspectors.clearStore(page, STORE_NAMES.CHAT);
|
||||||
@@ -243,12 +244,13 @@ test.describe('Chat Message Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('CHAT-MSG-03: Streaming response indicator', async ({ page }) => {
|
test('CHAT-MSG-03: Streaming response indicator', async ({ page }) => {
|
||||||
// Setup fresh page
|
// Setup fresh page with WebSocket support
|
||||||
await setupMockGateway(page);
|
await setupMockGatewayWithWebSocket(page, {
|
||||||
|
wsConfig: { responseContent: 'Streaming test response with longer content', streaming: true, chunkDelay: 100 }
|
||||||
|
});
|
||||||
await skipOnboarding(page);
|
await skipOnboarding(page);
|
||||||
await page.goto(BASE_URL);
|
await page.goto(BASE_URL);
|
||||||
await waitForAppReady(page);
|
await waitForAppReady(page);
|
||||||
await mockAgentMessageResponse(page, 'Streaming test response with longer content');
|
|
||||||
|
|
||||||
const chatInput = page.locator('textarea').first();
|
const chatInput = page.locator('textarea').first();
|
||||||
await chatInput.fill('Write a short poem');
|
await chatInput.fill('Write a short poem');
|
||||||
@@ -276,8 +278,10 @@ test.describe('Chat Message Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('CHAT-MSG-04: Error handling for failed message', async ({ page }) => {
|
test('CHAT-MSG-04: Error handling for failed message', async ({ page }) => {
|
||||||
// Setup fresh page with error mock
|
// Setup fresh page with error mock - WebSocket will simulate error
|
||||||
await mockErrorResponse(page, 'health', 500, 'Internal Server Error');
|
await setupMockGatewayWithWebSocket(page, {
|
||||||
|
wsConfig: { simulateError: true, errorMessage: 'WebSocket connection failed' }
|
||||||
|
});
|
||||||
await skipOnboarding(page);
|
await skipOnboarding(page);
|
||||||
await page.goto(BASE_URL);
|
await page.goto(BASE_URL);
|
||||||
await waitForAppReady(page);
|
await waitForAppReady(page);
|
||||||
@@ -294,12 +298,13 @@ test.describe('Chat Message Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('CHAT-MSG-05: Multiple messages in sequence', async ({ page }) => {
|
test('CHAT-MSG-05: Multiple messages in sequence', async ({ page }) => {
|
||||||
// Setup fresh page
|
// Setup fresh page with WebSocket support
|
||||||
await setupMockGateway(page);
|
await setupMockGatewayWithWebSocket(page, {
|
||||||
|
wsConfig: { responseContent: 'Response to sequential message', streaming: true }
|
||||||
|
});
|
||||||
await skipOnboarding(page);
|
await skipOnboarding(page);
|
||||||
await page.goto(BASE_URL);
|
await page.goto(BASE_URL);
|
||||||
await waitForAppReady(page);
|
await waitForAppReady(page);
|
||||||
await mockAgentMessageResponse(page, 'Response to sequential message');
|
|
||||||
|
|
||||||
// Clear existing messages
|
// Clear existing messages
|
||||||
await storeInspectors.clearStore(page, STORE_NAMES.CHAT);
|
await storeInspectors.clearStore(page, STORE_NAMES.CHAT);
|
||||||
|
|||||||
500
desktop/tests/lib/security.test.ts
Normal file
500
desktop/tests/lib/security.test.ts
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
/**
|
||||||
|
* Security Module Tests
|
||||||
|
*
|
||||||
|
* Unit tests for crypto utilities, security utils, and audit logging.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Crypto Utils Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('Crypto Utils', () => {
|
||||||
|
// Import dynamically to handle Web Crypto API
|
||||||
|
let cryptoUtils: typeof import('../../src/lib/crypto-utils');
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
cryptoUtils = await import('../../src/lib/crypto-utils');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('arrayToBase64 / base64ToArray', () => {
|
||||||
|
it('should convert Uint8Array to base64 and back', () => {
|
||||||
|
const original = new Uint8Array([72, 101, 108, 108, 111]);
|
||||||
|
const base64 = cryptoUtils.arrayToBase64(original);
|
||||||
|
const decoded = cryptoUtils.base64ToArray(base64);
|
||||||
|
|
||||||
|
expect(decoded).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty array', () => {
|
||||||
|
const empty = new Uint8Array([]);
|
||||||
|
expect(cryptoUtils.arrayToBase64(empty)).toBe('');
|
||||||
|
expect(cryptoUtils.base64ToArray('')).toEqual(empty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveKey', () => {
|
||||||
|
it('should derive a CryptoKey from a master key', async () => {
|
||||||
|
const key = await cryptoUtils.deriveKey('test-master-key');
|
||||||
|
|
||||||
|
expect(key).toBeDefined();
|
||||||
|
expect(key.type).toBe('secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should derive the same key for the same input', async () => {
|
||||||
|
const key1 = await cryptoUtils.deriveKey('test-master-key');
|
||||||
|
const key2 = await cryptoUtils.deriveKey('test-master-key');
|
||||||
|
|
||||||
|
// Both should be valid CryptoKey objects
|
||||||
|
expect(key1).toBeDefined();
|
||||||
|
expect(key2).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom salt', async () => {
|
||||||
|
const customSalt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||||
|
const key = await cryptoUtils.deriveKey('test-master-key', customSalt);
|
||||||
|
|
||||||
|
expect(key).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encrypt / decrypt', () => {
|
||||||
|
it('should encrypt and decrypt a string', async () => {
|
||||||
|
const key = await cryptoUtils.deriveKey('test-master-key');
|
||||||
|
const plaintext = 'Hello, World!';
|
||||||
|
|
||||||
|
const encrypted = await cryptoUtils.encrypt(plaintext, key);
|
||||||
|
expect(encrypted.iv).toBeDefined();
|
||||||
|
expect(encrypted.data).toBeDefined();
|
||||||
|
expect(encrypted.version).toBe(1);
|
||||||
|
|
||||||
|
const decrypted = await cryptoUtils.decrypt(encrypted, key);
|
||||||
|
expect(decrypted).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce different IVs for same plaintext', async () => {
|
||||||
|
const key = await cryptoUtils.deriveKey('test-master-key');
|
||||||
|
const plaintext = 'Same text';
|
||||||
|
|
||||||
|
const encrypted1 = await cryptoUtils.encrypt(plaintext, key);
|
||||||
|
const encrypted2 = await cryptoUtils.encrypt(plaintext, key);
|
||||||
|
|
||||||
|
expect(encrypted1.iv).not.toBe(encrypted2.iv);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', async () => {
|
||||||
|
const key = await cryptoUtils.deriveKey('test-master-key');
|
||||||
|
const plaintext = '';
|
||||||
|
|
||||||
|
const encrypted = await cryptoUtils.encrypt(plaintext, key);
|
||||||
|
const decrypted = await cryptoUtils.decrypt(encrypted, key);
|
||||||
|
|
||||||
|
expect(decrypted).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unicode characters', async () => {
|
||||||
|
const key = await cryptoUtils.deriveKey('test-master-key');
|
||||||
|
const plaintext = '中文测试 日本語 한국어 🎉';
|
||||||
|
|
||||||
|
const encrypted = await cryptoUtils.encrypt(plaintext, key);
|
||||||
|
const decrypted = await cryptoUtils.decrypt(encrypted, key);
|
||||||
|
|
||||||
|
expect(decrypted).toBe(plaintext);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encryptObject / decryptObject', () => {
|
||||||
|
it('should encrypt and decrypt an object', async () => {
|
||||||
|
const key = await cryptoUtils.deriveKey('test-master-key');
|
||||||
|
const obj = { name: 'test', count: 42, nested: { a: 1 } };
|
||||||
|
|
||||||
|
const encrypted = await cryptoUtils.encryptObject(obj, key);
|
||||||
|
const decrypted = await cryptoUtils.decryptObject<typeof obj>(encrypted, key);
|
||||||
|
|
||||||
|
expect(decrypted).toEqual(obj);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateMasterKey', () => {
|
||||||
|
it('should generate a base64 string', () => {
|
||||||
|
const key = cryptoUtils.generateMasterKey();
|
||||||
|
|
||||||
|
expect(typeof key).toBe('string');
|
||||||
|
expect(key.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate unique keys', () => {
|
||||||
|
const key1 = cryptoUtils.generateMasterKey();
|
||||||
|
const key2 = cryptoUtils.generateMasterKey();
|
||||||
|
|
||||||
|
expect(key1).not.toBe(key2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hashSha256', () => {
|
||||||
|
it('should produce consistent hash', async () => {
|
||||||
|
const input = 'test-input';
|
||||||
|
const hash1 = await cryptoUtils.hashSha256(input);
|
||||||
|
const hash2 = await cryptoUtils.hashSha256(input);
|
||||||
|
|
||||||
|
expect(hash1).toBe(hash2);
|
||||||
|
expect(hash1.length).toBe(64); // SHA-256 produces 64 hex chars
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constantTimeEqual', () => {
|
||||||
|
it('should return true for equal arrays', () => {
|
||||||
|
const a = new Uint8Array([1, 2, 3, 4]);
|
||||||
|
const b = new Uint8Array([1, 2, 3, 4]);
|
||||||
|
|
||||||
|
expect(cryptoUtils.constantTimeEqual(a, b)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different arrays', () => {
|
||||||
|
const a = new Uint8Array([1, 2, 3, 4]);
|
||||||
|
const b = new Uint8Array([1, 2, 3, 5]);
|
||||||
|
|
||||||
|
expect(cryptoUtils.constantTimeEqual(a, b)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different length arrays', () => {
|
||||||
|
const a = new Uint8Array([1, 2, 3]);
|
||||||
|
const b = new Uint8Array([1, 2, 3, 4]);
|
||||||
|
|
||||||
|
expect(cryptoUtils.constantTimeEqual(a, b)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidEncryptedData', () => {
|
||||||
|
it('should validate correct structure', () => {
|
||||||
|
const valid = { iv: 'abc', data: 'xyz' };
|
||||||
|
expect(cryptoUtils.isValidEncryptedData(valid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid structures', () => {
|
||||||
|
expect(cryptoUtils.isValidEncryptedData(null)).toBe(false);
|
||||||
|
expect(cryptoUtils.isValidEncryptedData({})).toBe(false);
|
||||||
|
expect(cryptoUtils.isValidEncryptedData({ iv: '' })).toBe(false);
|
||||||
|
expect(cryptoUtils.isValidEncryptedData({ data: '' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Security Utils Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('Security Utils', () => {
|
||||||
|
let securityUtils: typeof import('../security-utils');
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
securityUtils = await import('../security-utils');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('escapeHtml', () => {
|
||||||
|
it('should escape HTML special characters', () => {
|
||||||
|
const input = '<script>alert("xss")</script>';
|
||||||
|
const escaped = securityUtils.escapeHtml(input);
|
||||||
|
|
||||||
|
expect(escaped).toBe('<script>alert("xss")</script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle plain text', () => {
|
||||||
|
const input = 'Hello, World!';
|
||||||
|
expect(securityUtils.escapeHtml(input)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle ampersand', () => {
|
||||||
|
expect(securityUtils.escapeHtml('a & b')).toBe('a & b');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeHtml', () => {
|
||||||
|
it('should remove script tags', () => {
|
||||||
|
const html = '<p>Hello</p><script>alert("xss")</script>';
|
||||||
|
const sanitized = securityUtils.sanitizeHtml(html);
|
||||||
|
|
||||||
|
expect(sanitized).not.toContain('<script>');
|
||||||
|
expect(sanitized).toContain('<p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove event handlers', () => {
|
||||||
|
const html = '<div onclick="alert(1)">Click me</div>';
|
||||||
|
const sanitized = securityUtils.sanitizeHtml(html);
|
||||||
|
|
||||||
|
expect(sanitized).not.toContain('onclick');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove javascript: URLs', () => {
|
||||||
|
const html = '<a href="javascript:alert(1)">Link</a>';
|
||||||
|
const sanitized = securityUtils.sanitizeHtml(html);
|
||||||
|
|
||||||
|
expect(sanitized).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve allowed tags', () => {
|
||||||
|
const html = '<p><strong>Bold</strong> and <em>italic</em></p>';
|
||||||
|
const sanitized = securityUtils.sanitizeHtml(html);
|
||||||
|
|
||||||
|
expect(sanitized).toContain('<p>');
|
||||||
|
expect(sanitized).toContain('<strong>');
|
||||||
|
expect(sanitized).toContain('<em>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateUrl', () => {
|
||||||
|
it('should validate http URLs', () => {
|
||||||
|
const url = 'https://example.com/path';
|
||||||
|
expect(securityUtils.validateUrl(url)).toBe(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject javascript: URLs', () => {
|
||||||
|
expect(securityUtils.validateUrl('javascript:alert(1)')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject data: URLs by default', () => {
|
||||||
|
expect(securityUtils.validateUrl('data:text/html,<script>alert(1)</script>')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject localhost when not allowed', () => {
|
||||||
|
expect(
|
||||||
|
securityUtils.validateUrl('http://localhost:3000', { allowLocalhost: false })
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow localhost when allowed', () => {
|
||||||
|
const url = 'http://localhost:3000';
|
||||||
|
expect(
|
||||||
|
securityUtils.validateUrl(url, { allowLocalhost: true })
|
||||||
|
).toBe(url);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validatePath', () => {
|
||||||
|
it('should reject path traversal', () => {
|
||||||
|
expect(securityUtils.validatePath('../../../etc/passwd')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject null bytes', () => {
|
||||||
|
expect(securityUtils.validatePath('file\0.txt')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject absolute paths when not allowed', () => {
|
||||||
|
expect(
|
||||||
|
securityUtils.validatePath('/etc/passwd', { allowAbsolute: false })
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate relative paths', () => {
|
||||||
|
const result = securityUtils.validatePath('folder/file.txt');
|
||||||
|
expect(result).toBe('folder/file.txt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidEmail', () => {
|
||||||
|
it('should validate correct emails', () => {
|
||||||
|
expect(securityUtils.isValidEmail('test@example.com')).toBe(true);
|
||||||
|
expect(securityUtils.isValidEmail('user.name@subdomain.example.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid emails', () => {
|
||||||
|
expect(securityUtils.isValidEmail('not-an-email')).toBe(false);
|
||||||
|
expect(securityUtils.isValidEmail('@example.com')).toBe(false);
|
||||||
|
expect(securityUtils.isValidEmail('test@')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validatePasswordStrength', () => {
|
||||||
|
it('should accept strong passwords', () => {
|
||||||
|
const result = securityUtils.validatePasswordStrength('Str0ng!Pass');
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.score).toBeGreaterThan(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject short passwords', () => {
|
||||||
|
const result = securityUtils.validatePasswordStrength('short');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.issues).toContain('Password must be at least 8 characters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect weak patterns', () => {
|
||||||
|
const result = securityUtils.validatePasswordStrength('password123');
|
||||||
|
expect(result.issues).toContain('Password contains a common pattern');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeFilename', () => {
|
||||||
|
it('should remove path separators', () => {
|
||||||
|
expect(securityUtils.sanitizeFilename('../test.txt')).toBe('.._test.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove dangerous characters', () => {
|
||||||
|
const sanitized = securityUtils.sanitizeFilename('file<>:"|?*.txt');
|
||||||
|
expect(sanitized).not.toMatch(/[<>:"|?*]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle normal filenames', () => {
|
||||||
|
expect(securityUtils.sanitizeFilename('document.pdf')).toBe('document.pdf');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeJson', () => {
|
||||||
|
it('should parse valid JSON', () => {
|
||||||
|
const result = securityUtils.sanitizeJson('{"name":"test"}');
|
||||||
|
expect(result).toEqual({ name: 'test' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove prototype pollution keys', () => {
|
||||||
|
const result = securityUtils.sanitizeJson('{"__proto__":{"admin":true},"name":"test"}');
|
||||||
|
expect(result).not.toHaveProperty('__proto__');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for invalid JSON', () => {
|
||||||
|
expect(securityUtils.sanitizeJson('not json')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isRateLimited', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear rate limit store
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow first request', () => {
|
||||||
|
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block after limit reached', () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
securityUtils.isRateLimited('test-key', 5, 60000);
|
||||||
|
}
|
||||||
|
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset after window expires', () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
securityUtils.isRateLimited('test-key', 5, 60000);
|
||||||
|
}
|
||||||
|
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(true);
|
||||||
|
|
||||||
|
// Advance time past window
|
||||||
|
vi.advanceTimersByTime(61000);
|
||||||
|
expect(securityUtils.isRateLimited('test-key', 5, 60000)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateSecureToken', () => {
|
||||||
|
it('should generate hex string of correct length', () => {
|
||||||
|
const token = securityUtils.generateSecureToken(16);
|
||||||
|
expect(token.length).toBe(32); // 16 bytes = 32 hex chars
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate unique tokens', () => {
|
||||||
|
const token1 = securityUtils.generateSecureToken();
|
||||||
|
const token2 = securityUtils.generateSecureToken();
|
||||||
|
expect(token1).not.toBe(token2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateSecureId', () => {
|
||||||
|
it('should generate ID with prefix', () => {
|
||||||
|
const id = securityUtils.generateSecureId('user');
|
||||||
|
expect(id.startsWith('user_')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate ID without prefix', () => {
|
||||||
|
const id = securityUtils.generateSecureId();
|
||||||
|
expect(id).toMatch(/^\w+_\w+$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Security Audit Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('Security Audit', () => {
|
||||||
|
let securityAudit: typeof import('../security-audit');
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
securityAudit = await import('../security-audit');
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
securityAudit.clearSecurityAuditLog();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logSecurityEvent', () => {
|
||||||
|
it('should log security events', () => {
|
||||||
|
securityAudit.logSecurityEvent('auth_login', 'User logged in', { userId: '123' });
|
||||||
|
|
||||||
|
const events = securityAudit.getSecurityEvents();
|
||||||
|
expect(events.length).toBe(1);
|
||||||
|
expect(events[0].type).toBe('auth_login');
|
||||||
|
expect(events[0].message).toBe('User logged in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should determine severity automatically', () => {
|
||||||
|
securityAudit.logSecurityEvent('security_violation', 'Test violation');
|
||||||
|
securityAudit.logSecurityEvent('auth_login', 'Test login');
|
||||||
|
|
||||||
|
const events = securityAudit.getSecurityEvents();
|
||||||
|
expect(events[0].severity).toBe('critical');
|
||||||
|
expect(events[1].severity).toBe('info');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSecurityEventsByType', () => {
|
||||||
|
it('should filter events by type', () => {
|
||||||
|
securityAudit.logSecurityEvent('auth_login', 'Login 1');
|
||||||
|
securityAudit.logSecurityEvent('auth_logout', 'Logout');
|
||||||
|
securityAudit.logSecurityEvent('auth_login', 'Login 2');
|
||||||
|
|
||||||
|
const loginEvents = securityAudit.getSecurityEventsByType('auth_login');
|
||||||
|
expect(loginEvents.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSecurityEventsBySeverity', () => {
|
||||||
|
it('should filter events by severity', () => {
|
||||||
|
securityAudit.logSecurityEvent('auth_login', 'Login', {}, 'info');
|
||||||
|
securityAudit.logSecurityEvent('auth_failed', 'Failed', {}, 'warning');
|
||||||
|
|
||||||
|
const infoEvents = securityAudit.getSecurityEventsBySeverity('info');
|
||||||
|
expect(infoEvents.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateSecurityAuditReport', () => {
|
||||||
|
it('should generate a report', () => {
|
||||||
|
securityAudit.logSecurityEvent('auth_login', 'Login');
|
||||||
|
securityAudit.logSecurityEvent('auth_failed', 'Failed');
|
||||||
|
securityAudit.logSecurityEvent('security_violation', 'Violation');
|
||||||
|
|
||||||
|
const report = securityAudit.generateSecurityAuditReport();
|
||||||
|
|
||||||
|
expect(report.totalEvents).toBe(3);
|
||||||
|
expect(report.eventsByType.auth_login).toBe(1);
|
||||||
|
expect(report.eventsBySeverity.critical).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setAuditEnabled', () => {
|
||||||
|
it('should disable logging when set to false', () => {
|
||||||
|
securityAudit.setAuditEnabled(false);
|
||||||
|
securityAudit.logSecurityEvent('auth_login', 'Should not log');
|
||||||
|
securityAudit.setAuditEnabled(true);
|
||||||
|
|
||||||
|
const events = securityAudit.getSecurityEvents();
|
||||||
|
// Only the config_changed event should be logged
|
||||||
|
const loginEvents = events.filter(e => e.type === 'auth_login');
|
||||||
|
expect(loginEvents.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user