Files
zclaw_openfang/desktop/src/components/Settings/SecureStorage.tsx
iven bf6d81f9c6
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
refactor: 清理未使用代码并添加未来功能标记
style: 统一代码格式和注释风格

docs: 更新多个功能文档的完整度和状态

feat(runtime): 添加路径验证工具支持

fix(pipeline): 改进条件判断和变量解析逻辑

test(types): 为ID类型添加全面测试用例

chore: 更新依赖项和Cargo.lock文件

perf(mcp): 优化MCP协议传输和错误处理
2026-03-25 21:55:12 +08:00

383 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* SecureStorage - OS Keyring/Keychain Management UI
*
* Allows users to view, add, and delete securely stored credentials
* using the OS keyring (Windows DPAPI, macOS Keychain, Linux Secret Service).
*/
import { useState, useEffect } from 'react';
import {
Key,
Plus,
Trash2,
Eye,
EyeOff,
RefreshCw,
AlertCircle,
CheckCircle,
Shield,
ShieldOff,
} from 'lucide-react';
import { secureStorage, isSecureStorageAvailable } from '../../lib/secure-storage';
interface StoredKey {
key: string;
hasValue: boolean;
preview?: string;
}
// Known storage keys used by the application
const KNOWN_KEYS = [
{ key: 'zclaw_api_key', label: 'API Key', description: 'LLM API 密钥' },
{ key: 'zclaw_device_keys_private', label: 'Device Private Key', description: '设备私钥 (Ed25519)' },
{ key: 'zclaw_gateway_token', label: 'Gateway Token', description: 'Gateway 认证令牌' },
{ key: 'zclaw_feishu_secret', label: '飞书 Secret', description: '飞书应用密钥' },
{ key: 'zclaw_discord_token', label: 'Discord Token', description: 'Discord Bot Token' },
{ key: 'zclaw_slack_token', label: 'Slack Token', description: 'Slack Bot Token' },
{ key: 'zclaw_telegram_token', label: 'Telegram Token', description: 'Telegram Bot Token' },
];
export function SecureStorage() {
const [isAvailable, setIsAvailable] = useState<boolean | null>(null);
const [storedKeys, setStoredKeys] = useState<StoredKey[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showAddForm, setShowAddForm] = useState(false);
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
const [showValue, setShowValue] = useState<Record<string, boolean>>({});
const [revealedValues, setRevealedValues] = useState<Record<string, string>>({});
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const loadStoredKeys = async () => {
setIsLoading(true);
try {
const available = await isSecureStorageAvailable();
setIsAvailable(available);
const keys: StoredKey[] = [];
for (const knownKey of KNOWN_KEYS) {
const value = await secureStorage.get(knownKey.key);
keys.push({
key: knownKey.key,
hasValue: !!value,
preview: value ? `${value.slice(0, 8)}${value.length > 8 ? '...' : ''}` : undefined,
});
}
setStoredKeys(keys);
} catch (error) {
console.error('Failed to load stored keys:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadStoredKeys();
}, []);
const handleReveal = async (key: string) => {
if (revealedValues[key]) {
// Hide if already revealed
setRevealedValues((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
setShowValue((prev) => ({ ...prev, [key]: false }));
} else {
// Reveal the value
const value = await secureStorage.get(key);
if (value) {
setRevealedValues((prev) => ({ ...prev, [key]: value }));
setShowValue((prev) => ({ ...prev, [key]: true }));
}
}
};
const handleAddKey = async () => {
if (!newKey.trim() || !newValue.trim()) {
setMessage({ type: 'error', text: '请填写密钥名称和值' });
return;
}
setIsSaving(true);
setMessage(null);
try {
await secureStorage.set(newKey.trim(), newValue.trim());
setMessage({ type: 'success', text: '密钥已保存' });
setNewKey('');
setNewValue('');
setShowAddForm(false);
await loadStoredKeys();
} catch (error) {
setMessage({ type: 'error', text: `保存失败: ${error instanceof Error ? error.message : '未知错误'}` });
} finally {
setIsSaving(false);
}
};
const handleDeleteKey = async (key: string) => {
if (!confirm(`确定要删除密钥 "${key}" 吗?此操作无法撤销。`)) {
return;
}
setIsDeleting(key);
setMessage(null);
try {
await secureStorage.delete(key);
setMessage({ type: 'success', text: '密钥已删除' });
setRevealedValues((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
await loadStoredKeys();
} catch (error) {
setMessage({ type: 'error', text: `删除失败: ${error instanceof Error ? error.message : '未知错误'}` });
} finally {
setIsDeleting(null);
}
};
const getKeyLabel = (key: string) => {
const known = KNOWN_KEYS.find((k) => k.key === key);
return known ? known.label : key;
};
const getKeyDescription = (key: string) => {
const known = KNOWN_KEYS.find((k) => k.key === key);
return known?.description;
};
return (
<div className="max-w-3xl">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white"></h1>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
使 (Keyring/Keychain)
</p>
</div>
<div className="flex gap-2 items-center">
{isAvailable !== null && (
<span className={`text-xs flex items-center gap-1 ${isAvailable ? 'text-green-600' : 'text-amber-600'}`}>
{isAvailable ? (
<>
<Shield className="w-3 h-3" /> Keyring
</>
) : (
<>
<ShieldOff className="w-3 h-3" /> 使
</>
)}
</span>
)}
<button
onClick={loadStoredKeys}
disabled={isLoading}
className="text-xs text-white bg-orange-500 hover:bg-orange-600 px-3 py-1.5 rounded-lg flex items-center gap-1 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-3 h-3 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Status Banner */}
{isAvailable === false && (
<div className="mb-6 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-500 mt-0.5" />
<div className="text-xs text-amber-700 dark:text-amber-300">
<p className="font-medium">Keyring </p>
<p className="mt-1">
使 AES-GCM
Tauri
</p>
</div>
</div>
</div>
)}
{/* Message */}
{message && (
<div className={`mb-4 p-3 rounded-lg flex items-center gap-2 ${
message.type === 'success'
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300'
: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300'
}`}>
{message.type === 'success' ? (
<CheckCircle className="w-4 h-4" />
) : (
<AlertCircle className="w-4 h-4" />
)}
{message.text}
</div>
)}
{/* Stored Keys List */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 mb-6 shadow-sm">
{isLoading ? (
<div className="h-40 flex items-center justify-center text-sm text-gray-400">
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
...
</div>
) : storedKeys.length > 0 ? (
<div className="divide-y divide-gray-100 dark:divide-gray-700">
{storedKeys.map((item) => (
<div key={item.key} className="p-4">
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
item.hasValue
? 'bg-gradient-to-br from-green-500 to-emerald-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-400'
}`}>
<Key className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{getKeyLabel(item.key)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{getKeyDescription(item.key) || item.key}
</div>
{item.hasValue && (
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1 font-mono">
{showValue[item.key] ? (
<span className="break-all">{revealedValues[item.key]}</span>
) : (
<span>{item.preview}</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
{item.hasValue && (
<>
<button
onClick={() => handleReveal(item.key)}
className="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title={showValue[item.key] ? '隐藏' : '显示'}
>
{showValue[item.key] ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
<button
onClick={() => handleDeleteKey(item.key)}
disabled={isDeleting === item.key}
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
title="删除"
>
{isDeleting === item.key ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</>
)}
{!item.hasValue && (
<span className="text-xs text-gray-400 dark:text-gray-500 px-2"></span>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="h-40 flex items-center justify-center text-sm text-gray-400">
</div>
)}
</div>
{/* Add New Key */}
<div className="mb-6">
{!showAddForm ? (
<button
onClick={() => setShowAddForm(true)}
className="w-full p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl text-gray-500 dark:text-gray-400 hover:border-orange-400 hover:text-orange-500 transition-colors flex items-center justify-center gap-2"
>
<Plus className="w-4 h-4" />
<span className="text-sm"></span>
</button>
) : (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 shadow-sm">
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-4"></h3>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<input
type="text"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
placeholder="例如: zclaw_custom_key"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<input
type="password"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder="输入密钥值"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={() => {
setShowAddForm(false);
setNewKey('');
setNewValue('');
setMessage(null);
}}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm"
>
</button>
<button
onClick={handleAddKey}
disabled={isSaving || !newKey.trim() || !newValue.trim()}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
{isSaving ? (
<>
<RefreshCw className="w-3 h-3 animate-spin" />
...
</>
) : (
<>
<CheckCircle className="w-3 h-3" />
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
{/* Info Section */}
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-2"></h3>
<ul className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<li> Windows: 使用 DPAPI </li>
<li> macOS: 使用 Keychain </li>
<li> Linux: 使用 Secret Service API (gnome-keyring, kwallet )</li>
<li> 后备方案: AES-GCM localStorage</li>
</ul>
</div>
</div>
);
}