feat(app): add encrypted API key vault with IDB storage
Create key-vault.ts with AES-256-GCM encrypted IndexedDB storage for API keys (Claude, OpenRouter). Add ApiKeyManager.vue settings UI with masked key display and add/remove functionality. Integrate vault lookups into useAI.ts streaming functions with graceful fallback when IDB is unavailable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
621a324859
commit
9e4a2c30e1
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
|
||||
API Keys
|
||||
</h3>
|
||||
|
||||
<!-- Configured providers -->
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="provider in providers"
|
||||
:key="provider.id"
|
||||
class="flex items-center gap-3 p-3 rounded-xl"
|
||||
:class="isDark
|
||||
? 'bg-white/[0.03] border border-white/5'
|
||||
: 'bg-black/[0.02] border border-black/5'"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-xs font-medium"
|
||||
:class="isDark ? 'text-white/80' : 'text-gray-800'"
|
||||
>
|
||||
{{ provider.name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] font-mono mt-0.5"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'"
|
||||
>
|
||||
{{ provider.masked ?? 'Not configured' }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="provider.hasKey"
|
||||
class="text-[10px] px-2 py-1 rounded-md transition-colors"
|
||||
:class="isDark
|
||||
? 'text-red-400/60 hover:text-red-400 hover:bg-red-500/10'
|
||||
: 'text-red-500/60 hover:text-red-600 hover:bg-red-50'"
|
||||
@click="removeKey(provider.id)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add new key -->
|
||||
<div class="space-y-2">
|
||||
<select
|
||||
v-model="selectedProvider"
|
||||
class="w-full px-3 py-2 rounded-lg text-xs outline-none"
|
||||
:class="isDark
|
||||
? 'bg-white/5 text-white/80 border border-white/10'
|
||||
: 'bg-gray-50 text-gray-800 border border-gray-200'"
|
||||
>
|
||||
<option value="">Select provider...</option>
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
</select>
|
||||
|
||||
<div v-if="selectedProvider" class="flex gap-2">
|
||||
<input
|
||||
v-model="newKey"
|
||||
type="password"
|
||||
placeholder="Paste API key..."
|
||||
class="flex-1 px-3 py-2 rounded-lg text-xs outline-none"
|
||||
:class="isDark
|
||||
? 'bg-white/5 text-white/80 placeholder:text-white/25 border border-white/10'
|
||||
: 'bg-gray-50 text-gray-800 placeholder:text-gray-400 border border-gray-200'"
|
||||
style="font-size: 16px"
|
||||
@keydown.enter="saveKey"
|
||||
/>
|
||||
<button
|
||||
:disabled="!newKey.trim()"
|
||||
class="px-3 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="newKey.trim()
|
||||
? 'bg-accent text-white hover:bg-accent/90'
|
||||
: isDark
|
||||
? 'bg-white/5 text-white/20 cursor-not-allowed'
|
||||
: 'bg-gray-100 text-gray-300 cursor-not-allowed'"
|
||||
@click="saveKey"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="statusMessage"
|
||||
class="text-[10px]"
|
||||
:class="statusError ? 'text-red-400' : isDark ? 'text-green-400' : 'text-green-600'"
|
||||
>
|
||||
{{ statusMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { storeApiKey, getApiKey, deleteApiKey, listProviders, maskApiKey } from '@/utils/key-vault'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
interface ProviderInfo {
|
||||
id: string
|
||||
name: string
|
||||
hasKey: boolean
|
||||
masked: string | null
|
||||
}
|
||||
|
||||
const providers = ref<ProviderInfo[]>([])
|
||||
const selectedProvider = ref('')
|
||||
const newKey = ref('')
|
||||
const statusMessage = ref('')
|
||||
const statusError = ref(false)
|
||||
|
||||
const PROVIDER_NAMES: Record<string, string> = {
|
||||
claude: 'Claude (Anthropic)',
|
||||
openrouter: 'OpenRouter',
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
const configured = await listProviders()
|
||||
const allProviders = ['claude', 'openrouter']
|
||||
|
||||
providers.value = await Promise.all(
|
||||
allProviders.map(async (id) => {
|
||||
const key = await getApiKey(id)
|
||||
return {
|
||||
id,
|
||||
name: PROVIDER_NAMES[id] ?? id,
|
||||
hasKey: !!key,
|
||||
masked: key ? maskApiKey(key) : null,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function saveKey() {
|
||||
if (!selectedProvider.value || !newKey.value.trim()) return
|
||||
await storeApiKey(selectedProvider.value, newKey.value.trim())
|
||||
statusMessage.value = `${PROVIDER_NAMES[selectedProvider.value] ?? selectedProvider.value} key saved`
|
||||
statusError.value = false
|
||||
newKey.value = ''
|
||||
selectedProvider.value = ''
|
||||
await loadProviders()
|
||||
}
|
||||
|
||||
async function removeKey(provider: string) {
|
||||
await deleteApiKey(provider)
|
||||
statusMessage.value = `${PROVIDER_NAMES[provider] ?? provider} key removed`
|
||||
statusError.value = false
|
||||
await loadProviders()
|
||||
}
|
||||
|
||||
onMounted(loadProviders)
|
||||
</script>
|
||||
Reference in New Issue
Block a user