change theme, linter, formatter

This commit is contained in:
Loïc Gremaud 2025-10-21 10:46:36 +02:00
parent 823e377e4b
commit e2375fbba9
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
21 changed files with 1106 additions and 1312 deletions

View File

@ -12,6 +12,7 @@ module.exports = {
'plugin:@typescript-eslint/recommended',
'plugin:vue/vue3-recommended',
'@vue/eslint-config-typescript',
'prettier',
],
ignorePatterns: ['dist', '.eslintrc.cjs', '*.config.js'],
parser: 'vue-eslint-parser',

32
.prettierignore Normal file
View File

@ -0,0 +1,32 @@
# Dependencies
node_modules/
pnpm-lock.yaml
# Build outputs
dist/
build/
# Generated files
*.min.js
*.min.css
# Documentation
*.md
CHANGELOG.md
README.md
# Config files that should maintain their format
.prettierrc
.eslintrc*
tsconfig*.json
vite.config.ts
tailwind.config.js
postcss.config.js
# Other
.git/
.vscode/
.idea/
*.log
tags

View File

@ -1,14 +1,15 @@
<!doctype html>
<html lang="en" data-theme="dark">
<html lang="en" data-theme="business">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vault-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Browser Vault GUI</title>
<!-- Material Design Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -7,7 +7,9 @@
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.ts --report-unused-disable-directives --max-warnings 0"
"lint": "eslint . --ext .vue,.ts --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"vue": "^3.4.15"
@ -20,8 +22,10 @@
"autoprefixer": "^10.4.16",
"daisyui": "^4.4.24",
"eslint": "^8.55.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^9.19.2",
"postcss": "^8.4.33",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.8",

View File

@ -33,12 +33,18 @@ importers:
eslint:
specifier: ^8.55.0
version: 8.57.1
eslint-config-prettier:
specifier: ^10.1.8
version: 10.1.8(eslint@8.57.1)
eslint-plugin-vue:
specifier: ^9.19.2
version: 9.33.0(eslint@8.57.1)
postcss:
specifier: ^8.4.33
version: 8.5.6
prettier:
specifier: ^3.6.2
version: 3.6.2
tailwindcss:
specifier: ^3.4.1
version: 3.4.18
@ -720,6 +726,12 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
eslint-config-prettier@10.1.8:
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
eslint-plugin-vue@9.33.0:
resolution: {integrity: sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==}
engines: {node: ^14.17.0 || >=16.0.0}
@ -1134,6 +1146,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@3.6.2:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
engines: {node: '>=14'}
hasBin: true
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@ -1980,6 +1997,10 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@8.57.1):
dependencies:
eslint: 8.57.1
eslint-plugin-vue@9.33.0(eslint@8.57.1):
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1)
@ -2391,6 +2412,8 @@ snapshots:
prelude-ls@1.2.1: {}
prettier@3.6.2: {}
punycode@2.3.1: {}
queue-microtask@1.2.3: {}

View File

@ -18,11 +18,15 @@ onMounted(() => {
})
// Save servers to localStorage whenever they change
watch(servers, (newServers) => {
if (newServers.length > 0) {
localStorage.setItem('vaultServers', JSON.stringify(newServers))
}
}, { deep: true })
watch(
servers,
newServers => {
if (newServers.length > 0) {
localStorage.setItem('vaultServers', JSON.stringify(newServers))
}
},
{ deep: true }
)
const handleAddServer = (server: VaultServer) => {
servers.value = [...servers.value, server]
@ -47,10 +51,7 @@ const handleLogin = async (credentials: VaultCredentials, shouldSaveCredentials:
try {
// Verify login and get mount points
const { vaultApi } = await import('./services/vaultApi')
const mountPoints = await vaultApi.verifyLoginAndGetMounts(
selectedServer.value,
credentials
)
const mountPoints = await vaultApi.verifyLoginAndGetMounts(selectedServer.value, credentials)
activeConnection.value = {
server: selectedServer.value,
@ -79,10 +80,7 @@ const handleLogin = async (credentials: VaultCredentials, shouldSaveCredentials:
console.log(`✓ Logged in successfully. Found ${mountPoints.length} KV mount point(s).`)
} catch (error) {
console.error('Login failed:', error)
alert(
`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}\n\n` +
'Please check your credentials and server configuration.'
)
alert(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}\n\n` + 'Please check your credentials and server configuration.')
}
}
@ -96,7 +94,10 @@ const handleLogout = () => {
<!-- Header -->
<header class="bg-gradient-to-r from-primary to-secondary text-primary-content shadow-lg">
<div class="container mx-auto px-4 py-6">
<h1 class="text-4xl font-bold mb-2">🔐 Browser Vault GUI</h1>
<h1 class="text-4xl font-bold mb-2 flex items-center gap-3">
<i class="mdi mdi-shield-lock text-primary-content" />
Browser Vault GUI
</h1>
<p class="text-lg opacity-90">Alternative frontend for HashiCorp Vault</p>
</div>
</header>
@ -117,27 +118,17 @@ const handleLogout = () => {
<!-- Login Form -->
<div v-if="selectedServer">
<LoginForm
:server="selectedServer"
@login="handleLogin"
/>
<LoginForm :server="selectedServer" @login="handleLogin" />
</div>
</div>
<!-- Dashboard -->
<Dashboard
v-else
:connection="activeConnection"
@logout="handleLogout"
/>
<Dashboard v-else :connection="activeConnection" @logout="handleLogout" />
</main>
<!-- Footer -->
<footer class="bg-base-300 border-t border-base-content/10">
<div class="container mx-auto px-4 py-4 text-center text-sm opacity-70">
Browser Vault GUI - An alternative frontend for HashiCorp Vault
</div>
<div class="container mx-auto px-4 py-4 text-center text-sm opacity-70">Browser Vault GUI - An alternative frontend for HashiCorp Vault</div>
</footer>
</div>
</template>

View File

@ -47,11 +47,7 @@ const handleReadSecret = async (path?: string) => {
secretData.value = null
try {
const data = await vaultApi.readSecret(
props.connection.server,
props.connection.credentials,
pathToRead
)
const data = await vaultApi.readSecret(props.connection.server, props.connection.credentials, pathToRead)
if (data) {
secretData.value = data
@ -144,31 +140,28 @@ const handleViewSecret = () => {
<div class="flex flex-col md:flex-row justify-between gap-4">
<div>
<h2 class="text-2xl font-bold">Connected to {{ connection.server.name }}</h2>
<p class="text-sm font-mono opacity-70">{{ connection.server.url }}</p>
<p class="text-sm font-mono opacity-70">
{{ connection.server.url }}
</p>
<p class="text-sm opacity-60 mt-1">
Authenticated via {{ connection.credentials.authMethod }}
<span v-if="connection.lastConnected" class="italic">
Connected at {{ connection.lastConnected.toLocaleTimeString() }}
Connected at
{{ connection.lastConnected.toLocaleTimeString() }}
</span>
</p>
</div>
<div class="flex gap-2 flex-wrap">
<button
class="btn btn-primary btn-sm"
@click="showSearch = !showSearch"
>
{{ showSearch ? 'Hide Search' : '🔍 Show Search' }}
<button class="btn btn-primary btn-sm" @click="showSearch = !showSearch">
<i :class="showSearch ? 'mdi mdi-magnify-close' : 'mdi mdi-magnify'" class="mr-2" />
{{ showSearch ? 'Hide Search' : 'Show Search' }}
</button>
<button
class="btn btn-sm"
@click="showSettings = true"
>
Settings
<button class="btn btn-sm" @click="showSettings = true">
<i class="mdi mdi-cog mr-2" />
Settings
</button>
<button
class="btn btn-error btn-sm"
@click="emit('logout')"
>
<button class="btn btn-error btn-sm" @click="emit('logout')">
<i class="mdi mdi-logout mr-2" />
Logout
</button>
</div>
@ -195,19 +188,9 @@ const handleViewSecret = () => {
<label class="label">
<span class="label-text">Mount Point</span>
</label>
<select
v-model="selectedMountPoint"
class="select select-bordered w-full"
:disabled="isLoading"
>
<select v-model="selectedMountPoint" class="select select-bordered w-full" :disabled="isLoading">
<option value="">Select a mount point...</option>
<option
v-for="mount in connection.mountPoints"
:key="mount.path"
:value="mount.path"
>
{{ mount.path }}/ ({{ mount.type }} v2)
</option>
<option v-for="mount in connection.mountPoints" :key="mount.path" :value="mount.path">{{ mount.path }}/ ({{ mount.type }} v2)</option>
</select>
</div>
@ -217,9 +200,7 @@ const handleViewSecret = () => {
<span class="label-text">Secret Path</span>
</label>
<div class="join w-full">
<span class="join-item bg-base-300 px-3 py-2 text-sm font-mono border border-base-300">
{{ selectedMountPoint || 'mount' }}/
</span>
<span class="join-item bg-base-300 px-3 py-2 text-sm font-mono border border-base-300"> {{ selectedMountPoint || 'mount' }}/ </span>
<input
v-model="secretPath"
type="text"
@ -228,34 +209,34 @@ const handleViewSecret = () => {
:disabled="isLoading || !selectedMountPoint"
@keypress="handleKeyPress"
/>
<button
class="btn btn-primary join-item"
:disabled="!selectedMountPoint || !secretPath"
@click="handleViewSecret()"
>
<button class="btn btn-primary join-item" :disabled="!selectedMountPoint || !secretPath" @click="handleViewSecret()">
<i class="mdi mdi-eye mr-2" />
View Secret
</button>
</div>
<label class="label">
<span class="label-text-alt">
Full path: {{ selectedMountPoint ? `${selectedMountPoint}/${secretPath || 'path'}` : 'Select mount point first' }}
Full path:
{{ selectedMountPoint ? `${selectedMountPoint}/${secretPath || 'path'}` : 'Select mount point first' }}
</span>
</label>
</div>
<!-- Removed inline secret display - now using modal -->
<!-- Info Box -->
<div v-if="!showSearch" class="alert alert-info mt-6">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-sm">
<h4 class="font-bold">Browse Secrets</h4>
<ul class="list-disc list-inside mt-2 space-y-1">
<li>Select a mount point from the detected KV secret engines</li>
<li>Enter the secret path (without the mount point prefix)</li>
<li>Example: Mount <code class="bg-base-200 px-1 rounded">secret</code> + Path <code class="bg-base-200 px-1 rounded">data/myapp/config</code></li>
<li>
Example: Mount
<code class="bg-base-200 px-1 rounded">secret</code> + Path
<code class="bg-base-200 px-1 rounded">data/myapp/config</code>
</li>
<li>Use Search (shown above) to find secrets across all mount points</li>
<li><strong>Security:</strong> Secret data is never cached - always fetched fresh</li>
</ul>
@ -265,23 +246,35 @@ const handleViewSecret = () => {
<!-- API Info -->
<div class="alert mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<div class="text-xs">
<h4 class="font-semibold">Security & Caching</h4>
<p class="mt-1">🔒 <strong>Secret data is NEVER cached</strong> - always fetched fresh for security.</p>
<p class="mt-1">📂 Directory listings are cached to improve search performance.</p>
<p class="mt-1">🔑 All requests include the <code class="bg-base-200 px-1 rounded">X-Vault-Token</code> header for authentication.</p>
<p class="mt-1 flex items-center gap-2">
<i class="mdi mdi-lock text-success" />
<strong>Secret data is NEVER cached</strong> - always fetched fresh for security.
</p>
<p class="mt-1 flex items-center gap-2">
<i class="mdi mdi-folder text-info" />
Directory listings are cached to improve search performance.
</p>
<p class="mt-1 flex items-center gap-2">
<i class="mdi mdi-key text-warning" />
All requests include the
<code class="bg-base-200 px-1 rounded">X-Vault-Token</code> header for authentication.
</p>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<Settings
v-if="showSettings"
@close="showSettings = false"
/>
<Settings v-if="showSettings" @close="showSettings = false" />
<!-- Secret Viewer Modal -->
<SecretModal
@ -293,4 +286,3 @@ const handleViewSecret = () => {
/>
</div>
</template>

View File

@ -42,10 +42,14 @@ const loadCredentialsFromServer = (server: VaultServer) => {
loadCredentialsFromServer(props.server)
// Watch for server changes and reload credentials
watch(() => props.server, (newServer) => {
loadCredentialsFromServer(newServer)
showSecurityWarning.value = false // Close any open warning modal
}, { immediate: false })
watch(
() => props.server,
newServer => {
loadCredentialsFromServer(newServer)
showSecurityWarning.value = false // Close any open warning modal
},
{ immediate: false }
)
const handleSubmit = async () => {
// Show warning if user is trying to save credentials for the first time
@ -95,20 +99,19 @@ const cancelSaveCredentials = () => {
<!-- Header -->
<div class="mb-4">
<h2 class="card-title text-2xl mb-2">Connect to {{ server.name }}</h2>
<p class="text-sm font-mono opacity-70">{{ server.url }}</p>
<p class="text-sm font-mono opacity-70">
{{ server.url }}
</p>
</div>
<!-- Form -->
<form @submit.prevent="handleSubmit" class="space-y-4">
<form class="space-y-4" @submit.prevent="handleSubmit">
<!-- Auth Method -->
<div class="form-control">
<label class="label">
<span class="label-text">Authentication Method</span>
</label>
<select
v-model="authMethod"
class="select select-bordered w-full"
>
<select v-model="authMethod" class="select select-bordered w-full">
<option value="token">Token</option>
<option value="userpass">Username & Password</option>
<option value="ldap">LDAP</option>
@ -167,29 +170,21 @@ const cancelSaveCredentials = () => {
<!-- Save Credentials Option -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
v-model="saveCredentials"
type="checkbox"
class="checkbox checkbox-warning"
/>
<input v-model="saveCredentials" type="checkbox" class="checkbox checkbox-warning" />
<span class="label-text">
<span class="font-semibold text-warning"> Save credentials locally</span>
<span class="font-semibold text-warning flex items-center gap-2">
<i class="mdi mdi-alert text-warning" />
Save credentials locally
</span>
</span>
</label>
<label class="label">
<span class="label-text-alt text-warning">
Not recommended! Credentials will be stored in plain text in localStorage
</span>
<span class="label-text-alt text-warning"> Not recommended! Credentials will be stored in plain text in localStorage </span>
</label>
</div>
<!-- Submit Button -->
<button
type="submit"
class="btn btn-primary w-full"
:class="{ 'loading': isLoading }"
:disabled="isLoading"
>
<button type="submit" class="btn btn-primary w-full" :class="{ loading: isLoading }" :disabled="isLoading">
{{ isLoading ? 'Connecting...' : 'Connect' }}
</button>
</form>
@ -197,11 +192,19 @@ const cancelSaveCredentials = () => {
<!-- Security Warning Modal -->
<div v-if="showSecurityWarning" class="modal modal-open">
<div class="modal-box border-2 border-error">
<h3 class="font-bold text-lg text-error mb-4"> Security Warning</h3>
<h3 class="font-bold text-lg text-error mb-4 flex items-center gap-2">
<i class="mdi mdi-alert-circle text-error" />
Security Warning
</h3>
<div class="space-y-4">
<div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="font-semibold">This is NOT recommended for security reasons!</span>
</div>
@ -209,7 +212,10 @@ const cancelSaveCredentials = () => {
<div class="text-sm space-y-2">
<p class="font-semibold">If you save credentials:</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>Your token/password will be stored in <strong>plain text</strong></li>
<li>
Your token/password will be stored in
<strong>plain text</strong>
</li>
<li>Anyone with access to your browser can read them</li>
<li>Browser extensions can access localStorage</li>
<li>If your computer is compromised, credentials are exposed</li>
@ -226,28 +232,18 @@ const cancelSaveCredentials = () => {
<div class="bg-base-300 p-3 rounded text-xs">
<p class="font-mono">
<strong>Better alternatives:</strong><br>
Use short-lived tokens<br>
Re-login each session<br>
Use a password manager<br>
<strong>Better alternatives:</strong><br />
Use short-lived tokens<br />
Re-login each session<br />
Use a password manager<br />
Enable auto-logout timeout
</p>
</div>
</div>
<div class="modal-action">
<button
class="btn btn-ghost"
@click="cancelSaveCredentials"
>
Cancel - Don't Save
</button>
<button
class="btn btn-error"
@click="confirmSaveCredentials"
>
I Understand the Risks - Save Anyway
</button>
<button class="btn btn-ghost" @click="cancelSaveCredentials">Cancel</button>
<button class="btn btn-error" @click="confirmSaveCredentials">I Understand the Risks - Save Anyway</button>
</div>
</div>
</div>
@ -255,14 +251,19 @@ const cancelSaveCredentials = () => {
<!-- Security Notice -->
<div class="alert mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-xs">
<p class="font-semibold"> Security Notice:</p>
<p>This application connects directly to your Vault server. Credentials are not stored permanently and are only kept in memory during your session.</p>
<p class="font-semibold flex items-center gap-2">
<i class="mdi mdi-shield-alert text-warning" />
Security Notice:
</p>
<p>
This application connects directly to your Vault server. Credentials are not stored permanently and are only kept in memory during your
session.
</p>
</div>
</div>
</div>
</div>
</template>

View File

@ -45,16 +45,17 @@ const handleSearch = async () => {
try {
// Always search across all mount points
const searchResults = await vaultApi.searchAllMounts(
props.server,
props.credentials,
props.mountPoints!,
searchTerm.value
)
const searchResults = await vaultApi.searchAllMounts(props.server, props.credentials, props.mountPoints!, searchTerm.value)
const endTime = performance.now()
searchTime.value = endTime - startTime
results.value = searchResults
if (results.value.filter(r => !r.isDirectory).length == 1) {
emit('selectPath', results.value.filter(r => !r.isDirectory)[0].path)
}
console.log(results.value.length)
} catch (error) {
console.error('Search error:', error)
alert('Search failed. Check console for details.')
@ -73,24 +74,19 @@ const handleKeyPress = (event: KeyboardEvent) => {
<template>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="text-xl font-bold mb-4">🔍 Search Paths</h3>
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
<i class="mdi mdi-magnify text-primary" />
Search Paths
</h3>
<!-- Search Controls -->
<div class="space-y-4">
<!-- Search Info -->
<div class="alert alert-info">
<div v-if="!mountPointsAvailable" class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-sm">
<p class="font-semibold">🌐 Searching across all mount points</p>
<p v-if="mountPointsAvailable">
Found {{ mountPoints?.length }} KV mount point(s): {{ mountPoints?.map(m => m.path).join(', ') }}
</p>
<p v-else class="text-error">
No mount points detected - logout and login again to refresh
</p>
</div>
<p class="text-sm text-error">No mount points detected - logout and login again to refresh</p>
</div>
<!-- Search Term -->
@ -107,35 +103,31 @@ const handleKeyPress = (event: KeyboardEvent) => {
:disabled="isSearching"
@keypress="handleKeyPress"
/>
<button
class="btn btn-primary join-item"
:class="{ 'loading': isSearching }"
:disabled="isSearching"
@click="handleSearch"
>
<button class="btn btn-primary join-item" :class="{ loading: isSearching }" :disabled="isSearching" @click="handleSearch">
<i v-if="!isSearching" class="mdi mdi-magnify mr-2" />
{{ isSearching ? 'Searching...' : 'Search' }}
</button>
</div>
</div>
</div>
<!-- Search Progress -->
<div v-if="isSearching" class="alert mt-4">
<span class="loading loading-spinner"></span>
<span>Searching recursively... This may take a moment.</span>
</div>
<!-- Search Stats -->
<div v-if="searchTime !== null" class="alert alert-success mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
Found <strong>{{ results.length }}</strong> result{{ results.length !== 1 ? 's' : '' }}
in <strong>{{ (searchTime / 1000).toFixed(2) }}s</strong>
Found <strong>{{ results.length }}</strong> result{{ results.length !== 1 ? 's' : '' }} in
<strong>{{ (searchTime / 1000).toFixed(2) }}s</strong>
</span>
</div>
<!-- Search Progress -->
<div v-if="isSearching" class="alert mt-4">
<span class="loading loading-spinner" />
<span>Searching recursively... This may take a moment.</span>
</div>
<!-- Search Results -->
<div v-if="results.length > 0" class="mt-4">
<h4 class="font-semibold mb-3">Search Results</h4>
@ -148,19 +140,19 @@ const handleKeyPress = (event: KeyboardEvent) => {
@click="!result.isDirectory && emit('selectPath', result.path)"
>
<div class="card-body p-3 flex flex-row items-center gap-3">
<span class="text-2xl">{{ result.isDirectory ? '📁' : '📄' }}</span>
<i :class="result.isDirectory ? 'mdi mdi-folder text-warning' : 'mdi mdi-file-document text-info'" class="text-xl" />
<div class="flex-1 min-w-0">
<p class="font-mono text-sm break-all">{{ result.path }}</p>
<p v-if="result.mountPoint" class="text-xs opacity-60 italic">
📌 {{ result.mountPoint }}
<p class="font-mono text-sm break-all">
{{ result.path }}
</p>
<p v-if="result.mountPoint" class="text-xs opacity-60 italic flex items-center gap-1">
<i class="mdi mdi-pin text-xs" />
{{ result.mountPoint }}
</p>
</div>
<span class="badge badge-sm">Depth: {{ result.depth }}</span>
<button
v-if="!result.isDirectory"
class="btn btn-primary btn-xs"
@click.stop="emit('selectPath', result.path)"
>
<button v-if="!result.isDirectory" class="btn btn-primary btn-xs" @click.stop="emit('selectPath', result.path)">
<i class="mdi mdi-eye mr-1" />
View
</button>
</div>
@ -171,7 +163,12 @@ const handleKeyPress = (event: KeyboardEvent) => {
<!-- No Results -->
<div v-if="!isSearching && results.length === 0 && searchTime !== null" class="alert mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div>
<p>No results found for "{{ searchTerm }}" across all mount points</p>
@ -180,17 +177,22 @@ const handleKeyPress = (event: KeyboardEvent) => {
</div>
<!-- Search Tips -->
<div class="alert alert-info mt-4">
<div class="alert mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-sm">
<h4 class="font-bold"> Search Tips</h4>
<h4 class="font-bold flex items-center gap-2">
<i class="mdi mdi-information text-info" />
Search Tips
</h4>
<ul class="list-disc list-inside mt-2 space-y-1">
<li>Search is case-insensitive and matches partial paths</li>
<li>Searches across all detected KV secret engines automatically</li>
<li>Directory listings are cached to improve performance</li>
<li>Directories are marked with 📁, secrets with 📄</li>
<li>Search is case-insensitive and matches partial paths. Search is cached to improve performance</li>
<li>
Directories are marked with
<i class="mdi mdi-folder text-warning" />, secrets with
<i class="mdi mdi-file-document text-info" />
</li>
<li>Click "View" on secrets to open detailed modal with metadata</li>
<li>Maximum search depth and results can be configured in settings</li>
</ul>
@ -199,4 +201,3 @@ const handleKeyPress = (event: KeyboardEvent) => {
</div>
</div>
</template>

View File

@ -1,49 +1,54 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import type { VaultServer, VaultCredentials } from "../types";
import { vaultApi, VaultError } from "../services/vaultApi";
import { ref, onMounted, computed } from 'vue'
import type { VaultServer, VaultCredentials } from '../types'
import { vaultApi, VaultError } from '../services/vaultApi'
interface Props {
server: VaultServer;
credentials: VaultCredentials;
secretPath: string;
server: VaultServer
credentials: VaultCredentials
secretPath: string
}
const props = defineProps<Props>();
const props = defineProps<Props>()
const emit = defineEmits<{
close: [];
}>();
close: []
}>()
const secretData = ref<Record<string, unknown> | null>(null);
const secretMetadata = ref<any>(null);
const secretVersions = ref<any[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"current" | "json" | "metadata" | "versions">("current");
const visibleValues = ref<Record<string, boolean>>({});
const secretData = ref<Record<string, unknown> | null>(null)
const secretVersion = ref<number>(null)
const secretMetadata = ref<any>(null)
const secretVersions = ref<any[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const activeTab = ref<'current' | 'json' | 'metadata' | 'versions'>('current')
const visibleValues = ref<Record<string, boolean>>({})
onMounted(() => {
loadSecret();
});
loadSecret()
})
const isLatestVersion = computed<boolean>(() => {
if (!secretVersion.value) {
return true
}
if (!secretMetadata.value.current_version) {
return false
}
return secretMetadata.value.current_version == secretVersion.value
})
const loadSecret = async () => {
isLoading.value = true;
error.value = null;
isLoading.value = true
error.value = null
try {
// Load current secret data
const response = await vaultApi.readSecret(
props.server,
props.credentials,
props.secretPath,
);
console.log("Secret response structure:", response);
const response = await vaultApi.readSecret(props.server, props.credentials, props.secretPath)
// For KV v2, the response includes both data and metadata
if (response && typeof response === "object") {
if (response && typeof response === 'object') {
// Extract secret data (usually under 'data' key)
secretData.value = response.data || response;
secretData.value = response.data || response
// Extract metadata if present in the response
if (response.metadata) {
@ -56,60 +61,57 @@ const loadSecret = async () => {
destroyed: response.metadata.destroyed,
deletion_time: response.metadata.deletion_time,
custom_metadata: response.metadata.custom_metadata,
};
}
secretVersion.value = response.metadata.version
// Create a single version entry from the current metadata
if (response.metadata.version) {
secretVersions.value = [
{
version: response.metadata.version,
created_time: new Date(
response.metadata.created_time,
).toLocaleString(),
created_time: new Date(response.metadata.created_time).toLocaleString(),
destroyed: response.metadata.destroyed,
deletion_time: response.metadata.deletion_time,
},
];
]
}
}
} else {
secretData.value = response;
secretData.value = response
secretVersion.value = null
}
// Try to load full metadata and version history from metadata endpoint
await loadMetadataAndVersions();
await loadMetadataAndVersions()
} catch (err) {
console.error("Error loading secret:", err);
console.error('Error loading secret:', err)
if (err instanceof VaultError) {
error.value = `${err.message} (HTTP ${err.statusCode || "Unknown"})`;
error.value = `${err.message} (HTTP ${err.statusCode || 'Unknown'})`
if (err.errors && err.errors.length > 0) {
error.value += `\n\nDetails:\n${err.errors.join("\n")}`;
error.value += `\n\nDetails:\n${err.errors.join('\n')}`
}
} else {
error.value = err instanceof Error ? err.message : "Unknown error";
error.value = err instanceof Error ? err.message : 'Unknown error'
}
} finally {
isLoading.value = false;
isLoading.value = false
}
};
}
const loadMetadataAndVersions = async () => {
try {
// Use the dedicated readSecretMetadata method from VaultApi
const fullMetadata = await vaultApi.readSecretMetadata(
props.server,
props.credentials,
props.secretPath,
);
const fullMetadata = await vaultApi.readSecretMetadata(props.server, props.credentials, props.secretPath)
if (fullMetadata) {
console.log("Full metadata response:", fullMetadata);
console.log('Full metadata response:', fullMetadata)
// Merge with existing metadata or replace it
secretMetadata.value = {
...secretMetadata.value, // Keep any metadata from the secret response
...fullMetadata, // Override with full metadata
};
}
// Extract complete version history from full metadata
if (fullMetadata.versions) {
@ -119,97 +121,81 @@ const loadMetadataAndVersions = async () => {
...versionData,
created_time: new Date(versionData.created_time).toLocaleString(),
}))
.sort((a, b) => b.version - a.version); // Latest first
.sort((a, b) => b.version - a.version) // Latest first
} else if (secretMetadata.value?.current_version) {
// Fallback: if no versions array but we have current version info
secretVersions.value = [
{
version: secretMetadata.value.current_version,
created_time: secretMetadata.value.created_time
? new Date(secretMetadata.value.created_time).toLocaleString()
: "Unknown",
created_time: secretMetadata.value.created_time ? new Date(secretMetadata.value.created_time).toLocaleString() : 'Unknown',
destroyed: secretMetadata.value.destroyed || false,
deletion_time: secretMetadata.value.deletion_time,
},
];
]
}
}
} catch (err) {
console.warn(
"Could not load full metadata (using basic metadata from secret response):",
err,
);
console.warn('Could not load full metadata (using basic metadata from secret response):', err)
// If we can't load full metadata, we'll use what we extracted from the secret response
}
};
}
const loadVersion = async (version: number) => {
isLoading.value = true;
error.value = null;
isLoading.value = true
error.value = null
try {
// For KV v2, append ?version=X to get specific version
const versionPath = `${props.secretPath}?version=${version}`;
const data = await vaultApi.readSecret(
props.server,
props.credentials,
versionPath,
);
secretData.value = data;
activeTab.value = "current";
const versionPath = `${props.secretPath}?version=${version}`
const data = await vaultApi.readSecret(props.server, props.credentials, versionPath)
secretData.value = data
secretVersion.value = version
activeTab.value = 'current'
} catch (err) {
console.error("Error loading version:", err);
error.value = err instanceof Error ? err.message : "Unknown error";
console.error('Error loading version:', err)
error.value = err instanceof Error ? err.message : 'Unknown error'
} finally {
isLoading.value = false;
isLoading.value = false
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
};
}
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
await navigator.clipboard.writeText(text)
// Could add a toast notification here
} catch (err) {
console.error("Failed to copy:", err);
console.error('Failed to copy:', err)
}
};
}
const toggleValueVisibility = (key: string) => {
visibleValues.value[key] = !visibleValues.value[key];
};
visibleValues.value[key] = !visibleValues.value[key]
}
const isValueVisible = (key: string): boolean => {
return visibleValues.value[key] || false;
};
return visibleValues.value[key] || false
}
const maskValue = (value: string): string => {
return "•".repeat(Math.min(value.length, 12));
};
return '•'.repeat(Math.min(value.length, 12))
}
const getDisplayValue = (key: string, value: unknown): string => {
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
return isValueVisible(key) ? stringValue : maskValue(stringValue);
};
const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
return isValueVisible(key) ? stringValue : maskValue(stringValue)
}
const toggleAllValues = () => {
if (!secretData.value) return;
if (!secretData.value) return
// Check if any values are currently visible
const hasVisibleValues = Object.values(visibleValues.value).some((v) => v);
const hasVisibleValues = Object.values(visibleValues.value).some(v => v)
// Set all keys to the opposite state
Object.keys(secretData.value).forEach((key) => {
visibleValues.value[key] = !hasVisibleValues;
});
};
Object.keys(secretData.value).forEach(key => {
visibleValues.value[key] = !hasVisibleValues
})
}
</script>
<template>
@ -219,23 +205,23 @@ const toggleAllValues = () => {
<!-- Header -->
<div class="flex justify-between items-start mb-4 flex-shrink-0">
<div class="flex-1 min-w-0">
<h2 class="text-xl font-bold truncate">🔐 Secret Viewer</h2>
<p class="text-sm font-mono opacity-70 truncate mt-1">
<h2 class="text-xl font-bold truncate flex items-center gap-2">
<i class="mdi mdi-lock text-primary" />
{{ secretPath }}
</p>
<span v-if="isLatestVersion" class="badge badge-success">Latest</span>
<span v-else class="badge badge-warning">Version: {{ secretVersion }}</span>
</h2>
<div v-if="!isLatestVersion" class="alert alert-warning mt-4">This is not the latest version of this secret!</div>
</div>
<button
class="btn btn-sm btn-circle btn-ghost ml-4"
@click="emit('close')"
>
<button class="btn btn-sm btn-circle btn-ghost ml-4" @click="emit('close')">
<i class="mdi mdi-close" />
</button>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex-1 flex items-center justify-center">
<div class="text-center">
<span class="loading loading-spinner loading-lg"></span>
<span class="loading loading-spinner loading-lg" />
<p class="mt-4">Loading secret...</p>
</div>
</div>
@ -243,12 +229,7 @@ const toggleAllValues = () => {
<!-- Error State -->
<div v-else-if="error" class="flex-1">
<div class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -267,35 +248,21 @@ const toggleAllValues = () => {
<div v-else class="flex-1 flex flex-col overflow-hidden">
<!-- Tabs -->
<div class="tabs tabs-bordered mb-4 flex-shrink-0">
<button
class="tab"
:class="{ 'tab-active': activeTab === 'current' }"
@click="activeTab = 'current'"
>
📄 Current Data
<button class="tab" :class="{ 'tab-active': activeTab === 'current' }" @click="activeTab = 'current'">
<i class="mdi mdi-table mr-2" />
Current Data
</button>
<button
class="tab"
:class="{ 'tab-active': activeTab === 'json' }"
@click="activeTab = 'json'"
>
📋 JSON Data
<button class="tab" :class="{ 'tab-active': activeTab === 'json' }" @click="activeTab = 'json'">
<i class="mdi mdi-code-json mr-2" />
JSON Data
</button>
<button
v-if="secretMetadata"
class="tab"
:class="{ 'tab-active': activeTab === 'metadata' }"
@click="activeTab = 'metadata'"
>
Metadata
<button v-if="secretMetadata" class="tab" :class="{ 'tab-active': activeTab === 'metadata' }" @click="activeTab = 'metadata'">
<i class="mdi mdi-information mr-2" />
Metadata
</button>
<button
v-if="secretVersions.length > 0"
class="tab"
:class="{ 'tab-active': activeTab === 'versions' }"
@click="activeTab = 'versions'"
>
🕒 Versions ({{ secretVersions.length }})
<button v-if="secretVersions.length > 0" class="tab" :class="{ 'tab-active': activeTab === 'versions' }" @click="activeTab = 'versions'">
<i class="mdi mdi-history mr-2" />
Versions ({{ secretVersions.length }})
</button>
</div>
@ -307,19 +274,13 @@ const toggleAllValues = () => {
<h3 class="font-semibold">Secret Data</h3>
<div class="flex gap-2">
<button class="btn btn-sm btn-outline" @click="toggleAllValues">
{{
Object.values(visibleValues).some((v) => v)
? "🙈 Hide All"
: "👁️ Show All"
}}
<i :class="Object.values(visibleValues).some(v => v) ? 'mdi mdi-eye-off' : 'mdi mdi-eye'" class="mr-2" />
{{ Object.values(visibleValues).some(v => v) ? 'Hide All' : 'Show All' }}
</button>
</div>
</div>
<div class="flex-1 overflow-auto">
<div
v-if="secretData && Object.keys(secretData).length > 0"
class="overflow-x-auto"
>
<div v-if="secretData && Object.keys(secretData).length > 0" class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
@ -329,39 +290,28 @@ const toggleAllValues = () => {
</tr>
</thead>
<tbody>
<tr
v-for="[key, value] in Object.entries(secretData)"
:key="key"
>
<td class="font-mono font-semibold">{{ key }}</td>
<tr v-for="[key, value] in Object.entries(secretData)" :key="key">
<td class="font-mono font-semibold">
{{ key }}
</td>
<td class="font-mono text-sm">
<span class="select-all">{{
getDisplayValue(key, value)
}}</span>
<span class="select-all">{{ getDisplayValue(key, value) }}</span>
</td>
<td>
<div class="flex gap-1">
<button
class="btn btn-xs btn-ghost"
:title="
isValueVisible(key) ? 'Hide value' : 'Show value'
"
class="btn btn-ghost"
:title="isValueVisible(key) ? 'Hide value' : 'Show value'"
@click="toggleValueVisibility(key)"
>
{{ isValueVisible(key) ? "🙈" : "👁️" }}
<i :class="isValueVisible(key) ? 'mdi mdi-eye-off' : 'mdi mdi-eye'" />
</button>
<button
class="btn btn-xs btn-ghost"
class="btn btn-ghost"
title="Copy value"
@click="
copyToClipboard(
typeof value === 'string'
? value
: JSON.stringify(value),
)
"
@click="copyToClipboard(typeof value === 'string' ? value : JSON.stringify(value))"
>
📋
<i class="mdi mdi-content-copy" />
</button>
</div>
</td>
@ -369,10 +319,7 @@ const toggleAllValues = () => {
</tbody>
</table>
</div>
<div
v-else
class="flex items-center justify-center h-full text-base-content/60"
>
<div v-else class="flex items-center justify-center h-full text-base-content/60">
<p>No secret data available</p>
</div>
</div>
@ -382,26 +329,18 @@ const toggleAllValues = () => {
<div v-else-if="activeTab === 'json'" class="h-full flex flex-col">
<div class="flex justify-between items-center mb-3 flex-shrink-0">
<h3 class="font-semibold">JSON Data</h3>
<button
class="btn btn-sm btn-outline"
@click="copyToClipboard(JSON.stringify(secretData, null, 2))"
>
📋 Copy JSON
<button class="btn btn-sm btn-outline" @click="copyToClipboard(JSON.stringify(secretData, null, 2))">
<i class="mdi mdi-content-copy mr-2" />
Copy JSON
</button>
</div>
<div class="flex-1 overflow-auto">
<pre
class="bg-base-300 p-4 rounded-lg text-sm h-full overflow-auto"
>{{ JSON.stringify(secretData, null, 2) }}</pre
>
<pre class="bg-base-300 p-4 rounded-lg text-sm h-full overflow-auto">{{ JSON.stringify(secretData, null, 2) }}</pre>
</div>
</div>
<!-- Metadata Tab -->
<div
v-else-if="activeTab === 'metadata' && secretMetadata"
class="h-full flex flex-col"
>
<div v-else-if="activeTab === 'metadata' && secretMetadata" class="h-full flex flex-col">
<h3 class="font-semibold mb-3 flex-shrink-0">Secret Metadata</h3>
<div class="flex-1 overflow-auto">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
@ -411,35 +350,23 @@ const toggleAllValues = () => {
<div class="space-y-2 text-sm">
<div>
<strong>Current Version:</strong>
{{ secretMetadata.current_version || "N/A" }}
{{ secretMetadata.current_version || 'N/A' }}
</div>
<div>
<strong>Max Versions:</strong>
{{ secretMetadata.max_versions || "N/A" }}
{{ secretMetadata.max_versions || 'N/A' }}
</div>
<div>
<strong>Oldest Version:</strong>
{{ secretMetadata.oldest_version || "N/A" }}
{{ secretMetadata.oldest_version || 'N/A' }}
</div>
<div>
<strong>Created:</strong>
{{
secretMetadata.created_time
? new Date(
secretMetadata.created_time,
).toLocaleString()
: "N/A"
}}
{{ secretMetadata.created_time ? new Date(secretMetadata.created_time).toLocaleString() : 'N/A' }}
</div>
<div>
<strong>Updated:</strong>
{{
secretMetadata.updated_time
? new Date(
secretMetadata.updated_time,
).toLocaleString()
: "N/A"
}}
{{ secretMetadata.updated_time ? new Date(secretMetadata.updated_time).toLocaleString() : 'N/A' }}
</div>
</div>
</div>
@ -451,21 +378,15 @@ const toggleAllValues = () => {
<div class="space-y-2 text-sm">
<div>
<strong>Destroyed:</strong>
{{ secretMetadata.destroyed ? "Yes" : "No" }}
{{ secretMetadata.destroyed ? 'Yes' : 'No' }}
</div>
<div>
<strong>Delete Version After:</strong>
{{ secretMetadata.delete_version_after || "Never" }}
{{ secretMetadata.delete_version_after || 'Never' }}
</div>
<div v-if="secretMetadata.custom_metadata">
<strong>Custom Metadata:</strong>
<pre class="text-xs mt-1 bg-base-300 p-2 rounded">{{
JSON.stringify(
secretMetadata.custom_metadata,
null,
2,
)
}}</pre>
<pre class="text-xs mt-1 bg-base-300 p-2 rounded">{{ JSON.stringify(secretMetadata.custom_metadata, null, 2) }}</pre>
</div>
</div>
</div>
@ -475,64 +396,32 @@ const toggleAllValues = () => {
<div class="card bg-base-200">
<div class="card-body p-4">
<h4 class="font-semibold text-sm mb-2">Raw Metadata</h4>
<pre class="bg-base-300 p-4 rounded text-xs overflow-auto">{{
JSON.stringify(secretMetadata, null, 2)
}}</pre>
<pre class="bg-base-300 p-4 rounded text-xs overflow-auto">{{ JSON.stringify(secretMetadata, null, 2) }}</pre>
</div>
</div>
</div>
</div>
<!-- Versions Tab -->
<div
v-else-if="activeTab === 'versions'"
class="h-full flex flex-col"
>
<div v-else-if="activeTab === 'versions'" class="h-full flex flex-col">
<h3 class="font-semibold mb-3 flex-shrink-0">Version History</h3>
<div class="flex-1 overflow-auto">
<div class="space-y-2">
<div
v-for="version in secretVersions"
:key="version.version"
class="card bg-base-200 hover:bg-base-300 transition-colors"
>
<div
class="card-body p-4 flex flex-row items-center justify-between"
>
<div v-for="version in secretVersions" :key="version.version" class="card bg-base-200 hover:bg-base-300 transition-colors">
<div class="card-body p-4 flex flex-row items-center justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="badge badge-primary"
>v{{ version.version }}</span
>
<span
v-if="
version.version === secretMetadata?.current_version
"
class="badge badge-success"
>Current</span
>
<span v-if="version.destroyed" class="badge badge-error"
>Destroyed</span
>
<span class="badge badge-primary">v{{ version.version }}</span>
<span v-if="version.version === secretMetadata?.current_version" class="badge badge-success">Current</span>
<span v-if="version.destroyed" class="badge badge-error">Destroyed</span>
</div>
<p class="text-sm opacity-70">
Created: {{ version.created_time }}
</p>
<p
v-if="version.deletion_time"
class="text-sm opacity-70"
>
<p class="text-sm opacity-70">Created: {{ version.created_time }}</p>
<p v-if="version.deletion_time" class="text-sm opacity-70">
Deleted:
{{ new Date(version.deletion_time).toLocaleString() }}
</p>
</div>
<button
v-if="!version.destroyed"
class="btn btn-sm btn-primary"
@click="loadVersion(version.version)"
>
View Version
</button>
<button v-if="!version.destroyed" class="btn btn-sm btn-primary" @click="loadVersion(version.version)">View Version</button>
</div>
</div>
</div>
@ -544,8 +433,8 @@ const toggleAllValues = () => {
<!-- Footer -->
<div class="modal-action flex-shrink-0">
<div class="flex-1 text-xs opacity-70">
<p>🔒 Secret data is never cached - always fetched fresh</p>
<p>📊 KV v2: Metadata and version history available</p>
<p><i class="mdi mdi-lock mr-1" />Secret data is never cached - always fetched fresh</p>
<p><i class="mdi mdi-chart-line mr-1" />KV v2: Metadata and version history available</p>
</div>
<button class="btn" @click="emit('close')">Close</button>
</div>

View File

@ -49,60 +49,39 @@ const handleRemove = (serverId: string, serverName: string) => {
<!-- Header -->
<div class="flex justify-between items-center mb-4">
<h2 class="card-title text-2xl">Vault Servers</h2>
<button
class="btn btn-primary btn-sm"
@click="showAddForm = !showAddForm"
>
{{ showAddForm ? 'Cancel' : '+ Add Server' }}
<button class="btn btn-primary btn-sm" @click="showAddForm = !showAddForm">
<i :class="showAddForm ? 'mdi mdi-close' : 'mdi mdi-plus'" class="mr-2" />
{{ showAddForm ? 'Cancel' : 'Add Server' }}
</button>
</div>
<!-- Add Server Form -->
<div v-if="showAddForm" class="bg-base-200 p-4 rounded-lg mb-4">
<form @submit.prevent="handleSubmit" class="space-y-4">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="form-control">
<label class="label">
<span class="label-text">Server Name *</span>
</label>
<input
v-model="newServer.name"
type="text"
placeholder="Production Vault"
class="input input-bordered w-full"
required
/>
<input v-model="newServer.name" type="text" placeholder="Production Vault" class="input input-bordered w-full" required />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Server URL *</span>
</label>
<input
v-model="newServer.url"
type="url"
placeholder="https://vault.example.com"
class="input input-bordered w-full"
required
/>
<input v-model="newServer.url" type="url" placeholder="https://vault.example.com" class="input input-bordered w-full" required />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<input
v-model="newServer.description"
type="text"
placeholder="Optional description"
class="input input-bordered w-full"
/>
<input v-model="newServer.description" type="text" placeholder="Optional description" class="input input-bordered w-full" />
</div>
<!-- KV v2 is enforced - no version selection needed -->
<button type="submit" class="btn btn-success w-full">
Add Server
</button>
<button type="submit" class="btn btn-success w-full">Add Server</button>
</form>
</div>
@ -122,28 +101,27 @@ const handleRemove = (serverId: string, serverName: string) => {
>
<div class="card-body p-4 flex flex-row justify-between items-center">
<div class="flex-1">
<h3 class="font-bold text-lg">{{ server.name }}</h3>
<p class="text-sm font-mono opacity-70 mt-1">{{ server.url }}</p>
<h3 class="font-bold text-lg">
{{ server.name }}
</h3>
<p class="text-sm font-mono opacity-70 mt-1">
{{ server.url }}
</p>
<p v-if="server.description" class="text-sm italic opacity-60 mt-1">
{{ server.description }}
</p>
<div class="mt-2 flex gap-2 flex-wrap">
<span class="badge badge-sm badge-outline">KV v2</span>
<span v-if="server.savedCredentials" class="badge badge-sm badge-warning">
🔓 Saved Credentials
<i class="mdi mdi-lock-open mr-1" />
Saved Credentials
</span>
</div>
</div>
<button
class="btn btn-error btn-sm"
@click.stop="handleRemove(server.id, server.name)"
>
Remove
</button>
<button class="btn btn-error btn-sm" @click.stop="handleRemove(server.id, server.name)">Remove</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -56,19 +56,16 @@ const formatDate = (timestamp: number | null): string => {
<template>
<!-- Modal Overlay -->
<div
class="modal modal-open"
@click.self="emit('close')"
>
<div class="modal modal-open" @click.self="emit('close')">
<div class="modal-box max-w-3xl max-h-[90vh] overflow-y-auto">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold"> Settings</h2>
<button
class="btn btn-sm btn-circle btn-ghost"
@click="emit('close')"
>
<h2 class="text-2xl font-bold flex items-center gap-2">
<i class="mdi mdi-cog text-primary" />
Settings
</h2>
<button class="btn btn-sm btn-circle btn-ghost" @click="emit('close')">
<i class="mdi mdi-close" />
</button>
</div>
@ -79,11 +76,7 @@ const formatDate = (timestamp: number | null): string => {
<!-- Enable Cache -->
<div class="form-control mb-4">
<label class="label cursor-pointer justify-start gap-3">
<input
v-model="config.cache.enabled"
type="checkbox"
class="checkbox checkbox-primary"
/>
<input v-model="config.cache.enabled" type="checkbox" class="checkbox checkbox-primary" />
<span class="label-text">Enable cache</span>
</label>
<p class="text-sm opacity-70 ml-8">Cache API responses to reduce load on Vault server</p>
@ -94,13 +87,7 @@ const formatDate = (timestamp: number | null): string => {
<label class="label">
<span class="label-text">Maximum cache size (MB)</span>
</label>
<input
v-model.number="config.cache.maxSizeMB"
type="number"
min="1"
max="100"
class="input input-bordered w-full"
/>
<input v-model.number="config.cache.maxSizeMB" type="number" min="1" max="100" class="input input-bordered w-full" />
<label class="label">
<span class="label-text-alt">Maximum size of cached data in megabytes</span>
</label>
@ -132,27 +119,30 @@ const formatDate = (timestamp: number | null): string => {
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="opacity-70">Total Size:</p>
<p class="font-mono">{{ formatBytes(cacheStats.totalSize) }}</p>
<p class="font-mono">
{{ formatBytes(cacheStats.totalSize) }}
</p>
</div>
<div>
<p class="opacity-70">Entry Count:</p>
<p class="font-mono">{{ cacheStats.entryCount }}</p>
<p class="font-mono">
{{ cacheStats.entryCount }}
</p>
</div>
<div>
<p class="opacity-70">Oldest Entry:</p>
<p class="font-mono text-xs">{{ formatDate(cacheStats.oldestEntry) }}</p>
<p class="font-mono text-xs">
{{ formatDate(cacheStats.oldestEntry) }}
</p>
</div>
<div>
<p class="opacity-70">Newest Entry:</p>
<p class="font-mono text-xs">{{ formatDate(cacheStats.newestEntry) }}</p>
<p class="font-mono text-xs">
{{ formatDate(cacheStats.newestEntry) }}
</p>
</div>
</div>
<button
class="btn btn-error btn-sm mt-4"
@click="handleClearCache"
>
Clear Cache
</button>
<button class="btn btn-error btn-sm mt-4" @click="handleClearCache">Clear Cache</button>
</div>
</div>
</div>
@ -166,13 +156,7 @@ const formatDate = (timestamp: number | null): string => {
<label class="label">
<span class="label-text">Maximum search depth</span>
</label>
<input
v-model.number="config.search.maxDepth"
type="number"
min="1"
max="50"
class="input input-bordered w-full"
/>
<input v-model.number="config.search.maxDepth" type="number" min="1" max="50" class="input input-bordered w-full" />
<label class="label">
<span class="label-text-alt">Maximum recursion depth for path searches</span>
</label>
@ -183,13 +167,7 @@ const formatDate = (timestamp: number | null): string => {
<label class="label">
<span class="label-text">Maximum search results</span>
</label>
<input
v-model.number="config.search.maxResults"
type="number"
min="10"
max="10000"
class="input input-bordered w-full"
/>
<input v-model.number="config.search.maxResults" type="number" min="10" max="10000" class="input input-bordered w-full" />
<label class="label">
<span class="label-text-alt">Maximum number of results to return from a search</span>
</label>
@ -198,20 +176,9 @@ const formatDate = (timestamp: number | null): string => {
<!-- Footer Actions -->
<div class="modal-action">
<button
class="btn"
@click="emit('close')"
>
Cancel
</button>
<button
class="btn btn-success"
@click="handleSave"
>
Save Settings
</button>
<button class="btn" @click="emit('close')">Cancel</button>
<button class="btn btn-success" @click="handleSave">Save Settings</button>
</div>
</div>
</div>
</template>

View File

@ -1,14 +1,14 @@
// Application configuration
export interface AppConfig {
cache: {
maxSizeMB: number; // Maximum cache size in megabytes
maxAge: number; // Maximum age of cache entries in milliseconds
enabled: boolean;
};
maxSizeMB: number // Maximum cache size in megabytes
maxAge: number // Maximum age of cache entries in milliseconds
enabled: boolean
}
search: {
maxDepth: number; // Maximum recursion depth for path search
maxResults: number; // Maximum number of results to return
};
maxDepth: number // Maximum recursion depth for path search
maxResults: number // Maximum number of results to return
}
}
// Default configuration
@ -22,27 +22,26 @@ export const defaultConfig: AppConfig = {
maxDepth: 10,
maxResults: 1000,
},
};
}
// Load configuration from localStorage
export function loadConfig(): AppConfig {
try {
const saved = localStorage.getItem('vaultGuiConfig');
const saved = localStorage.getItem('vaultGuiConfig')
if (saved) {
return { ...defaultConfig, ...JSON.parse(saved) };
return { ...defaultConfig, ...JSON.parse(saved) }
}
} catch (error) {
console.error('Failed to load config:', error);
console.error('Failed to load config:', error)
}
return defaultConfig;
return defaultConfig
}
// Save configuration to localStorage
export function saveConfig(config: AppConfig): void {
try {
localStorage.setItem('vaultGuiConfig', JSON.stringify(config));
localStorage.setItem('vaultGuiConfig', JSON.stringify(config))
} catch (error) {
console.error('Failed to save config:', error);
console.error('Failed to save config:', error)
}
}

7
src/env.d.ts vendored
View File

@ -1,8 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@ -3,4 +3,3 @@ import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

View File

@ -1,13 +1,13 @@
import { VaultServer, VaultCredentials, MountPoint } from '../types';
import { vaultCache } from '../utils/cache';
import { loadConfig } from '../config';
import { VaultClient, VaultError } from './vaultClient';
import { VaultServer, VaultCredentials, MountPoint } from '../types'
import { vaultCache } from '../utils/cache'
import { loadConfig } from '../config'
import { VaultClient, VaultError } from './vaultClient'
export interface SearchResult {
path: string;
isDirectory: boolean;
depth: number;
mountPoint?: string;
path: string
isDirectory: boolean
depth: number
mountPoint?: string
}
/**
@ -20,97 +20,82 @@ class VaultApiService {
/**
* Create a VaultClient instance for the given server and credentials
*/
private createClient(
server: VaultServer,
credentials: VaultCredentials
): VaultClient {
private createClient(server: VaultServer, credentials: VaultCredentials): VaultClient {
return new VaultClient({
server,
credentials,
timeout: 30000,
retries: 2,
kvVersion: 2, // KV v2 is enforced
});
})
}
/**
* Generate a cache key for a given operation
*/
private getCacheKey(
server: VaultServer,
path: string,
operation: string
): string {
return `${server.id}:${operation}:${path}`;
private getCacheKey(server: VaultServer, path: string, operation: string): string {
return `${server.id}:${operation}:${path}`
}
/**
* List secrets at a given path with caching
*/
async listSecrets(
server: VaultServer,
credentials: VaultCredentials,
path: string
): Promise<string[]> {
const cacheKey = this.getCacheKey(server, path, 'list');
async listSecrets(server: VaultServer, credentials: VaultCredentials, path: string): Promise<string[]> {
const cacheKey = this.getCacheKey(server, path, 'list')
// Check cache first
const cached = vaultCache.get<string[]>(cacheKey);
const cached = vaultCache.get<string[]>(cacheKey)
if (cached) {
console.log(`✓ Cache hit for list: ${path}`);
return cached;
console.log(`✓ Cache hit for list: ${path}`)
return cached
}
console.log(`⚡ API call for list: ${path}`);
console.log(`⚡ API call for list: ${path}`)
try {
const client = this.createClient(server, credentials);
const keys = await client.list(path);
const client = this.createClient(server, credentials)
const keys = await client.list(path)
// Cache the result
vaultCache.set(cacheKey, keys);
vaultCache.set(cacheKey, keys)
return keys;
return keys
} catch (error) {
if (error instanceof VaultError) {
console.error(`Vault error listing ${path}:`, error.message);
console.error(`Vault error listing ${path}:`, error.message)
if (error.errors) {
console.error('Details:', error.errors);
console.error('Details:', error.errors)
}
} else {
console.error(`Error listing secrets at ${path}:`, error);
console.error(`Error listing secrets at ${path}:`, error)
}
return [];
return []
}
}
/**
* Read a secret from Vault (NO CACHING - secrets are never cached for security)
*/
async readSecret(
server: VaultServer,
credentials: VaultCredentials,
path: string
): Promise<Record<string, unknown> | null> {
console.log(`⚡ API call for read (no cache): ${path}`);
async readSecret(server: VaultServer, credentials: VaultCredentials, path: string): Promise<Record<string, unknown> | null> {
console.log(`⚡ API call for read (no cache): ${path}`)
try {
const client = this.createClient(server, credentials);
const secretData = await client.read<Record<string, unknown>>(path);
const client = this.createClient(server, credentials)
const secretData = await client.read<Record<string, unknown>>(path)
// SECURITY: Never cache secret data - always fetch fresh
return secretData;
return secretData
} catch (error) {
if (error instanceof VaultError) {
console.error(`Vault error reading ${path}:`, error.message);
console.error(`Vault error reading ${path}:`, error.message)
if (error.errors) {
console.error('Details:', error.errors);
console.error('Details:', error.errors)
}
// Re-throw to let the caller handle it
throw error;
throw error
} else {
console.error(`Error reading secret at ${path}:`, error);
throw new VaultError('Failed to read secret');
console.error(`Error reading secret at ${path}:`, error)
throw new VaultError('Failed to read secret')
}
}
}
@ -118,29 +103,25 @@ class VaultApiService {
/**
* Read metadata for a secret (KV v2 only)
*/
async readSecretMetadata(
server: VaultServer,
credentials: VaultCredentials,
path: string
): Promise<any> {
console.log(`⚡ API call for metadata (no cache): ${path}`);
async readSecretMetadata(server: VaultServer, credentials: VaultCredentials, path: string): Promise<any> {
console.log(`⚡ API call for metadata (no cache): ${path}`)
try {
const client = this.createClient(server, credentials);
const metadata = await client.readMetadata(path);
const client = this.createClient(server, credentials)
const metadata = await client.readMetadata(path)
return metadata;
return metadata
} catch (error) {
if (error instanceof VaultError) {
console.error(`Vault error reading metadata ${path}:`, error.message);
console.error(`Vault error reading metadata ${path}:`, error.message)
if (error.errors) {
console.error('Details:', error.errors);
console.error('Details:', error.errors)
}
// Re-throw to let the caller handle it
throw error;
throw error
} else {
console.error(`Error reading metadata at ${path}:`, error);
throw new VaultError('Failed to read metadata');
console.error(`Error reading metadata at ${path}:`, error)
throw new VaultError('Failed to read metadata')
}
}
}
@ -148,30 +129,25 @@ class VaultApiService {
/**
* Write a secret to Vault (no caching)
*/
async writeSecret(
server: VaultServer,
credentials: VaultCredentials,
path: string,
data: Record<string, unknown>
): Promise<void> {
console.log(`⚡ API call for write: ${path}`);
async writeSecret(server: VaultServer, credentials: VaultCredentials, path: string, data: Record<string, unknown>): Promise<void> {
console.log(`⚡ API call for write: ${path}`)
try {
const client = this.createClient(server, credentials);
await client.write(path, data);
const client = this.createClient(server, credentials)
await client.write(path, data)
// Invalidate cache for this path
const cacheKey = this.getCacheKey(server, path, 'read');
vaultCache.delete(cacheKey);
const cacheKey = this.getCacheKey(server, path, 'read')
vaultCache.delete(cacheKey)
console.log(`✓ Secret written successfully: ${path}`);
console.log(`✓ Secret written successfully: ${path}`)
} catch (error) {
if (error instanceof VaultError) {
console.error(`Vault error writing ${path}:`, error.message);
throw error;
console.error(`Vault error writing ${path}:`, error.message)
throw error
} else {
console.error(`Error writing secret at ${path}:`, error);
throw new VaultError('Failed to write secret');
console.error(`Error writing secret at ${path}:`, error)
throw new VaultError('Failed to write secret')
}
}
}
@ -179,29 +155,25 @@ class VaultApiService {
/**
* Delete a secret from Vault (no caching)
*/
async deleteSecret(
server: VaultServer,
credentials: VaultCredentials,
path: string
): Promise<void> {
console.log(`⚡ API call for delete: ${path}`);
async deleteSecret(server: VaultServer, credentials: VaultCredentials, path: string): Promise<void> {
console.log(`⚡ API call for delete: ${path}`)
try {
const client = this.createClient(server, credentials);
await client.delete(path);
const client = this.createClient(server, credentials)
await client.delete(path)
// Invalidate cache for this path
const cacheKey = this.getCacheKey(server, path, 'read');
vaultCache.delete(cacheKey);
const cacheKey = this.getCacheKey(server, path, 'read')
vaultCache.delete(cacheKey)
console.log(`✓ Secret deleted successfully: ${path}`);
console.log(`✓ Secret deleted successfully: ${path}`)
} catch (error) {
if (error instanceof VaultError) {
console.error(`Vault error deleting ${path}:`, error.message);
throw error;
console.error(`Vault error deleting ${path}:`, error.message)
throw error
} else {
console.error(`Error deleting secret at ${path}:`, error);
throw new VaultError('Failed to delete secret');
console.error(`Error deleting secret at ${path}:`, error)
throw new VaultError('Failed to delete secret')
}
}
}
@ -209,20 +181,17 @@ class VaultApiService {
/**
* Verify login and get available mount points
*/
async verifyLoginAndGetMounts(
server: VaultServer,
credentials: VaultCredentials
): Promise<MountPoint[]> {
console.log('⚡ Verifying login and fetching mount points...');
async verifyLoginAndGetMounts(server: VaultServer, credentials: VaultCredentials): Promise<MountPoint[]> {
console.log('⚡ Verifying login and fetching mount points...')
try {
const client = this.createClient(server, credentials);
const mounts = await client.listMounts();
const client = this.createClient(server, credentials)
const mounts = await client.listMounts()
console.log('📋 Raw mount points from API:', mounts);
console.log('📋 Raw mount points from API:', mounts)
// Convert to array and filter for KV secret engines
const mountPoints: MountPoint[] = [];
const mountPoints: MountPoint[] = []
for (const [path, mount] of Object.entries(mounts)) {
// Only include KV secret engines
@ -234,18 +203,21 @@ class VaultApiService {
accessor: mount.accessor,
config: mount.config,
options: mount.options || {},
});
})
}
}
console.log(`✓ Found ${mountPoints.length} KV mount point(s):`, mountPoints.map(m => `${m.path} (v${m.options?.version || '1'})`));
return mountPoints;
console.log(
`✓ Found ${mountPoints.length} KV mount point(s):`,
mountPoints.map(m => `${m.path} (v${m.options?.version || '1'})`)
)
return mountPoints
} catch (error) {
if (error instanceof VaultError) {
console.error('✗ Login verification failed:', error.message);
throw error;
console.error('✗ Login verification failed:', error.message)
throw error
}
throw new VaultError('Failed to verify login');
throw new VaultError('Failed to verify login')
}
}
@ -260,23 +232,23 @@ class VaultApiService {
currentDepth: number = 0,
mountPoint?: string
): Promise<SearchResult[]> {
const config = loadConfig();
const config = loadConfig()
// Check depth limit
if (currentDepth >= config.search.maxDepth) {
console.warn(`⚠ Max depth ${config.search.maxDepth} reached at ${basePath}`);
return [];
console.warn(`⚠ Max depth ${config.search.maxDepth} reached at ${basePath}`)
return []
}
const results: SearchResult[] = [];
const results: SearchResult[] = []
try {
// List items at current path
const items = await this.listSecrets(server, credentials, basePath);
const items = await this.listSecrets(server, credentials, basePath)
for (const item of items) {
const fullPath = basePath ? `${basePath}${item}` : item;
const isDirectory = item.endsWith('/');
const fullPath = basePath ? `${basePath}${item}` : item
const isDirectory = item.endsWith('/')
// Check if this path matches the search term
if (fullPath.toLowerCase().includes(searchTerm.toLowerCase())) {
@ -285,88 +257,65 @@ class VaultApiService {
isDirectory,
depth: currentDepth,
mountPoint,
});
})
// Stop if we've reached max results
if (results.length >= config.search.maxResults) {
console.warn(
`⚠ Max results ${config.search.maxResults} reached`
);
return results;
console.warn(`⚠ Max results ${config.search.maxResults} reached`)
return results
}
}
// If it's a directory, recursively search it
if (isDirectory) {
const subResults = await this.searchPaths(
server,
credentials,
fullPath,
searchTerm,
currentDepth + 1,
mountPoint
);
results.push(...subResults);
const subResults = await this.searchPaths(server, credentials, fullPath, searchTerm, currentDepth + 1, mountPoint)
results.push(...subResults)
// Stop if we've reached max results
if (results.length >= config.search.maxResults) {
console.warn(
`⚠ Max results ${config.search.maxResults} reached`
);
return results.slice(0, config.search.maxResults);
console.warn(`⚠ Max results ${config.search.maxResults} reached`)
return results.slice(0, config.search.maxResults)
}
}
}
} catch (error) {
console.error(`Error searching path ${basePath}:`, error);
console.error(`Error searching path ${basePath}:`, error)
}
return results;
return results
}
/**
* Search across all mount points
*/
async searchAllMounts(
server: VaultServer,
credentials: VaultCredentials,
mountPoints: MountPoint[],
searchTerm: string
): Promise<SearchResult[]> {
console.log(`🔍 Searching across ${mountPoints.length} mount point(s)...`);
async searchAllMounts(server: VaultServer, credentials: VaultCredentials, mountPoints: MountPoint[], searchTerm: string): Promise<SearchResult[]> {
console.log(`🔍 Searching across ${mountPoints.length} mount point(s)...`)
const allResults: SearchResult[] = [];
const config = loadConfig();
const allResults: SearchResult[] = []
const config = loadConfig()
for (const mount of mountPoints) {
console.log(` → Searching in ${mount.path}/`);
console.log(` → Searching in ${mount.path}/`)
try {
// Search this mount point (KV v2 enforced)
const results = await this.searchPaths(
server,
credentials,
`${mount.path}/`,
searchTerm,
0,
mount.path
);
const results = await this.searchPaths(server, credentials, `${mount.path}/`, searchTerm, 0, mount.path)
allResults.push(...results);
allResults.push(...results)
// Stop if we've hit the global max results
if (allResults.length >= config.search.maxResults) {
console.warn(`⚠ Max results ${config.search.maxResults} reached`);
return allResults.slice(0, config.search.maxResults);
console.warn(`⚠ Max results ${config.search.maxResults} reached`)
return allResults.slice(0, config.search.maxResults)
}
} catch (error) {
console.error(` ✗ Error searching ${mount.path}:`, error);
console.error(` ✗ Error searching ${mount.path}:`, error)
// Continue with other mount points even if one fails
}
}
console.log(`✓ Found ${allResults.length} total result(s) across all mounts`);
return allResults;
console.log(`✓ Found ${allResults.length} total result(s) across all mounts`)
return allResults
}
/**
@ -376,72 +325,58 @@ class VaultApiService {
try {
const client = this.createClient(server, {
serverId: server.id,
authMethod: 'token'
});
const health = await client.health();
console.log('✓ Vault server health:', health);
return health.initialized && !health.sealed;
authMethod: 'token',
})
const health = await client.health()
console.log('✓ Vault server health:', health)
return health.initialized && !health.sealed
} catch (error) {
console.error('✗ Failed to connect to Vault:', error);
return false;
console.error('✗ Failed to connect to Vault:', error)
return false
}
}
/**
* Authenticate with username/password
*/
async loginUserpass(
server: VaultServer,
username: string,
password: string
): Promise<string> {
async loginUserpass(server: VaultServer, username: string, password: string): Promise<string> {
const client = this.createClient(server, {
serverId: server.id,
authMethod: 'userpass',
});
return await client.loginUserpass(username, password);
})
return await client.loginUserpass(username, password)
}
/**
* Authenticate with LDAP
*/
async loginLdap(
server: VaultServer,
username: string,
password: string
): Promise<string> {
async loginLdap(server: VaultServer, username: string, password: string): Promise<string> {
const client = this.createClient(server, {
serverId: server.id,
authMethod: 'ldap',
});
return await client.loginLdap(username, password);
})
return await client.loginLdap(username, password)
}
/**
* Get current token information
*/
async getTokenInfo(
server: VaultServer,
credentials: VaultCredentials
): Promise<unknown> {
const client = this.createClient(server, credentials);
return await client.tokenLookupSelf();
async getTokenInfo(server: VaultServer, credentials: VaultCredentials): Promise<unknown> {
const client = this.createClient(server, credentials)
return await client.tokenLookupSelf()
}
/**
* Revoke current token (logout)
*/
async logout(
server: VaultServer,
credentials: VaultCredentials
): Promise<void> {
const client = this.createClient(server, credentials);
await client.tokenRevokeSelf();
async logout(server: VaultServer, credentials: VaultCredentials): Promise<void> {
const client = this.createClient(server, credentials)
await client.tokenRevokeSelf()
}
}
// Export singleton instance
export const vaultApi = new VaultApiService();
export const vaultApi = new VaultApiService()
// Export VaultError for error handling
export { VaultError };
export { VaultError }

View File

@ -1,28 +1,28 @@
import { VaultServer, VaultCredentials } from '../types';
import { VaultServer, VaultCredentials } from '../types'
/**
* Configuration options for VaultClient
*/
export interface VaultClientOptions {
server: VaultServer;
credentials: VaultCredentials;
timeout?: number;
retries?: number;
kvVersion?: 1 | 2; // KV secret engine version
server: VaultServer
credentials: VaultCredentials
timeout?: number
retries?: number
kvVersion?: 1 | 2 // KV secret engine version
}
/**
* Vault API error with additional context
*/
export class VaultError extends Error {
constructor(
message: string,
public statusCode?: number,
public errors?: string[]
) {
super(message);
this.name = 'VaultError';
}
constructor(
message: string,
public statusCode?: number,
public errors?: string[]
) {
super(message)
this.name = 'VaultError'
}
}
/**
@ -33,413 +33,395 @@ export class VaultError extends Error {
* Supports both KV v1 and KV v2 secret engines.
*/
export class VaultClient {
private baseUrl: string;
private token?: string;
private timeout: number;
private retries: number;
private kvVersion: 1 | 2;
private baseUrl: string
private token?: string
private timeout: number
private retries: number
private kvVersion: 1 | 2
constructor(options: VaultClientOptions) {
this.baseUrl = options.server.url.replace(/\/$/, ''); // Remove trailing slash
this.token = options.credentials.token;
this.timeout = options.timeout || 30000; // 30 seconds default
this.retries = options.retries || 2;
this.kvVersion = options.kvVersion || 2; // Default to KV v2 (most common)
constructor(options: VaultClientOptions) {
this.baseUrl = options.server.url.replace(/\/$/, '') // Remove trailing slash
this.token = options.credentials.token
this.timeout = options.timeout || 30000 // 30 seconds default
this.retries = options.retries || 2
this.kvVersion = options.kvVersion || 2 // Default to KV v2 (most common)
}
/**
* Transform a path based on KV version
* KV v2 uses /data/ for reads/writes and /metadata/ for lists
*/
private transformPath(path: string, operation: 'data' | 'metadata' | 'none' = 'none'): string {
const normalized = path.replace(/^\/+/, '').replace(/\/+$/, '')
if (this.kvVersion === 1) {
return normalized
}
/**
* Transform a path based on KV version
* KV v2 uses /data/ for reads/writes and /metadata/ for lists
*/
private transformPath(path: string, operation: 'data' | 'metadata' | 'none' = 'none'): string {
const normalized = path.replace(/^\/+/, '').replace(/\/+$/, '');
if (this.kvVersion === 1) {
return normalized;
}
// KV v2 path transformation
// Check if path already has /data/ or /metadata/
if (normalized.includes('/data/') || normalized.includes('/metadata/')) {
return normalized;
}
// For KV v2, transform the path
const parts = normalized.split('/');
const mount = parts[0]; // e.g., "secret"
const rest = parts.slice(1).join('/');
if (operation === 'data') {
return `${mount}/data/${rest}`;
} else if (operation === 'metadata') {
return `${mount}/metadata/${rest}`;
}
return normalized;
// KV v2 path transformation
// Check if path already has /data/ or /metadata/
if (normalized.includes('/data/') || normalized.includes('/metadata/')) {
return normalized
}
/**
* Make an HTTP request to the Vault API
*/
private async request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}/v1/${path.replace(/^\//, '')}`;
// For KV v2, transform the path
const parts = normalized.split('/')
const mount = parts[0] // e.g., "secret"
const rest = parts.slice(1).join('/')
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (operation === 'data') {
return `${mount}/data/${rest}`
} else if (operation === 'metadata') {
return `${mount}/metadata/${rest}`
}
// Add authentication token if available
if (this.token) {
headers['X-Vault-Token'] = this.token;
}
return normalized
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
/**
* Make an HTTP request to the Vault API
*/
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}/v1/${path.replace(/^\//, '')}`
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
}
// Add authentication token if available
if (this.token) {
headers['X-Vault-Token'] = this.token
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
try {
const response = await fetch(url, {
...options,
headers,
signal: controller.signal,
})
clearTimeout(timeoutId)
// Handle non-OK responses
if (!response.ok) {
let errorData: { errors?: string[] } = {}
try {
const response = await fetch(url, {
...options,
headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
// Handle non-OK responses
if (!response.ok) {
let errorData: { errors?: string[] } = {};
try {
errorData = await response.json();
} catch {
// Response might not be JSON
}
throw new VaultError(
`Vault API error: ${response.statusText}`,
response.status,
errorData.errors
);
}
// Handle empty responses (e.g., 204 No Content)
if (response.status === 204 || response.headers.get('content-length') === '0') {
return null as T;
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof VaultError) {
throw error;
}
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new VaultError('Request timeout');
}
throw new VaultError(`Network error: ${error.message}`);
}
throw new VaultError('Unknown error occurred');
}
}
/**
* Make a request with automatic retries
*/
private async requestWithRetry<T>(
path: string,
options: RequestInit = {},
attempt = 0
): Promise<T> {
try {
return await this.request<T>(path, options);
} catch (error) {
// Only retry on network errors, not on 4xx client errors
if (
attempt < this.retries &&
error instanceof VaultError &&
(!error.statusCode || error.statusCode >= 500)
) {
// Exponential backoff
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
return this.requestWithRetry<T>(path, options, attempt + 1);
}
throw error;
}
}
/**
* List secrets at a given path
*
* For KV v2, this uses the /metadata/ endpoint
* For KV v1, this uses the path directly
*/
async list(path: string): Promise<string[]> {
const normalizedPath = this.transformPath(path, 'metadata');
// Ensure path ends with / for LIST operations
const listPath = normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`;
const response = await this.requestWithRetry<{ data: { keys: string[] } }>(
`${listPath}?list=true`,
{ method: 'LIST' }
);
return response?.data?.keys || [];
}
/**
* Read a secret from Vault
*
* For KV v2, this uses the /data/ endpoint
* For KV v1, this uses the path directly
*/
async read<T = Record<string, unknown>>(path: string): Promise<T | null> {
const normalizedPath = this.transformPath(path, 'data');
if (this.kvVersion === 2) {
// KV v2 returns { data: { data: {...}, metadata: {...} } }
const response = await this.requestWithRetry<{
data: { data: T; metadata?: unknown };
}>(normalizedPath, { method: 'GET' });
return response?.data?.data || null;
} else {
// KV v1 returns { data: {...} }
const response = await this.requestWithRetry<{ data: T }>(
normalizedPath,
{ method: 'GET' }
);
return response?.data || null;
}
}
/**
* Write a secret to Vault
*
* For KV v2, this uses the /data/ endpoint
* For KV v1, this uses the path directly
*/
async write<T = Record<string, unknown>>(
path: string,
data: T
): Promise<void> {
const normalizedPath = this.transformPath(path, 'data');
const body = this.kvVersion === 2 ? { data } : data;
await this.requestWithRetry<void>(normalizedPath, {
method: 'POST',
body: JSON.stringify(body),
});
}
/**
* Delete a secret from Vault
*
* For KV v2, this uses the /data/ endpoint (soft delete)
* For KV v1, this uses the path directly (hard delete)
*/
async delete(path: string): Promise<void> {
const normalizedPath = this.transformPath(path, 'data');
await this.requestWithRetry<void>(normalizedPath, {
method: 'DELETE',
});
}
/**
* Read secret metadata (KV v2 only)
* Returns version history, created time, etc.
*/
async readMetadata(path: string): Promise<{
versions: Record<string, {
created_time: string;
deletion_time: string;
destroyed: boolean;
}>;
current_version: number;
oldest_version: number;
created_time: string;
updated_time: string;
} | null> {
if (this.kvVersion !== 2) {
throw new VaultError('Metadata is only available in KV v2');
}
const normalizedPath = this.transformPath(path, 'metadata');
const response = await this.requestWithRetry<{
data: {
versions: Record<string, {
created_time: string;
deletion_time: string;
destroyed: boolean;
}>;
current_version: number;
oldest_version: number;
created_time: string;
updated_time: string;
};
}>(normalizedPath, { method: 'GET' });
return response?.data || null;
}
/**
* Get health status of Vault server
*/
async health(): Promise<{
initialized: boolean;
sealed: boolean;
standby: boolean;
version: string;
}> {
// Health endpoint doesn't require authentication
const url = `${this.baseUrl}/v1/sys/health`;
const response = await fetch(url);
return response.json();
}
/**
* Authenticate with username/password
*/
async loginUserpass(username: string, password: string): Promise<string> {
const response = await this.request<{
auth: { client_token: string };
}>('auth/userpass/login/' + username, {
method: 'POST',
body: JSON.stringify({ password }),
});
this.token = response.auth.client_token;
return this.token;
}
/**
* Authenticate with LDAP
*/
async loginLdap(username: string, password: string): Promise<string> {
const response = await this.request<{
auth: { client_token: string };
}>('auth/ldap/login/' + username, {
method: 'POST',
body: JSON.stringify({ password }),
});
this.token = response.auth.client_token;
return this.token;
}
/**
* Lookup current token info
*/
async tokenLookupSelf(): Promise<{
data: {
accessor: string;
creation_time: number;
creation_ttl: number;
display_name: string;
entity_id: string;
expire_time: string | null;
explicit_max_ttl: number;
id: string;
issue_time: string;
meta: Record<string, string>;
num_uses: number;
orphan: boolean;
path: string;
policies: string[];
renewable: boolean;
ttl: number;
type: string;
};
}> {
return this.requestWithRetry('auth/token/lookup-self', {
method: 'GET',
});
}
/**
* Revoke current token (logout)
*/
async tokenRevokeSelf(): Promise<void> {
await this.requestWithRetry<void>('auth/token/revoke-self', {
method: 'POST',
});
this.token = undefined;
}
/**
* List all secret engine mount points
* This also verifies the token is valid
*/
async listMounts(): Promise<{
[key: string]: {
type: string;
description: string;
accessor: string;
config: {
default_lease_ttl: number;
max_lease_ttl: number;
};
options: {
version?: string;
} | null;
};
}> {
const response = await this.requestWithRetry<{
data: {
auth?: {
[key: string]: {
type: string;
description: string;
accessor: string;
config: Record<string, unknown>;
options: Record<string, unknown> | null;
};
};
secret?: {
[key: string]: {
type: string;
description: string;
accessor: string;
config: {
default_lease_ttl: number;
max_lease_ttl: number;
};
options: {
version?: string;
} | null;
};
};
};
}>('sys/internal/ui/mounts', { method: 'GET' });
// Return only the secret engines (not auth methods)
return response?.data?.secret || {};
}
/**
* Detect KV version for a mount point
*/
async detectKvVersion(mountPath: string): Promise<1 | 2> {
try {
const response = await this.requestWithRetry<{
data: {
options: { version?: string };
type: string;
};
}>(`sys/internal/ui/mounts/${mountPath}`, { method: 'GET' });
const version = response?.data?.options?.version;
return version === '2' ? 2 : 1;
errorData = await response.json()
} catch {
// If detection fails, assume v2 (most common)
return 2;
// Response might not be JSON
}
throw new VaultError(`Vault API error: ${response.statusText}`, response.status, errorData.errors)
}
// Handle empty responses (e.g., 204 No Content)
if (response.status === 204 || response.headers.get('content-length') === '0') {
return null as T
}
return await response.json()
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof VaultError) {
throw error
}
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new VaultError('Request timeout')
}
throw new VaultError(`Network error: ${error.message}`)
}
throw new VaultError('Unknown error occurred')
}
}
/**
* Make a request with automatic retries
*/
private async requestWithRetry<T>(path: string, options: RequestInit = {}, attempt = 0): Promise<T> {
try {
return await this.request<T>(path, options)
} catch (error) {
// Only retry on network errors, not on 4xx client errors
if (attempt < this.retries && error instanceof VaultError && (!error.statusCode || error.statusCode >= 500)) {
// Exponential backoff
const delay = Math.pow(2, attempt) * 1000
await new Promise(resolve => setTimeout(resolve, delay))
return this.requestWithRetry<T>(path, options, attempt + 1)
}
throw error
}
}
/**
* List secrets at a given path
*
* For KV v2, this uses the /metadata/ endpoint
* For KV v1, this uses the path directly
*/
async list(path: string): Promise<string[]> {
const normalizedPath = this.transformPath(path, 'metadata')
// Ensure path ends with / for LIST operations
const listPath = normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`
const response = await this.requestWithRetry<{ data: { keys: string[] } }>(`${listPath}?list=true`, { method: 'LIST' })
return response?.data?.keys || []
}
/**
* Read a secret from Vault
*
* For KV v2, this uses the /data/ endpoint
* For KV v1, this uses the path directly
*/
async read<T = Record<string, unknown>>(path: string): Promise<T | null> {
const normalizedPath = this.transformPath(path, 'data')
if (this.kvVersion === 2) {
// KV v2 returns { data: { data: {...}, metadata: {...} } }
const response = await this.requestWithRetry<{
data: { data: T; metadata?: unknown }
}>(normalizedPath, { method: 'GET' })
return response?.data?.data || null
} else {
// KV v1 returns { data: {...} }
const response = await this.requestWithRetry<{ data: T }>(normalizedPath, { method: 'GET' })
return response?.data || null
}
}
/**
* Write a secret to Vault
*
* For KV v2, this uses the /data/ endpoint
* For KV v1, this uses the path directly
*/
async write<T = Record<string, unknown>>(path: string, data: T): Promise<void> {
const normalizedPath = this.transformPath(path, 'data')
const body = this.kvVersion === 2 ? { data } : data
await this.requestWithRetry<void>(normalizedPath, {
method: 'POST',
body: JSON.stringify(body),
})
}
/**
* Delete a secret from Vault
*
* For KV v2, this uses the /data/ endpoint (soft delete)
* For KV v1, this uses the path directly (hard delete)
*/
async delete(path: string): Promise<void> {
const normalizedPath = this.transformPath(path, 'data')
await this.requestWithRetry<void>(normalizedPath, {
method: 'DELETE',
})
}
/**
* Read secret metadata (KV v2 only)
* Returns version history, created time, etc.
*/
async readMetadata(path: string): Promise<{
versions: Record<
string,
{
created_time: string
deletion_time: string
destroyed: boolean
}
>
current_version: number
oldest_version: number
created_time: string
updated_time: string
} | null> {
if (this.kvVersion !== 2) {
throw new VaultError('Metadata is only available in KV v2')
}
const normalizedPath = this.transformPath(path, 'metadata')
const response = await this.requestWithRetry<{
data: {
versions: Record<
string,
{
created_time: string
deletion_time: string
destroyed: boolean
}
>
current_version: number
oldest_version: number
created_time: string
updated_time: string
}
}>(normalizedPath, { method: 'GET' })
return response?.data || null
}
/**
* Get health status of Vault server
*/
async health(): Promise<{
initialized: boolean
sealed: boolean
standby: boolean
version: string
}> {
// Health endpoint doesn't require authentication
const url = `${this.baseUrl}/v1/sys/health`
const response = await fetch(url)
return response.json()
}
/**
* Authenticate with username/password
*/
async loginUserpass(username: string, password: string): Promise<string> {
const response = await this.request<{
auth: { client_token: string }
}>('auth/userpass/login/' + username, {
method: 'POST',
body: JSON.stringify({ password }),
})
this.token = response.auth.client_token
return this.token
}
/**
* Authenticate with LDAP
*/
async loginLdap(username: string, password: string): Promise<string> {
const response = await this.request<{
auth: { client_token: string }
}>('auth/ldap/login/' + username, {
method: 'POST',
body: JSON.stringify({ password }),
})
this.token = response.auth.client_token
return this.token
}
/**
* Lookup current token info
*/
async tokenLookupSelf(): Promise<{
data: {
accessor: string
creation_time: number
creation_ttl: number
display_name: string
entity_id: string
expire_time: string | null
explicit_max_ttl: number
id: string
issue_time: string
meta: Record<string, string>
num_uses: number
orphan: boolean
path: string
policies: string[]
renewable: boolean
ttl: number
type: string
}
}> {
return this.requestWithRetry('auth/token/lookup-self', {
method: 'GET',
})
}
/**
* Revoke current token (logout)
*/
async tokenRevokeSelf(): Promise<void> {
await this.requestWithRetry<void>('auth/token/revoke-self', {
method: 'POST',
})
this.token = undefined
}
/**
* List all secret engine mount points
* This also verifies the token is valid
*/
async listMounts(): Promise<{
[key: string]: {
type: string
description: string
accessor: string
config: {
default_lease_ttl: number
max_lease_ttl: number
}
options: {
version?: string
} | null
}
}> {
const response = await this.requestWithRetry<{
data: {
auth?: {
[key: string]: {
type: string
description: string
accessor: string
config: Record<string, unknown>
options: Record<string, unknown> | null
}
}
secret?: {
[key: string]: {
type: string
description: string
accessor: string
config: {
default_lease_ttl: number
max_lease_ttl: number
}
options: {
version?: string
} | null
}
}
}
}>('sys/internal/ui/mounts', { method: 'GET' })
// Return only the secret engines (not auth methods)
return response?.data?.secret || {}
}
/**
* Detect KV version for a mount point
*/
async detectKvVersion(mountPath: string): Promise<1 | 2> {
try {
const response = await this.requestWithRetry<{
data: {
options: { version?: string }
type: string
}
}>(`sys/internal/ui/mounts/${mountPath}`, { method: 'GET' })
const version = response?.data?.options?.version
return version === '2' ? 2 : 1
} catch {
// If detection fails, assume v2 (most common)
return 2
}
}
}

View File

@ -4,27 +4,27 @@
/* Custom scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-base-300;
@apply bg-base-300;
}
::-webkit-scrollbar-thumb {
@apply bg-base-content/30 rounded;
@apply bg-base-content/30 rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-base-content/50;
@apply bg-base-content/50;
}
/* Code blocks */
pre {
@apply bg-base-300 p-4 rounded-lg overflow-x-auto text-sm;
@apply bg-base-300 p-4 rounded-lg overflow-x-auto text-sm;
}
code {
@apply bg-base-300 px-2 py-1 rounded text-sm font-mono;
@apply bg-base-300 px-2 py-1 rounded text-sm font-mono;
}

View File

@ -1,50 +1,51 @@
export interface VaultServer {
id: string;
name: string;
url: string;
description?: string;
id: string
name: string
url: string
description?: string
// KV v2 is enforced - no version selection needed
savedCredentials?: VaultCredentials; // Optional saved credentials (WARNING: stored in localStorage)
savedCredentials?: VaultCredentials // Optional saved credentials (WARNING: stored in localStorage)
}
export interface VaultCredentials {
serverId: string;
token?: string;
username?: string;
password?: string;
authMethod: 'token' | 'userpass' | 'ldap';
serverId: string
token?: string
username?: string
password?: string
authMethod: 'token' | 'userpass' | 'ldap'
}
export interface MountPoint {
path: string;
type: string;
description: string;
accessor: string;
path: string
type: string
description: string
accessor: string
config: {
default_lease_ttl: number;
max_lease_ttl: number;
};
options: {
version?: string;
} | Record<string, never>;
default_lease_ttl: number
max_lease_ttl: number
}
options:
| {
version?: string
}
| Record<string, never>
}
export interface VaultConnection {
server: VaultServer;
credentials: VaultCredentials;
isConnected: boolean;
lastConnected?: Date;
mountPoints?: MountPoint[];
server: VaultServer
credentials: VaultCredentials
isConnected: boolean
lastConnected?: Date
mountPoints?: MountPoint[]
}
export interface VaultSecret {
path: string;
data: Record<string, unknown>;
path: string
data: Record<string, unknown>
metadata?: {
created_time: string;
deletion_time: string;
destroyed: boolean;
version: number;
};
created_time: string
deletion_time: string
destroyed: boolean
version: number
}
}

View File

@ -1,166 +1,166 @@
import { loadConfig } from '../config';
import { loadConfig } from '../config'
export interface CacheEntry<T> {
data: T;
timestamp: number;
size: number; // Size in bytes
data: T
timestamp: number
size: number // Size in bytes
}
export interface CacheStats {
totalSize: number; // Total size in bytes
entryCount: number;
oldestEntry: number | null;
newestEntry: number | null;
totalSize: number // Total size in bytes
entryCount: number
oldestEntry: number | null
newestEntry: number | null
}
class VaultCache {
private readonly CACHE_KEY = 'vaultApiCache';
private cache: Map<string, CacheEntry<unknown>>;
private readonly CACHE_KEY = 'vaultApiCache'
private cache: Map<string, CacheEntry<unknown>>
constructor() {
this.cache = this.loadFromStorage();
this.cache = this.loadFromStorage()
}
private loadFromStorage(): Map<string, CacheEntry<unknown>> {
try {
const stored = localStorage.getItem(this.CACHE_KEY);
const stored = localStorage.getItem(this.CACHE_KEY)
if (stored) {
const parsed = JSON.parse(stored);
return new Map(Object.entries(parsed));
const parsed = JSON.parse(stored)
return new Map(Object.entries(parsed))
}
} catch (error) {
console.error('Failed to load cache from storage:', error);
console.error('Failed to load cache from storage:', error)
}
return new Map();
return new Map()
}
private saveToStorage(): void {
try {
const obj = Object.fromEntries(this.cache);
localStorage.setItem(this.CACHE_KEY, JSON.stringify(obj));
const obj = Object.fromEntries(this.cache)
localStorage.setItem(this.CACHE_KEY, JSON.stringify(obj))
} catch (error) {
console.error('Failed to save cache to storage:', error);
console.error('Failed to save cache to storage:', error)
// If quota exceeded, clear old entries and retry
this.evictOldEntries(0.5); // Remove 50% of entries
this.evictOldEntries(0.5) // Remove 50% of entries
try {
const obj = Object.fromEntries(this.cache);
localStorage.setItem(this.CACHE_KEY, JSON.stringify(obj));
const obj = Object.fromEntries(this.cache)
localStorage.setItem(this.CACHE_KEY, JSON.stringify(obj))
} catch (retryError) {
console.error('Failed to save cache after cleanup:', retryError);
console.error('Failed to save cache after cleanup:', retryError)
}
}
}
private calculateSize(data: unknown): number {
// Rough estimation of size in bytes
return new Blob([JSON.stringify(data)]).size;
return new Blob([JSON.stringify(data)]).size
}
private evictOldEntries(fraction: number): void {
const entries = Array.from(this.cache.entries());
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
const toRemove = Math.floor(entries.length * fraction);
const entries = Array.from(this.cache.entries())
entries.sort((a, b) => a[1].timestamp - b[1].timestamp)
const toRemove = Math.floor(entries.length * fraction)
for (let i = 0; i < toRemove; i++) {
this.cache.delete(entries[i][0]);
this.cache.delete(entries[i][0])
}
}
private enforceSizeLimit(): void {
const config = loadConfig();
if (!config.cache.enabled) return;
const config = loadConfig()
if (!config.cache.enabled) return
const maxBytes = config.cache.maxSizeMB * 1024 * 1024;
let totalSize = 0;
const maxBytes = config.cache.maxSizeMB * 1024 * 1024
let totalSize = 0
// Calculate total size
for (const entry of this.cache.values()) {
totalSize += entry.size;
totalSize += entry.size
}
// If over limit, remove oldest entries
if (totalSize > maxBytes) {
const entries = Array.from(this.cache.entries());
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
const entries = Array.from(this.cache.entries())
entries.sort((a, b) => a[1].timestamp - b[1].timestamp)
for (const [key, entry] of entries) {
if (totalSize <= maxBytes * 0.8) break; // Remove until 80% of limit
totalSize -= entry.size;
this.cache.delete(key);
if (totalSize <= maxBytes * 0.8) break // Remove until 80% of limit
totalSize -= entry.size
this.cache.delete(key)
}
}
}
get<T>(key: string): T | null {
const config = loadConfig();
if (!config.cache.enabled) return null;
const config = loadConfig()
if (!config.cache.enabled) return null
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
if (!entry) return null;
const entry = this.cache.get(key) as CacheEntry<T> | undefined
if (!entry) return null
// Check if entry is expired
const age = Date.now() - entry.timestamp;
const age = Date.now() - entry.timestamp
if (age > config.cache.maxAge) {
this.cache.delete(key);
return null;
this.cache.delete(key)
return null
}
return entry.data;
return entry.data
}
set<T>(key: string, data: T): void {
const config = loadConfig();
if (!config.cache.enabled) return;
const config = loadConfig()
if (!config.cache.enabled) return
const size = this.calculateSize(data);
const size = this.calculateSize(data)
const entry: CacheEntry<T> = {
data,
timestamp: Date.now(),
size,
};
}
this.cache.set(key, entry as CacheEntry<unknown>);
this.enforceSizeLimit();
this.saveToStorage();
this.cache.set(key, entry as CacheEntry<unknown>)
this.enforceSizeLimit()
this.saveToStorage()
}
has(key: string): boolean {
const config = loadConfig();
if (!config.cache.enabled) return false;
const config = loadConfig()
if (!config.cache.enabled) return false
const entry = this.cache.get(key);
if (!entry) return false;
const entry = this.cache.get(key)
if (!entry) return false
const age = Date.now() - entry.timestamp;
const age = Date.now() - entry.timestamp
if (age > config.cache.maxAge) {
this.cache.delete(key);
return false;
this.cache.delete(key)
return false
}
return true;
return true
}
delete(key: string): void {
this.cache.delete(key);
this.saveToStorage();
this.cache.delete(key)
this.saveToStorage()
}
clear(): void {
this.cache.clear();
this.saveToStorage();
this.cache.clear()
this.saveToStorage()
}
getStats(): CacheStats {
let totalSize = 0;
let oldestEntry: number | null = null;
let newestEntry: number | null = null;
let totalSize = 0
let oldestEntry: number | null = null
let newestEntry: number | null = null
for (const entry of this.cache.values()) {
totalSize += entry.size;
totalSize += entry.size
if (oldestEntry === null || entry.timestamp < oldestEntry) {
oldestEntry = entry.timestamp;
oldestEntry = entry.timestamp
}
if (newestEntry === null || entry.timestamp > newestEntry) {
newestEntry = entry.timestamp;
newestEntry = entry.timestamp
}
}
@ -169,34 +169,33 @@ class VaultCache {
entryCount: this.cache.size,
oldestEntry,
newestEntry,
};
}
}
// Clean up expired entries
cleanup(): void {
const config = loadConfig();
const now = Date.now();
const keysToDelete: string[] = [];
const config = loadConfig()
const now = Date.now()
const keysToDelete: string[] = []
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > config.cache.maxAge) {
keysToDelete.push(key);
keysToDelete.push(key)
}
}
for (const key of keysToDelete) {
this.cache.delete(key);
this.cache.delete(key)
}
if (keysToDelete.length > 0) {
this.saveToStorage();
this.saveToStorage()
}
}
}
// Singleton instance
export const vaultCache = new VaultCache();
export const vaultCache = new VaultCache()
// Cleanup expired entries on page load
vaultCache.cleanup();
vaultCache.cleanup()

View File

@ -9,8 +9,8 @@ export default {
},
plugins: [require("daisyui")],
daisyui: {
themes: ["dark", "light"],
darkTheme: "dark",
themes: ["business"],
darkTheme: "business",
},
}