peaufinage

This commit is contained in:
Loïc Gremaud 2025-10-21 14:18:17 +02:00
parent e2375fbba9
commit d8cf61be47
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
17 changed files with 953 additions and 373 deletions

View File

@ -12,6 +12,7 @@
"format:check": "prettier --check ." "format:check": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"sweetalert2": "^11.26.3",
"vue": "^3.4.15" "vue": "^3.4.15"
}, },
"devDependencies": { "devDependencies": {

View File

@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
sweetalert2:
specifier: ^11.26.3
version: 11.26.3
vue: vue:
specifier: ^3.4.15 specifier: ^3.4.15
version: 3.5.22(typescript@5.9.3) version: 3.5.22(typescript@5.9.3)
@ -1249,6 +1252,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
sweetalert2@11.26.3:
resolution: {integrity: sha512-VU0hGw/WfI9h7Mh+SCsDlWgtxDwWZ6ccqS7QcO8zEeWnwplN1GptcLstq76OluUBSLUza6ldvKd3558OhjpJ9A==}
tailwindcss@3.4.18: tailwindcss@3.4.18:
resolution: {integrity: sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==} resolution: {integrity: sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@ -2524,6 +2530,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
sweetalert2@11.26.3: {}
tailwindcss@3.4.18: tailwindcss@3.4.18:
dependencies: dependencies:
'@alloc/quick-lru': 5.2.0 '@alloc/quick-lru': 5.2.0

View File

@ -4,11 +4,23 @@ import type { VaultServer, VaultCredentials, VaultConnection } from './types'
import ServerSelector from './components/ServerSelector.vue' import ServerSelector from './components/ServerSelector.vue'
import LoginForm from './components/LoginForm.vue' import LoginForm from './components/LoginForm.vue'
import Dashboard from './components/Dashboard.vue' import Dashboard from './components/Dashboard.vue'
import PolicyModal from './components/PolicyModal.vue'
import { useSweetAlert } from './composables/useSweetAlert'
import { usePolicyModal } from './composables/usePolicyModal'
const servers = ref<VaultServer[]>([]) const servers = ref<VaultServer[]>([])
const selectedServer = ref<VaultServer | null>(null) const selectedServer = ref<VaultServer | null>(null)
const activeConnection = ref<VaultConnection | null>(null) const activeConnection = ref<VaultConnection | null>(null)
const { error } = useSweetAlert()
const { modalState, closePolicyModal, showPolicyModal } = usePolicyModal()
// Test function for debugging
const testPolicyModal = () => {
console.log('🔍 Testing policy modal')
showPolicyModal('secret/myapp/config', 'write', 'permission denied', 'Test Policy Modal')
}
// Load servers from localStorage on mount // Load servers from localStorage on mount
onMounted(() => { onMounted(() => {
const savedServers = localStorage.getItem('vaultServers') const savedServers = localStorage.getItem('vaultServers')
@ -51,11 +63,11 @@ const handleLogin = async (credentials: VaultCredentials, shouldSaveCredentials:
try { try {
// Verify login and get mount points // Verify login and get mount points
const { vaultApi } = await import('./services/vaultApi') const { vaultApi } = await import('./services/vaultApi')
const mountPoints = await vaultApi.verifyLoginAndGetMounts(selectedServer.value, credentials) const { mountPoints, updatedCredentials } = await vaultApi.verifyLoginAndGetMounts(selectedServer.value, credentials)
activeConnection.value = { activeConnection.value = {
server: selectedServer.value, server: selectedServer.value,
credentials, credentials: updatedCredentials, // Use the updated credentials with token
isConnected: true, isConnected: true,
lastConnected: new Date(), lastConnected: new Date(),
mountPoints, mountPoints,
@ -65,7 +77,7 @@ const handleLogin = async (credentials: VaultCredentials, shouldSaveCredentials:
if (shouldSaveCredentials) { if (shouldSaveCredentials) {
const serverIndex = servers.value.findIndex(s => s.id === selectedServer.value!.id) const serverIndex = servers.value.findIndex(s => s.id === selectedServer.value!.id)
if (serverIndex !== -1) { if (serverIndex !== -1) {
servers.value[serverIndex].savedCredentials = credentials servers.value[serverIndex].savedCredentials = updatedCredentials // Save the updated credentials with token
console.log('⚠️ Credentials saved to localStorage (insecure!)') console.log('⚠️ Credentials saved to localStorage (insecure!)')
} }
} else { } else {
@ -78,9 +90,9 @@ const handleLogin = async (credentials: VaultCredentials, shouldSaveCredentials:
} }
console.log(`✓ Logged in successfully. Found ${mountPoints.length} KV mount point(s).`) console.log(`✓ Logged in successfully. Found ${mountPoints.length} KV mount point(s).`)
} catch (error) { } catch (err) {
console.error('Login failed:', error) console.error('Login failed:', err)
alert(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}\n\n` + 'Please check your credentials and server configuration.') error(`Login failed: ${err instanceof Error ? err.message : 'Unknown error'}\n\nPlease check your credentials and server configuration.`)
} }
} }
@ -104,6 +116,11 @@ const handleLogout = () => {
<!-- Main Content --> <!-- Main Content -->
<main class="flex-1 container mx-auto px-4 py-8"> <main class="flex-1 container mx-auto px-4 py-8">
<!-- Debug button -->
<div class="mb-4">
<button class="btn btn-sm btn-secondary" @click="testPolicyModal">🔍 Test Policy Modal</button>
</div>
<div v-if="!activeConnection" class="grid md:grid-cols-2 gap-8"> <div v-if="!activeConnection" class="grid md:grid-cols-2 gap-8">
<!-- Server Selection --> <!-- Server Selection -->
<div> <div>
@ -130,5 +147,15 @@ const handleLogout = () => {
<footer class="bg-base-300 border-t border-base-content/10"> <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> </footer>
<!-- Policy Modal -->
<PolicyModal
:is-open="modalState.isOpen"
:title="modalState.title"
:path="modalState.path"
:operation="modalState.operation"
:original-error="modalState.originalError"
@close="closePolicyModal"
/>
</div> </div>
</template> </template>

View File

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref } from 'vue'
import type { VaultConnection } from '../types' import type { VaultConnection } from '../types'
import { vaultApi, VaultError } from '../services/vaultApi'
import PathSearch from './PathSearch.vue' import PathSearch from './PathSearch.vue'
import Settings from './Settings.vue' import Settings from './Settings.vue'
import SecretModal from './SecretModal.vue' import SecretModal from './SecretModal.vue'
@ -15,125 +14,19 @@ const emit = defineEmits<{
logout: [] logout: []
}>() }>()
const selectedMountPoint = ref('')
const secretPath = ref('')
const secretData = ref<Record<string, unknown> | null>(null)
const isLoading = ref(false)
const showSettings = ref(false) const showSettings = ref(false)
const showSearch = ref(true) // Show search by default
const showSecretModal = ref(false) const showSecretModal = ref(false)
const selectedSecretPath = ref('') const selectedSecretPath = ref('')
// Select first mount point by default
onMounted(() => {
if (props.connection.mountPoints && props.connection.mountPoints.length > 0) {
selectedMountPoint.value = props.connection.mountPoints[0].path
}
})
const handleReadSecret = async (path?: string) => {
let pathToRead = path
if (!pathToRead) {
// Build path from mount point + secret path
if (!selectedMountPoint.value || !secretPath.value) {
alert('Please select a mount point and enter a secret path')
return
}
pathToRead = `${selectedMountPoint.value}/${secretPath.value}`
}
isLoading.value = true
secretData.value = null
try {
const data = await vaultApi.readSecret(props.connection.server, props.connection.credentials, pathToRead)
if (data) {
secretData.value = data
// Update the form fields if this was a manual read
if (!path) {
// Keep the current mount point and path
}
} else {
alert('Secret not found or empty.')
}
} catch (error) {
console.error('Error reading secret:', error)
if (error instanceof VaultError) {
let message = `Failed to read secret: ${error.message}`
if (error.statusCode) {
message += ` (HTTP ${error.statusCode})`
}
if (error.errors && error.errors.length > 0) {
message += `\n\nDetails:\n${error.errors.join('\n')}`
}
if (error.statusCode === 403) {
message += '\n\nYou may not have permission to read this secret.'
} else if (error.statusCode === 404) {
message = 'Secret not found at this path.'
} else if (error.message.includes('CORS')) {
message += '\n\nCORS error: Make sure your Vault server is configured to allow requests from this origin.'
}
alert(message)
} else {
alert('Failed to read secret. Check console for details.')
}
} finally {
isLoading.value = false
}
}
const handleSelectPath = (path: string) => { const handleSelectPath = (path: string) => {
// Parse the path to extract mount point and secret path // Open the selected secret in the modal
const mountPoints = props.connection.mountPoints || []
let foundMount = ''
let remainingPath = path
// Find the longest matching mount point
for (const mount of mountPoints) {
const mountPath = mount.path + '/'
if (path.startsWith(mountPath)) {
if (mountPath.length > foundMount.length) {
foundMount = mount.path
remainingPath = path.substring(mountPath.length)
}
}
}
if (foundMount) {
selectedMountPoint.value = foundMount
secretPath.value = remainingPath
}
// Open secret in modal instead of inline
selectedSecretPath.value = path selectedSecretPath.value = path
showSecretModal.value = true showSecretModal.value = true
} }
const handleKeyPress = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !isLoading.value) {
handleViewSecret()
}
}
const handleViewSecret = () => {
if (!selectedMountPoint.value || !secretPath.value) {
alert('Please select a mount point and enter a secret path')
return
}
const fullPath = `${selectedMountPoint.value}/${secretPath.value}`
selectedSecretPath.value = fullPath
showSecretModal.value = true
}
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="container mx-auto p-6 space-y-6">
<!-- Header --> <!-- Header -->
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
@ -152,10 +45,6 @@ const handleViewSecret = () => {
</p> </p>
</div> </div>
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<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"> <button class="btn btn-sm" @click="showSettings = true">
<i class="mdi mdi-cog mr-2" /> <i class="mdi mdi-cog mr-2" />
Settings Settings
@ -171,108 +60,12 @@ const handleViewSecret = () => {
<!-- Search Component --> <!-- Search Component -->
<PathSearch <PathSearch
v-if="showSearch"
:server="connection.server" :server="connection.server"
:credentials="connection.credentials" :credentials="connection.credentials"
:mount-points="connection.mountPoints" :mount-points="connection.mountPoints"
@select-path="handleSelectPath" @select-path="handleSelectPath"
/> />
<!-- Secret Browser -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="text-xl font-bold mb-4">Browse Secrets</h3>
<!-- Mount Point Selector -->
<div class="form-control">
<label class="label">
<span class="label-text">Mount Point</span>
</label>
<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>
</select>
</div>
<!-- Secret Path Input -->
<div class="form-control">
<label class="label">
<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>
<input
v-model="secretPath"
type="text"
placeholder="data/myapp/config"
class="input input-bordered join-item flex-1"
:disabled="isLoading || !selectedMountPoint"
@keypress="handleKeyPress"
/>
<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' }}
</span>
</label>
</div>
<!-- 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" />
</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>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>
</div>
</div>
<!-- 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"
/>
</svg>
<div class="text-xs">
<h4 class="font-semibold">Security & Caching</h4>
<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 Modal -->
<Settings v-if="showSettings" @close="showSettings = false" /> <Settings v-if="showSettings" @close="showSettings = false" />

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import type { VaultServer, VaultCredentials } from '../types' import type { VaultServer, VaultCredentials } from '../types'
import { useSweetAlert } from '../composables/useSweetAlert'
interface Props { interface Props {
server: VaultServer server: VaultServer
@ -11,6 +12,8 @@ const emit = defineEmits<{
login: [credentials: VaultCredentials, saveCredentials: boolean] login: [credentials: VaultCredentials, saveCredentials: boolean]
}>() }>()
const { error } = useSweetAlert()
const authMethod = ref<'token' | 'userpass' | 'ldap'>('token') const authMethod = ref<'token' | 'userpass' | 'ldap'>('token')
const token = ref('') const token = ref('')
const username = ref('') const username = ref('')
@ -74,9 +77,9 @@ const performLogin = async () => {
try { try {
await emit('login', credentials, saveCredentials.value) await emit('login', credentials, saveCredentials.value)
} catch (error) { } catch (err) {
console.error('Login error:', error) console.error('Login error:', err)
alert('Login failed. Please check your credentials.') error('Login failed. Please check your credentials.')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }

View File

@ -2,6 +2,9 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { VaultServer, VaultCredentials, MountPoint } from '../types' import type { VaultServer, VaultCredentials, MountPoint } from '../types'
import { vaultApi, type SearchResult } from '../services/vaultApi' import { vaultApi, type SearchResult } from '../services/vaultApi'
import { VaultError } from '../services/vaultClient'
import { useSweetAlert } from '../composables/useSweetAlert'
import { usePolicyModal } from '../composables/usePolicyModal'
interface Props { interface Props {
server: VaultServer server: VaultServer
@ -14,10 +17,14 @@ const emit = defineEmits<{
selectPath: [path: string] selectPath: [path: string]
}>() }>()
const { warning, error } = useSweetAlert()
const { showPolicyModal } = usePolicyModal()
const searchTerm = ref('') const searchTerm = ref('')
const results = ref<SearchResult[]>([]) const results = ref<SearchResult[]>([])
const isSearching = ref(false) const isSearching = ref(false)
const searchTime = ref<number | null>(null) const searchTime = ref<number | null>(null)
const searchProgress = ref({ current: 0, total: 0 })
// Debug: Log mount points // Debug: Log mount points
console.log('PathSearch - mountPoints:', props.mountPoints) console.log('PathSearch - mountPoints:', props.mountPoints)
@ -28,24 +35,35 @@ const mountPointsAvailable = computed(() => {
const handleSearch = async () => { const handleSearch = async () => {
if (!searchTerm.value.trim()) { if (!searchTerm.value.trim()) {
alert('Please enter a search term') warning('Please enter a search term')
return return
} }
if (!mountPointsAvailable.value) { if (!mountPointsAvailable.value) {
alert('No mount points available. Please ensure you are connected to Vault.') warning(
'No mount points available. Please ensure you are connected to Vault. If you are connected, ensure you have some permissions to see some secrets!'
)
return return
} }
isSearching.value = true isSearching.value = true
results.value = [] results.value = []
searchTime.value = null searchTime.value = null
searchProgress.value = { current: 0, total: 0 }
const startTime = performance.now() const startTime = performance.now()
try { try {
// Always search across all mount points // Always search across all mount points with progress tracking
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,
(pathsSearched: number) => {
searchProgress.value.current += pathsSearched
}
)
const endTime = performance.now() const endTime = performance.now()
searchTime.value = endTime - startTime searchTime.value = endTime - startTime
@ -56,11 +74,17 @@ const handleSearch = async () => {
} }
console.log(results.value.length) console.log(results.value.length)
} catch (error) { } catch (err) {
console.error('Search error:', error) console.error('Search error:', err)
alert('Search failed. Check console for details.') if (err instanceof VaultError && err.statusCode === 403) {
// Use a generic path for search operations since we're searching across multiple mounts
showPolicyModal('*/metadata/*', 'list', err.message, 'Permission Denied - Cannot Search Secrets')
} else {
error('Search failed. Check console for details.')
}
} finally { } finally {
isSearching.value = false isSearching.value = false
searchProgress.value = { current: 0, total: 0 }
} }
} }
@ -125,7 +149,12 @@ const handleKeyPress = (event: KeyboardEvent) => {
<!-- Search Progress --> <!-- Search Progress -->
<div v-if="isSearching" class="alert mt-4"> <div v-if="isSearching" class="alert mt-4">
<span class="loading loading-spinner" /> <span class="loading loading-spinner" />
<span>Searching recursively... This may take a moment.</span> <div class="flex flex-col">
<span>Searching recursively... This may take a moment.</span>
<span v-if="searchProgress.current > 0" class="text-sm opacity-70 mt-1">
Paths searched: <strong>{{ searchProgress.current }}</strong>
</span>
</div>
</div> </div>
<!-- Search Results --> <!-- Search Results -->

View File

@ -0,0 +1,185 @@
<template>
<div v-if="isOpen" class="modal modal-open">
<div class="modal-box w-11/12 max-w-4xl">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg text-error flex items-center">
<i class="mdi mdi-shield-alert mr-2"></i>
{{ title }}
</h3>
<button class="btn btn-sm btn-circle btn-ghost" @click="close">
<i class="mdi mdi-close"></i>
</button>
</div>
<!-- Content -->
<div class="space-y-4">
<!-- Error message -->
<div class="alert alert-error">
<i class="mdi mdi-alert-circle"></i>
<span>{{ originalError }}</span>
</div>
<!-- Policy guidance -->
<div class="space-y-3">
<h4 class="font-semibold text-base">Required Vault Policy Permissions:</h4>
<p class="text-sm text-base-content/70">You need the following permissions in your Vault policy to {{ policyContent.description }}:</p>
<pre><code>{{ policyContent.combinedCode }}</code></pre>
</div>
<!-- Instructions -->
<div class="alert alert-info">
<i class="mdi mdi-information"></i>
<div>
<div class="font-semibold">Next Steps:</div>
<div class="text-sm mt-1">Copy the policy code above and ask your Vault administrator to add these permissions to your policy.</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="modal-action">
<button class="btn btn-primary" @click="close">
<i class="mdi mdi-check mr-2"></i>
Got it
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
interface Props {
isOpen: boolean
title?: string
path: string
operation: 'read' | 'write' | 'delete' | 'list'
originalError: string
}
const props = withDefaults(defineProps<Props>(), {
title: 'Permission Denied',
})
const emit = defineEmits<{
close: []
}>()
const close = () => {
emit('close')
}
// Debug logging
watch(
() => props.isOpen,
newValue => {
console.log('🔍 PolicyModal isOpen changed:', newValue)
}
)
watch(
() => [props.path, props.operation, props.originalError],
([path, operation, originalError]) => {
console.log('🔍 PolicyModal props changed:', { path, operation, originalError })
}
)
// Generate policy examples directly
const policyContent = computed(() => {
const pathParts = props.path.split('/')
const mountPoint = pathParts[0]
const secretPath = pathParts.slice(1).join('/')
let description: string
let examples: string[] = []
switch (props.operation) {
case 'read':
description = 'read secrets'
examples = [
`# Specific secret
path "${mountPoint}/data/${secretPath || '*'}" {
capabilities = ["read"]
}`,
`# All secrets in mount
path "${mountPoint}/data/*" {
capabilities = ["read"]
}`,
]
break
case 'write':
description = 'create and update secrets'
examples = [
`# Specific secret
path "${mountPoint}/data/${secretPath || '*'}" {
capabilities = ["create", "update"]
}`,
`# All secrets in mount
path "${mountPoint}/data/*" {
capabilities = ["create", "update"]
}`,
]
break
case 'delete':
description = 'delete secrets'
examples = [
`# Specific secret
path "${mountPoint}/data/${secretPath || '*'}" {
capabilities = ["delete"]
}`,
`# All secrets in mount
path "${mountPoint}/data/*" {
capabilities = ["delete"]
}`,
`# For KV v2: also need metadata delete permissions
path "${mountPoint}/metadata/${secretPath || '*'}" {
capabilities = ["delete"]
}`,
]
break
case 'list':
description = 'list secrets'
examples = [
`# List secrets in mount
path "${mountPoint}/metadata/*" {
capabilities = ["list"]
}`,
`# List specific path
path "${mountPoint}/metadata/${secretPath || '*'}" {
capabilities = ["list"]
}`,
]
break
}
return {
description,
combinedCode: '\n' + examples.join('\n\n'),
}
})
// Close modal on Escape key
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.isOpen) {
close()
}
}
watch(
() => props.isOpen,
isOpen => {
if (isOpen) {
document.addEventListener('keydown', handleKeydown)
} else {
document.removeEventListener('keydown', handleKeydown)
}
}
)
</script>

View File

@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import type { VaultServer, VaultCredentials } from '../types' import type { VaultServer, VaultCredentials } from '../types'
import { vaultApi, VaultError } from '../services/vaultApi' import { VaultClient, VaultError } from '../services/vaultClient'
import { useSweetAlert } from '../composables/useSweetAlert'
import { usePolicyModal } from '../composables/usePolicyModal'
interface Props { interface Props {
server: VaultServer server: VaultServer
@ -15,15 +17,28 @@ const emit = defineEmits<{
}>() }>()
const secretData = ref<Record<string, unknown> | null>(null) const secretData = ref<Record<string, unknown> | null>(null)
const secretVersion = ref<number>(null) const secretVersion = ref<number | null>(null)
const secretMetadata = ref<any>(null) const secretMetadata = ref<Record<string, unknown> | null>(null)
const secretVersions = ref<any[]>([]) const secretVersions = ref<Array<{ version: number; created_time: string; destroyed: boolean; deletion_time?: string }>>([])
const vaultClient = ref<VaultClient | null>(null)
const { success, error: showError, confirm } = useSweetAlert()
const { showPolicyModal } = usePolicyModal()
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const activeTab = ref<'current' | 'json' | 'metadata' | 'versions'>('current') const activeTab = ref<'current' | 'json' | 'metadata' | 'versions' | 'edit'>('current')
const visibleValues = ref<Record<string, boolean>>({}) const visibleValues = ref<Record<string, boolean>>({})
const isEditing = ref(false)
const isSaving = ref(false)
const editedData = ref<Record<string, string>>({})
onMounted(() => { onMounted(() => {
// Initialize VaultClient
vaultClient.value = new VaultClient({
server: props.server,
credentials: props.credentials,
kvVersion: 2, // Default to KV v2, will be auto-detected
})
loadSecret() loadSecret()
}) })
@ -37,59 +52,49 @@ const isLatestVersion = computed<boolean>(() => {
return secretMetadata.value.current_version == secretVersion.value return secretMetadata.value.current_version == secretVersion.value
}) })
const loadSecret = async () => { // Generate Vault server UI URL for this secret
const vaultServerUrl = computed(() => {
const baseUrl = props.server.url.replace(/\/$/, '') // Remove trailing slash
// Vault UI uses the path format: /ui/vault/secrets/{mount}/show/{path}
const pathParts = props.secretPath.split('/')
const mountPoint = pathParts[0]
const secretPath = pathParts.slice(1).join('/')
return `${baseUrl}/ui/vault/secrets/${mountPoint}/show/${secretPath}`
})
const loadSecret = async (version?: number) => {
if (!vaultClient.value) return
isLoading.value = true isLoading.value = true
error.value = null error.value = null
try { try {
// Load current secret data // Load secret data using VaultClient (with optional version)
const response = await vaultApi.readSecret(props.server, props.credentials, props.secretPath) const data = await vaultClient.value.read(props.secretPath, version)
secretData.value = data
// For KV v2, the response includes both data and metadata if (version) {
if (response && typeof response === 'object') { // If loading a specific version, set it and switch to current tab
// Extract secret data (usually under 'data' key) secretVersion.value = version
secretData.value = response.data || response activeTab.value = 'current'
// Extract metadata if present in the response
if (response.metadata) {
secretMetadata.value = {
...response.metadata,
// Add any additional metadata fields from the response root
current_version: response.metadata.version,
created_time: response.metadata.created_time,
updated_time: response.metadata.created_time, // KV v2 doesn't have separate updated_time in single secret response
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(),
destroyed: response.metadata.destroyed,
deletion_time: response.metadata.deletion_time,
},
]
}
}
} else { } else {
secretData.value = response // If loading current version, also load metadata and version history
secretVersion.value = null await loadMetadataAndVersions()
} }
// Try to load full metadata and version history from metadata endpoint
await loadMetadataAndVersions()
} catch (err) { } catch (err) {
console.error('Error loading secret:', err) console.error('Error loading secret:', err)
if (err instanceof VaultError) { if (err instanceof VaultError) {
error.value = `${err.message} (HTTP ${err.statusCode || 'Unknown'})` console.log('🔍 VaultError caught, statusCode:', err.statusCode)
if (err.errors && err.errors.length > 0) { if (err.statusCode === 403) {
error.value += `\n\nDetails:\n${err.errors.join('\n')}` console.log('🔍 403 error detected, showing policy modal')
showPolicyModal(props.secretPath, 'read', err.message, 'Permission Denied - Cannot Read Secret')
error.value = 'Permission denied. Check the policy guidance modal for details.'
} else {
error.value = `${err.message} (HTTP ${err.statusCode || 'Unknown'})`
if (err.errors && err.errors.length > 0) {
error.value += `\n\nDetails:\n${err.errors.join('\n')}`
}
} }
} else { } else {
error.value = err instanceof Error ? err.message : 'Unknown error' error.value = err instanceof Error ? err.message : 'Unknown error'
@ -100,71 +105,49 @@ const loadSecret = async () => {
} }
const loadMetadataAndVersions = async () => { const loadMetadataAndVersions = async () => {
if (!vaultClient.value) return
try { try {
// Use the dedicated readSecretMetadata method from VaultApi // Use VaultClient to read metadata
const fullMetadata = await vaultApi.readSecretMetadata(props.server, props.credentials, props.secretPath) const fullMetadata = await vaultClient.value.readMetadata(props.secretPath)
if (fullMetadata) { if (fullMetadata) {
console.log('Full metadata response:', fullMetadata) console.log('Full metadata response:', fullMetadata)
// Merge with existing metadata or replace it // Set the metadata
secretMetadata.value = { secretMetadata.value = fullMetadata
...secretMetadata.value, // Keep any metadata from the secret response
...fullMetadata, // Override with full metadata
}
// Extract complete version history from full metadata // Extract complete version history from full metadata
if (fullMetadata.versions) { if (fullMetadata.versions) {
secretVersions.value = Object.entries(fullMetadata.versions) secretVersions.value = Object.entries(fullMetadata.versions)
.map(([version, versionData]: [string, any]) => ({ .map(([version, versionData]) => ({
version: parseInt(version), version: parseInt(version),
...versionData, created_time: new Date(versionData.created_time).toLocaleString('eu-CH'),
created_time: new Date(versionData.created_time).toLocaleString(), destroyed: versionData.destroyed,
deletion_time: versionData.deletion_time,
})) }))
.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 // Set current version from metadata
secretVersions.value = [ secretVersion.value = fullMetadata.current_version
{
version: secretMetadata.value.current_version,
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) { } catch (err) {
console.warn('Could not load full metadata (using basic metadata from secret response):', err) console.warn('Could not load full metadata (KV v1 or metadata not available):', err)
// If we can't load full metadata, we'll use what we extracted from the secret response // If we can't load metadata, it might be KV v1 or metadata endpoint not available
} secretMetadata.value = null
} secretVersions.value = []
secretVersion.value = null
const loadVersion = async (version: number) => {
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
secretVersion.value = version
activeTab.value = 'current'
} catch (err) {
console.error('Error loading version:', err)
error.value = err instanceof Error ? err.message : 'Unknown error'
} finally {
isLoading.value = false
} }
} }
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
// Could add a toast notification here success('Copied to clipboard!')
} catch (err) { } catch (err) {
console.error('Failed to copy:', err) console.error('Failed to copy:', err)
showError('Failed to copy to clipboard')
} }
} }
@ -196,12 +179,104 @@ const toggleAllValues = () => {
visibleValues.value[key] = !hasVisibleValues visibleValues.value[key] = !hasVisibleValues
}) })
} }
const startEdit = () => {
if (!secretData.value) return
// Initialize edited data with current secret data (convert all values to strings for editing)
editedData.value = {}
Object.entries(secretData.value).forEach(([key, value]) => {
editedData.value[key] = typeof value === 'string' ? value : JSON.stringify(value)
})
isEditing.value = true
activeTab.value = 'edit'
}
const cancelEdit = () => {
isEditing.value = false
editedData.value = {}
activeTab.value = 'current'
}
const addNewField = () => {
const newKey = `new_field_${Date.now()}`
editedData.value[newKey] = ''
}
const removeField = (key: string) => {
delete editedData.value[key]
}
const saveSecret = async () => {
if (!vaultClient.value || !secretData.value) return
// Validate that we have at least one field
if (Object.keys(editedData.value).length === 0) {
showError('Cannot save empty secret')
return
}
// Validate that all keys are non-empty
const emptyKeys = Object.keys(editedData.value).filter(key => !key.trim())
if (emptyKeys.length > 0) {
showError('All field names must be non-empty')
return
}
const result = await confirm(
'This will create a new version of the secret. The previous version will still be available in the version history.',
'Save Secret Changes?'
)
if (!result.isConfirmed) return
isSaving.value = true
try {
// Convert edited data to proper types (try to parse JSON, fallback to string)
const dataToSave: Record<string, unknown> = {}
Object.entries(editedData.value).forEach(([key, value]) => {
try {
// Try to parse as JSON first
dataToSave[key] = JSON.parse(value)
} catch {
// If parsing fails, keep as string
dataToSave[key] = value
}
})
// Save the secret using VaultClient
await vaultClient.value.write(props.secretPath, dataToSave)
success('Secret saved successfully! New version created.')
// Reload the secret to show the new version
isEditing.value = false
editedData.value = {}
activeTab.value = 'current'
await loadSecret()
} catch (err) {
console.error('Error saving secret:', err)
if (err instanceof VaultError) {
if (err.statusCode === 403) {
showPolicyModal(props.secretPath, 'write', err.message, 'Permission Denied - Cannot Write Secret')
} else {
showError(`Failed to save secret: ${err.message}`)
}
} else {
showError('Failed to save secret. Check console for details.')
}
} finally {
isSaving.value = false
}
}
</script> </script>
<template> <template>
<!-- Modal Overlay --> <!-- 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-6xl max-h-[90vh] overflow-hidden flex flex-col"> <div class="modal-box max-w-6xl max-h-[90vh] h-[50vh] overflow-hidden flex flex-col">
<!-- Header --> <!-- Header -->
<div class="flex justify-between items-start mb-4 flex-shrink-0"> <div class="flex justify-between items-start mb-4 flex-shrink-0">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@ -272,12 +347,6 @@ const toggleAllValues = () => {
<div v-if="activeTab === 'current'" class="h-full flex flex-col"> <div v-if="activeTab === 'current'" class="h-full flex flex-col">
<div class="flex justify-between items-center mb-3 flex-shrink-0"> <div class="flex justify-between items-center mb-3 flex-shrink-0">
<h3 class="font-semibold">Secret Data</h3> <h3 class="font-semibold">Secret Data</h3>
<div class="flex gap-2">
<button class="btn btn-sm btn-outline" @click="toggleAllValues">
<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>
<div class="flex-1 overflow-auto"> <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">
@ -294,8 +363,10 @@ const toggleAllValues = () => {
<td class="font-mono font-semibold"> <td class="font-mono font-semibold">
{{ key }} {{ key }}
</td> </td>
<td class="font-mono text-sm"> <td class="font-mono text-sm max-w-0">
<span class="select-all">{{ getDisplayValue(key, value) }}</span> <div class="overflow-auto max-h-32 whitespace-pre-wrap break-words">
<span class="select-all">{{ getDisplayValue(key, value) }}</span>
</div>
</td> </td>
<td> <td>
<div class="flex gap-1"> <div class="flex gap-1">
@ -329,19 +400,23 @@ const toggleAllValues = () => {
<div v-else-if="activeTab === 'json'" class="h-full flex flex-col"> <div v-else-if="activeTab === 'json'" class="h-full flex flex-col">
<div class="flex justify-between items-center mb-3 flex-shrink-0"> <div class="flex justify-between items-center mb-3 flex-shrink-0">
<h3 class="font-semibold">JSON Data</h3> <h3 class="font-semibold">JSON Data</h3>
<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>
<div class="flex-1 overflow-auto"> <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 overflow-auto whitespace-pre-wrap break-words">{{
JSON.stringify(secretData, null, 2)
}}</pre>
</div> </div>
</div> </div>
<!-- Metadata Tab --> <!-- 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 justify-between items-center mb-3 flex-shrink-0">
<h3 class="font-semibold">Secret Metadata</h3>
<a :href="vaultServerUrl" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline btn-primary" title="Open in Vault UI">
<i class="mdi mdi-open-in-new mr-1"></i>
Open in Vault UI
</a>
</div>
<div class="flex-1 overflow-auto"> <div class="flex-1 overflow-auto">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="card bg-base-200"> <div class="card bg-base-200">
@ -362,11 +437,11 @@ const toggleAllValues = () => {
</div> </div>
<div> <div>
<strong>Created:</strong> <strong>Created:</strong>
{{ secretMetadata.created_time ? new Date(secretMetadata.created_time).toLocaleString() : 'N/A' }} {{ secretMetadata.created_time ? new Date(secretMetadata.created_time).toLocaleString('eu-CH') : 'N/A' }}
</div> </div>
<div> <div>
<strong>Updated:</strong> <strong>Updated:</strong>
{{ secretMetadata.updated_time ? new Date(secretMetadata.updated_time).toLocaleString() : 'N/A' }} {{ secretMetadata.updated_time ? new Date(secretMetadata.updated_time).toLocaleString('eu-CH') : 'N/A' }}
</div> </div>
</div> </div>
</div> </div>
@ -386,7 +461,9 @@ const toggleAllValues = () => {
</div> </div>
<div v-if="secretMetadata.custom_metadata"> <div v-if="secretMetadata.custom_metadata">
<strong>Custom Metadata:</strong> <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 overflow-auto max-h-32 whitespace-pre-wrap break-words">{{
JSON.stringify(secretMetadata.custom_metadata, null, 2)
}}</pre>
</div> </div>
</div> </div>
</div> </div>
@ -396,7 +473,9 @@ const toggleAllValues = () => {
<div class="card bg-base-200"> <div class="card bg-base-200">
<div class="card-body p-4"> <div class="card-body p-4">
<h4 class="font-semibold text-sm mb-2">Raw Metadata</h4> <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 whitespace-pre-wrap break-words max-h-60">{{
JSON.stringify(secretMetadata, null, 2)
}}</pre>
</div> </div>
</div> </div>
</div> </div>
@ -418,15 +497,87 @@ const toggleAllValues = () => {
<p class="text-sm opacity-70">Created: {{ version.created_time }}</p> <p class="text-sm opacity-70">Created: {{ version.created_time }}</p>
<p v-if="version.deletion_time" class="text-sm opacity-70"> <p v-if="version.deletion_time" class="text-sm opacity-70">
Deleted: Deleted:
{{ new Date(version.deletion_time).toLocaleString() }} {{ new Date(version.deletion_time).toLocaleString('eu-CH') }}
</p> </p>
</div> </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="loadSecret(version.version)">View Version</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Edit Tab -->
<div v-else-if="activeTab === 'edit'" class="h-full flex flex-col">
<div class="flex justify-between items-center mb-3 flex-shrink-0">
<h3 class="font-semibold">Edit Secret</h3>
</div>
<div class="alert alert-info mb-4">
<i class="mdi mdi-information" />
<div class="text-sm">
<p><strong>Editing Secret:</strong> Changes will create a new version of this secret.</p>
<p>Previous versions will remain available in the version history.</p>
</div>
</div>
<div class="flex-1 overflow-auto">
<div class="space-y-3">
<div v-for="(value, key) in editedData" :key="key" class="card bg-base-200">
<div class="card-body p-4">
<div class="flex gap-3 items-start">
<div class="form-control flex-1">
<label class="label">
<span class="label-text font-semibold">Field Name</span>
</label>
<input
:value="key"
type="text"
class="input input-bordered input-sm"
:placeholder="key"
@input="
e => {
const newKey = (e.target as HTMLInputElement).value
if (newKey !== key && newKey.trim()) {
const value = editedData[key]
delete editedData[key]
editedData[newKey] = value
}
}
"
/>
</div>
<div class="form-control flex-[2]">
<label class="label">
<span class="label-text font-semibold">Value</span>
</label>
<textarea
v-model="editedData[key]"
class="textarea textarea-bordered textarea-sm resize-y"
rows="1"
:placeholder="'Enter value for ' + key"
/>
</div>
<button class="btn btn-error btn-sm mt-8" @click="removeField(key)">
<i class="mdi mdi-delete" />
</button>
</div>
</div>
</div>
<div class="flex gap-3 items-start">
<button class="btn btn-outline flex-grow" @click="addNewField">
<i class="mdi mdi-plus mr-2" />
Add Field
</button>
</div>
<div v-if="Object.keys(editedData).length === 0" class="text-center py-8 text-base-content/60">
<i class="mdi mdi-folder-open text-4xl mb-2" />
<p>No fields to edit. Click "Add Field" to create a new field.</p>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
@ -436,6 +587,37 @@ const toggleAllValues = () => {
<p><i class="mdi mdi-lock mr-1" />Secret data is never cached - always fetched fresh</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> <p><i class="mdi mdi-chart-line mr-1" />KV v2: Metadata and version history available</p>
</div> </div>
<template v-if="activeTab === 'edit'">
<button class="btn btn-success" :disabled="isSaving" @click="saveSecret">
<span v-if="isSaving" class="loading loading-spinner loading-xs mr-2" />
<i v-else class="mdi mdi-content-save mr-2" />
{{ isSaving ? 'Saving...' : 'Save Changes' }}
</button>
<button class="btn btn-ghost" @click="cancelEdit">
<i class="mdi mdi-close mr-2" />
Cancel
</button>
</template>
<template v-else-if="activeTab === 'current'">
<button v-if="isLatestVersion" class="btn btn-primary" @click="startEdit">
<i class="mdi mdi-pencil mr-2" />
Edit Secret
</button>
<button class="btn btn-outline" @click="toggleAllValues">
<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>
</template>
<template v-else-if="activeTab === 'json'">
<button class="btn btn-outline" @click="copyToClipboard(JSON.stringify(secretData, null, 2))">
<i class="mdi mdi-content-copy mr-2" />
Copy JSON
</button>
</template>
<button class="btn" @click="emit('close')">Close</button> <button class="btn" @click="emit('close')">Close</button>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import type { VaultServer } from '../types' import type { VaultServer } from '../types'
import { useSweetAlert } from '../composables/useSweetAlert'
interface Props { interface Props {
servers: VaultServer[] servers: VaultServer[]
@ -14,6 +15,8 @@ const emit = defineEmits<{
selectServer: [server: VaultServer] selectServer: [server: VaultServer]
}>() }>()
const { confirm } = useSweetAlert()
const showAddForm = ref(false) const showAddForm = ref(false)
const newServer = ref({ const newServer = ref({
name: '', name: '',
@ -36,8 +39,9 @@ const handleSubmit = () => {
showAddForm.value = false showAddForm.value = false
} }
const handleRemove = (serverId: string, serverName: string) => { const handleRemove = async (serverId: string, serverName: string) => {
if (confirm(`Remove server "${serverName}"?`)) { const result = await confirm(`Remove server "${serverName}"?`, 'Remove Server')
if (result.isConfirmed) {
emit('removeServer', serverId) emit('removeServer', serverId)
} }
} }

View File

@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
import type { AppConfig } from '../config' import type { AppConfig } from '../config'
import { loadConfig, saveConfig } from '../config' import { loadConfig, saveConfig } from '../config'
import { vaultCache } from '../utils/cache' import { vaultCache } from '../utils/cache'
import { useSweetAlert } from '../composables/useSweetAlert'
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
@ -10,6 +11,7 @@ const emit = defineEmits<{
const config = ref<AppConfig>(loadConfig()) const config = ref<AppConfig>(loadConfig())
const cacheStats = ref(vaultCache.getStats()) const cacheStats = ref(vaultCache.getStats())
const { success, confirm } = useSweetAlert()
let intervalId: number | null = null let intervalId: number | null = null
@ -28,15 +30,16 @@ onUnmounted(() => {
const handleSave = () => { const handleSave = () => {
saveConfig(config.value) saveConfig(config.value)
alert('Settings saved successfully!') success('Settings saved successfully!')
emit('close') emit('close')
} }
const handleClearCache = () => { const handleClearCache = async () => {
if (confirm('Are you sure you want to clear the cache?')) { const result = await confirm('This will remove all cached data. Are you sure?', 'Clear Cache?')
if (result.isConfirmed) {
vaultCache.clear() vaultCache.clear()
cacheStats.value = vaultCache.getStats() cacheStats.value = vaultCache.getStats()
alert('Cache cleared successfully!') success('Cache cleared successfully!')
} }
} }
@ -50,7 +53,7 @@ const formatBytes = (bytes: number): string => {
const formatDate = (timestamp: number | null): string => { const formatDate = (timestamp: number | null): string => {
if (!timestamp) return 'N/A' if (!timestamp) return 'N/A'
return new Date(timestamp).toLocaleString() return new Date(timestamp).toLocaleString('eu-CH')
} }
</script> </script>
@ -99,16 +102,15 @@ const formatDate = (timestamp: number | null): string => {
<span class="label-text">Cache expiration (minutes)</span> <span class="label-text">Cache expiration (minutes)</span>
</label> </label>
<input <input
v-model.number="config.cache.maxAge" v-model.number="config.cache.maxAgeMinutes"
type="number" type="number"
min="1" min="0"
max="1440" max="1440"
class="input input-bordered w-full" class="input input-bordered w-full"
:model-value="Math.round(config.cache.maxAge / 1000 / 60)" placeholder="30 (default)"
@update:model-value="config.cache.maxAge = ($event || 30) * 60 * 1000"
/> />
<label class="label"> <label class="label">
<span class="label-text-alt">How long cached entries remain valid</span> <span class="label-text-alt">How long cached entries remain valid (empty = 30 minutes)</span>
</label> </label>
</div> </div>

View File

@ -0,0 +1,53 @@
import { ref } from 'vue'
interface PolicyModalState {
isOpen: boolean
title: string
path: string
operation: 'read' | 'write' | 'delete' | 'list'
originalError: string
}
const modalState = ref<PolicyModalState>({
isOpen: false,
title: '',
path: '',
operation: 'read',
originalError: '',
})
export function usePolicyModal() {
const showPolicyModal = (
path: string,
operation: 'read' | 'write' | 'delete' | 'list',
originalError: string,
title: string = 'Permission Denied'
) => {
console.log('🔍 showPolicyModal called:', { title, path, operation, originalError })
modalState.value = {
isOpen: true,
title,
path,
operation,
originalError,
}
console.log('🔍 modalState updated:', modalState.value.isOpen)
}
const closePolicyModal = () => {
console.log('🔍 Closing policy modal')
modalState.value = {
isOpen: false,
title: '',
path: '',
operation: 'read',
originalError: '',
}
}
return {
modalState,
showPolicyModal,
closePolicyModal,
}
}

View File

@ -0,0 +1,87 @@
import Swal from 'sweetalert2'
export function useSweetAlert() {
// Success notification
const success = (message: string, title: string = 'Success!') => {
return Swal.fire({
icon: 'success',
title,
text: message,
timer: 3000,
timerProgressBar: true,
showConfirmButton: false,
toast: true,
position: 'top-end',
})
}
// Error notification
const error = (message: string, title: string = 'Error!') => {
return Swal.fire({
icon: 'error',
title,
text: message,
timer: 5000,
timerProgressBar: true,
showConfirmButton: true,
toast: true,
position: 'top-end',
})
}
// Warning notification
const warning = (message: string, title: string = 'Warning!') => {
return Swal.fire({
icon: 'warning',
title,
text: message,
timer: 4000,
timerProgressBar: true,
showConfirmButton: false,
toast: true,
position: 'top-end',
})
}
// Info notification
const info = (message: string, title: string = 'Info') => {
return Swal.fire({
icon: 'info',
title,
text: message,
timer: 3000,
timerProgressBar: true,
showConfirmButton: false,
toast: true,
position: 'top-end',
})
}
// Confirmation dialog
const confirm = (message: string, title: string = 'Are you sure?') => {
return Swal.fire({
icon: 'question',
title,
text: message,
showCancelButton: true,
confirmButtonText: 'Yes',
cancelButtonText: 'No',
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
})
}
// Generic alert (for complex cases)
const alert = (options: Parameters<typeof Swal.fire>[0]) => {
return Swal.fire(options)
}
return {
success,
error,
warning,
info,
confirm,
alert,
}
}

View File

@ -2,7 +2,7 @@
export interface AppConfig { export interface AppConfig {
cache: { cache: {
maxSizeMB: number // Maximum cache size in megabytes maxSizeMB: number // Maximum cache size in megabytes
maxAge: number // Maximum age of cache entries in milliseconds maxAgeMinutes: number // Maximum age of cache entries in minutes (0 = 30 minutes default)
enabled: boolean enabled: boolean
} }
search: { search: {
@ -15,7 +15,7 @@ export interface AppConfig {
export const defaultConfig: AppConfig = { export const defaultConfig: AppConfig = {
cache: { cache: {
maxSizeMB: 10, // 10 MB default maxSizeMB: 10, // 10 MB default
maxAge: 1000 * 60 * 30, // 30 minutes maxAgeMinutes: 0, // 0 = 30 minutes default (empty field)
enabled: true, enabled: true,
}, },
search: { search: {

View File

@ -2,6 +2,7 @@ import { VaultServer, VaultCredentials, MountPoint } from '../types'
import { vaultCache } from '../utils/cache' import { vaultCache } from '../utils/cache'
import { loadConfig } from '../config' import { loadConfig } from '../config'
import { VaultClient, VaultError } from './vaultClient' import { VaultClient, VaultError } from './vaultClient'
import { generate403PolicyGuidance } from '../utils/vaultPolicyHelper'
export interface SearchResult { export interface SearchResult {
path: string path: string
@ -91,6 +92,13 @@ class VaultApiService {
if (error.errors) { if (error.errors) {
console.error('Details:', error.errors) console.error('Details:', error.errors)
} }
// Enhance 403 errors with policy guidance
if (error.statusCode === 403) {
const enhancedMessage = generate403PolicyGuidance(path, 'read', error.message)
throw new VaultError(enhancedMessage, error.statusCode, error.errors)
}
// Re-throw to let the caller handle it // Re-throw to let the caller handle it
throw error throw error
} else { } else {
@ -117,6 +125,13 @@ class VaultApiService {
if (error.errors) { if (error.errors) {
console.error('Details:', error.errors) console.error('Details:', error.errors)
} }
// Enhance 403 errors with policy guidance
if (error.statusCode === 403) {
const enhancedMessage = generate403PolicyGuidance(path, 'read', error.message)
throw new VaultError(enhancedMessage, error.statusCode, error.errors)
}
// Re-throw to let the caller handle it // Re-throw to let the caller handle it
throw error throw error
} else { } else {
@ -144,6 +159,13 @@ class VaultApiService {
} catch (error) { } catch (error) {
if (error instanceof VaultError) { if (error instanceof VaultError) {
console.error(`Vault error writing ${path}:`, error.message) console.error(`Vault error writing ${path}:`, error.message)
// Enhance 403 errors with policy guidance
if (error.statusCode === 403) {
const enhancedMessage = generate403PolicyGuidance(path, 'write', error.message)
throw new VaultError(enhancedMessage, error.statusCode, error.errors)
}
throw error throw error
} else { } else {
console.error(`Error writing secret at ${path}:`, error) console.error(`Error writing secret at ${path}:`, error)
@ -181,11 +203,35 @@ class VaultApiService {
/** /**
* Verify login and get available mount points * Verify login and get available mount points
*/ */
async verifyLoginAndGetMounts(server: VaultServer, credentials: VaultCredentials): Promise<MountPoint[]> { async verifyLoginAndGetMounts(
server: VaultServer,
credentials: VaultCredentials
): Promise<{ mountPoints: MountPoint[]; updatedCredentials: VaultCredentials }> {
console.log('⚡ Verifying login and fetching mount points...') console.log('⚡ Verifying login and fetching mount points...')
try { try {
const client = this.createClient(server, credentials) const client = this.createClient(server, credentials)
let updatedCredentials = { ...credentials }
// For userpass and LDAP, we need to authenticate first to get a token
if (credentials.authMethod === 'userpass' && credentials.username && credentials.password) {
console.log('🔐 Authenticating with userpass...')
const token = await client.loginUserpass(credentials.username, credentials.password)
console.log('✓ Userpass authentication successful, token obtained')
// Update credentials with the obtained token
updatedCredentials.token = token
} else if (credentials.authMethod === 'ldap' && credentials.username && credentials.password) {
console.log('🔐 Authenticating with LDAP...')
const token = await client.loginLdap(credentials.username, credentials.password)
console.log('✓ LDAP authentication successful, token obtained')
// Update credentials with the obtained token
updatedCredentials.token = token
} else if (credentials.authMethod === 'token' && credentials.token) {
console.log('🔐 Using provided token for authentication')
} else {
throw new VaultError('Invalid credentials: missing required fields for authentication method')
}
const mounts = await client.listMounts() const mounts = await client.listMounts()
console.log('📋 Raw mount points from API:', mounts) console.log('📋 Raw mount points from API:', mounts)
@ -211,7 +257,7 @@ class VaultApiService {
`✓ Found ${mountPoints.length} KV mount point(s):`, `✓ Found ${mountPoints.length} KV mount point(s):`,
mountPoints.map(m => `${m.path} (v${m.options?.version || '1'})`) mountPoints.map(m => `${m.path} (v${m.options?.version || '1'})`)
) )
return mountPoints return { mountPoints, updatedCredentials }
} catch (error) { } catch (error) {
if (error instanceof VaultError) { if (error instanceof VaultError) {
console.error('✗ Login verification failed:', error.message) console.error('✗ Login verification failed:', error.message)
@ -230,7 +276,8 @@ class VaultApiService {
basePath: string, basePath: string,
searchTerm: string, searchTerm: string,
currentDepth: number = 0, currentDepth: number = 0,
mountPoint?: string mountPoint?: string,
onProgress?: (pathsSearched: number) => void
): Promise<SearchResult[]> { ): Promise<SearchResult[]> {
const config = loadConfig() const config = loadConfig()
@ -246,6 +293,11 @@ class VaultApiService {
// List items at current path // List items at current path
const items = await this.listSecrets(server, credentials, basePath) const items = await this.listSecrets(server, credentials, basePath)
// Report progress for this path
if (onProgress) {
onProgress(1)
}
for (const item of items) { for (const item of items) {
const fullPath = basePath ? `${basePath}${item}` : item const fullPath = basePath ? `${basePath}${item}` : item
const isDirectory = item.endsWith('/') const isDirectory = item.endsWith('/')
@ -268,7 +320,7 @@ class VaultApiService {
// If it's a directory, recursively search it // If it's a directory, recursively search it
if (isDirectory) { if (isDirectory) {
const subResults = await this.searchPaths(server, credentials, fullPath, searchTerm, currentDepth + 1, mountPoint) const subResults = await this.searchPaths(server, credentials, fullPath, searchTerm, currentDepth + 1, mountPoint, onProgress)
results.push(...subResults) results.push(...subResults)
// Stop if we've reached max results // Stop if we've reached max results
@ -288,7 +340,13 @@ class VaultApiService {
/** /**
* Search across all mount points * Search across all mount points
*/ */
async searchAllMounts(server: VaultServer, credentials: VaultCredentials, mountPoints: MountPoint[], searchTerm: string): Promise<SearchResult[]> { async searchAllMounts(
server: VaultServer,
credentials: VaultCredentials,
mountPoints: MountPoint[],
searchTerm: string,
onProgress?: (pathsSearched: number) => void
): Promise<SearchResult[]> {
console.log(`🔍 Searching across ${mountPoints.length} mount point(s)...`) console.log(`🔍 Searching across ${mountPoints.length} mount point(s)...`)
const allResults: SearchResult[] = [] const allResults: SearchResult[] = []
@ -299,7 +357,7 @@ class VaultApiService {
try { try {
// Search this mount point (KV v2 enforced) // 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, onProgress)
allResults.push(...results) allResults.push(...results)

View File

@ -25,6 +25,34 @@ export class VaultError extends Error {
} }
} }
/**
* Vault authentication response structure
*/
interface VaultAuthResponse {
request_id: string
lease_id: string
renewable: boolean
lease_duration: number
data: unknown
wrap_info: unknown
warnings: unknown
auth: {
client_token: string
accessor: string
policies: string[]
token_policies: string[]
metadata: Record<string, unknown>
lease_duration: number
renewable: boolean
entity_id: string
token_type: string
orphan: boolean
mfa_requirement: unknown
num_uses: number
}
mount_type: string
}
/** /**
* Browser-compatible HashiCorp Vault client * Browser-compatible HashiCorp Vault client
* *
@ -84,9 +112,9 @@ export class VaultClient {
private async request<T>(path: string, options: RequestInit = {}): Promise<T> { private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}/v1/${path.replace(/^\//, '')}` const url = `${this.baseUrl}/v1/${path.replace(/^\//, '')}`
const headers: HeadersInit = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers, ...(options.headers as Record<string, string>),
} }
// Add authentication token if available // Add authentication token if available
@ -182,18 +210,19 @@ export class VaultClient {
* For KV v2, this uses the /data/ endpoint * For KV v2, this uses the /data/ endpoint
* For KV v1, this uses the path directly * For KV v1, this uses the path directly
*/ */
async read<T = Record<string, unknown>>(path: string): Promise<T | null> { async read<T = Record<string, unknown>>(path: string, version?: number): Promise<T | null> {
const normalizedPath = this.transformPath(path, 'data') const normalizedPath = this.transformPath(path, 'data')
const pathWithVersion = version ? `${normalizedPath}?version=${version}` : normalizedPath
if (this.kvVersion === 2) { if (this.kvVersion === 2) {
// KV v2 returns { data: { data: {...}, metadata: {...} } } // KV v2 returns { data: { data: {...}, metadata: {...} } }
const response = await this.requestWithRetry<{ const response = await this.requestWithRetry<{
data: { data: T; metadata?: unknown } data: { data: T; metadata?: unknown }
}>(normalizedPath, { method: 'GET' }) }>(pathWithVersion, { method: 'GET' })
return response?.data?.data || null return response?.data?.data || null
} else { } else {
// KV v1 returns { data: {...} } // KV v1 returns { data: {...} }
const response = await this.requestWithRetry<{ data: T }>(normalizedPath, { method: 'GET' }) const response = await this.requestWithRetry<{ data: T }>(pathWithVersion, { method: 'GET' })
return response?.data || null return response?.data || null
} }
} }
@ -290,14 +319,19 @@ export class VaultClient {
* Authenticate with username/password * Authenticate with username/password
*/ */
async loginUserpass(username: string, password: string): Promise<string> { async loginUserpass(username: string, password: string): Promise<string> {
const response = await this.request<{ const response = await this.request<VaultAuthResponse>('auth/userpass/login/' + username, {
auth: { client_token: string }
}>('auth/userpass/login/' + username, {
method: 'POST', method: 'POST',
body: JSON.stringify({ password }), body: JSON.stringify({ password }),
}) })
if (!response.auth || !response.auth.client_token) {
throw new VaultError('Authentication failed: no token received from server')
}
this.token = response.auth.client_token this.token = response.auth.client_token
console.log('✓ Token extracted from userpass response:', this.token.substring(0, 10) + '...')
console.log('✓ User policies:', response.auth.policies)
console.log('✓ Token metadata:', response.auth.metadata)
return this.token return this.token
} }
@ -305,14 +339,19 @@ export class VaultClient {
* Authenticate with LDAP * Authenticate with LDAP
*/ */
async loginLdap(username: string, password: string): Promise<string> { async loginLdap(username: string, password: string): Promise<string> {
const response = await this.request<{ const response = await this.request<VaultAuthResponse>('auth/ldap/login/' + username, {
auth: { client_token: string }
}>('auth/ldap/login/' + username, {
method: 'POST', method: 'POST',
body: JSON.stringify({ password }), body: JSON.stringify({ password }),
}) })
if (!response.auth || !response.auth.client_token) {
throw new VaultError('Authentication failed: no token received from server')
}
this.token = response.auth.client_token this.token = response.auth.client_token
console.log('✓ Token extracted from LDAP response:', this.token.substring(0, 10) + '...')
console.log('✓ User policies:', response.auth.policies)
console.log('✓ Token metadata:', response.auth.metadata)
return this.token return this.token
} }

View File

@ -21,6 +21,12 @@ class VaultCache {
this.cache = this.loadFromStorage() this.cache = this.loadFromStorage()
} }
private getMaxAgeMs(): number {
const config = loadConfig()
const minutes = config.cache.maxAgeMinutes || 30 // Default to 30 minutes if 0 or empty
return minutes * 60 * 1000 // Convert to milliseconds
}
private loadFromStorage(): Map<string, CacheEntry<unknown>> { private loadFromStorage(): Map<string, CacheEntry<unknown>> {
try { try {
const stored = localStorage.getItem(this.CACHE_KEY) const stored = localStorage.getItem(this.CACHE_KEY)
@ -99,7 +105,7 @@ class VaultCache {
// Check if entry is expired // Check if entry is expired
const age = Date.now() - entry.timestamp const age = Date.now() - entry.timestamp
if (age > config.cache.maxAge) { if (age > this.getMaxAgeMs()) {
this.cache.delete(key) this.cache.delete(key)
return null return null
} }
@ -131,7 +137,7 @@ class VaultCache {
if (!entry) return false if (!entry) return false
const age = Date.now() - entry.timestamp const age = Date.now() - entry.timestamp
if (age > config.cache.maxAge) { if (age > this.getMaxAgeMs()) {
this.cache.delete(key) this.cache.delete(key)
return false return false
} }
@ -174,12 +180,12 @@ class VaultCache {
// Clean up expired entries // Clean up expired entries
cleanup(): void { cleanup(): void {
const config = loadConfig()
const now = Date.now() const now = Date.now()
const maxAge = this.getMaxAgeMs()
const keysToDelete: string[] = [] const keysToDelete: string[] = []
for (const [key, entry] of this.cache.entries()) { for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > config.cache.maxAge) { if (now - entry.timestamp > maxAge) {
keysToDelete.push(key) keysToDelete.push(key)
} }
} }

View File

@ -0,0 +1,103 @@
/**
* Utility functions for generating Vault policy guidance
*/
export interface PolicyGuidance {
operation: 'read' | 'write' | 'delete' | 'list'
path: string
capabilities: string[]
}
/**
* Generate policy guidance for common Vault operations
*/
export function generatePolicyGuidance(path: string, operation: 'read' | 'write' | 'delete' | 'list'): string {
const pathParts = path.split('/')
const mountPoint = pathParts[0]
const secretPath = pathParts.slice(1).join('/')
let description: string
let examples: string[] = []
switch (operation) {
case 'read':
description = 'read secrets'
examples = [
`# Specific secret
path "${mountPoint}/data/${secretPath || '*'}" {
capabilities = ["read"]
}`,
`# All secrets in mount
path "${mountPoint}/data/*" {
capabilities = ["read"]
}`,
]
break
case 'write':
description = 'create and update secrets'
examples = [
`# Specific secret
path "${mountPoint}/data/${secretPath || '*'}" {
capabilities = ["create", "update"]
}`,
`# All secrets in mount
path "${mountPoint}/data/*" {
capabilities = ["create", "update"]
}`,
]
break
case 'delete':
description = 'delete secrets'
examples = [
`# Specific secret
path "${mountPoint}/data/${secretPath || '*'}" {
capabilities = ["delete"]
}`,
`# All secrets in mount
path "${mountPoint}/data/*" {
capabilities = ["delete"]
}`,
`# For KV v2: also need metadata delete permissions
path "${mountPoint}/metadata/${secretPath || '*'}" {
capabilities = ["delete"]
}`,
]
break
case 'list':
description = 'list secrets'
examples = [
`# List secrets in mount
path "${mountPoint}/metadata/*" {
capabilities = ["list"]
}`,
`# List specific path
path "${mountPoint}/metadata/${secretPath || '*'}" {
capabilities = ["list"]
}`,
]
break
}
return `**Permission Denied (403)**
You need the following permissions in your Vault policy to ${description}:
\`\`\`hcl
${examples.join('\n\n')}
\`\`\`
**Ask your Vault administrator to add these permissions to your policy.**`
}
/**
* Generate policy guidance for 403 errors with context
*/
export function generate403PolicyGuidance(path: string, operation: 'read' | 'write' | 'delete' | 'list', originalError: string): string {
const guidance = generatePolicyGuidance(path, operation)
return `${guidance}
**Original error:** ${originalError}`
}