wtf
This commit is contained in:
+17
-1
@@ -41,7 +41,7 @@ const handleSelectServer = (server: VaultServer) => {
|
||||
activeConnection.value = null
|
||||
}
|
||||
|
||||
const handleLogin = async (credentials: VaultCredentials) => {
|
||||
const handleLogin = async (credentials: VaultCredentials, shouldSaveCredentials: boolean) => {
|
||||
if (!selectedServer.value) return
|
||||
|
||||
try {
|
||||
@@ -60,6 +60,22 @@ const handleLogin = async (credentials: VaultCredentials) => {
|
||||
mountPoints,
|
||||
}
|
||||
|
||||
// Save credentials if requested
|
||||
if (shouldSaveCredentials) {
|
||||
const serverIndex = servers.value.findIndex(s => s.id === selectedServer.value!.id)
|
||||
if (serverIndex !== -1) {
|
||||
servers.value[serverIndex].savedCredentials = credentials
|
||||
console.log('⚠️ Credentials saved to localStorage (insecure!)')
|
||||
}
|
||||
} else {
|
||||
// Remove saved credentials if user unchecked the option
|
||||
const serverIndex = servers.value.findIndex(s => s.id === selectedServer.value!.id)
|
||||
if (serverIndex !== -1 && servers.value[serverIndex].savedCredentials) {
|
||||
delete servers.value[serverIndex].savedCredentials
|
||||
console.log('✓ Saved credentials removed from localStorage')
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ Logged in successfully. Found ${mountPoints.length} KV mount point(s).`)
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
|
||||
+118
-33
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { VaultConnection } from '../types'
|
||||
import { vaultApi, VaultError } from '../services/vaultApi'
|
||||
import PathSearch from './PathSearch.vue'
|
||||
import Settings from './Settings.vue'
|
||||
import SecretModal from './SecretModal.vue'
|
||||
|
||||
interface Props {
|
||||
connection: VaultConnection
|
||||
@@ -14,18 +15,32 @@ const emit = defineEmits<{
|
||||
logout: []
|
||||
}>()
|
||||
|
||||
const currentPath = ref('')
|
||||
const selectedMountPoint = ref('')
|
||||
const secretPath = ref('')
|
||||
const secretData = ref<Record<string, unknown> | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const showSettings = ref(false)
|
||||
const showSearch = ref(false)
|
||||
const showSearch = ref(true) // Show search by default
|
||||
const showSecretModal = ref(false)
|
||||
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) => {
|
||||
const pathToRead = path || currentPath.value
|
||||
let pathToRead = path
|
||||
|
||||
if (!pathToRead) {
|
||||
alert('Please enter a secret path')
|
||||
return
|
||||
// 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
|
||||
@@ -40,7 +55,10 @@ const handleReadSecret = async (path?: string) => {
|
||||
|
||||
if (data) {
|
||||
secretData.value = data
|
||||
currentPath.value = pathToRead
|
||||
// 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.')
|
||||
}
|
||||
@@ -74,16 +92,48 @@ const handleReadSecret = async (path?: string) => {
|
||||
}
|
||||
|
||||
const handleSelectPath = (path: string) => {
|
||||
currentPath.value = path
|
||||
handleReadSecret(path)
|
||||
showSearch.value = false
|
||||
// Parse the path to extract mount point and secret path
|
||||
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
|
||||
showSecretModal.value = true
|
||||
}
|
||||
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && !isLoading.value) {
|
||||
handleReadSecret()
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -107,7 +157,7 @@ const handleKeyPress = (event: KeyboardEvent) => {
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="showSearch = !showSearch"
|
||||
>
|
||||
{{ showSearch ? 'Hide Search' : '🔍 Search' }}
|
||||
{{ showSearch ? 'Hide Search' : '🔍 Show Search' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@@ -140,36 +190,60 @@ const handleKeyPress = (event: KeyboardEvent) => {
|
||||
<div class="card-body">
|
||||
<h3 class="text-xl font-bold mb-4">Browse Secrets</h3>
|
||||
|
||||
<!-- Path Input -->
|
||||
<!-- 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="currentPath"
|
||||
v-model="secretPath"
|
||||
type="text"
|
||||
placeholder="secret/data/myapp/config"
|
||||
placeholder="data/myapp/config"
|
||||
class="input input-bordered join-item flex-1"
|
||||
:disabled="isLoading"
|
||||
:disabled="isLoading || !selectedMountPoint"
|
||||
@keypress="handleKeyPress"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary join-item"
|
||||
:class="{ 'loading': isLoading }"
|
||||
:disabled="isLoading"
|
||||
@click="handleReadSecret()"
|
||||
:disabled="!selectedMountPoint || !secretPath"
|
||||
@click="handleViewSecret()"
|
||||
>
|
||||
{{ isLoading ? 'Loading...' : 'Read Secret' }}
|
||||
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>
|
||||
|
||||
<!-- Secret Data Display -->
|
||||
<div v-if="secretData" class="mt-6">
|
||||
<h4 class="text-lg font-semibold mb-2">Secret Data</h4>
|
||||
<pre class="bg-base-300 p-4 rounded-lg overflow-x-auto text-sm">{{ JSON.stringify(secretData, null, 2) }}</pre>
|
||||
</div>
|
||||
<!-- Removed inline secret display - now using modal -->
|
||||
|
||||
<!-- Info Box -->
|
||||
<div v-if="!showSearch" class="alert alert-info mt-6">
|
||||
@@ -177,12 +251,13 @@ const handleKeyPress = (event: KeyboardEvent) => {
|
||||
<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>
|
||||
</svg>
|
||||
<div class="text-sm">
|
||||
<h4 class="font-bold">Getting Started</h4>
|
||||
<h4 class="font-bold">Browse Secrets</h4>
|
||||
<ul class="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Enter a secret path to read from your Vault server</li>
|
||||
<li>Example paths: <code class="bg-base-200 px-1 rounded">secret/data/myapp/config</code></li>
|
||||
<li>Use the Search feature to find secrets recursively</li>
|
||||
<li>Results are cached to prevent excessive API calls</li>
|
||||
<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>
|
||||
@@ -193,9 +268,10 @@ const handleKeyPress = (event: KeyboardEvent) => {
|
||||
<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>
|
||||
</svg>
|
||||
<div class="text-xs">
|
||||
<h4 class="font-semibold">Implementation Notes</h4>
|
||||
<p class="mt-1">This application uses the Vault HTTP API with caching enabled.</p>
|
||||
<p class="mt-1">All requests include the <code class="bg-base-200 px-1 rounded">X-Vault-Token</code> header for authentication. Configure cache settings and search limits in Settings.</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,6 +282,15 @@ const handleKeyPress = (event: KeyboardEvent) => {
|
||||
v-if="showSettings"
|
||||
@close="showSettings = false"
|
||||
/>
|
||||
|
||||
<!-- Secret Viewer Modal -->
|
||||
<SecretModal
|
||||
v-if="showSecretModal"
|
||||
:server="connection.server"
|
||||
:credentials="connection.credentials"
|
||||
:secret-path="selectedSecretPath"
|
||||
@close="showSecretModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import type { VaultServer, VaultCredentials } from '../types'
|
||||
|
||||
interface Props {
|
||||
@@ -8,7 +8,7 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
login: [credentials: VaultCredentials]
|
||||
login: [credentials: VaultCredentials, saveCredentials: boolean]
|
||||
}>()
|
||||
|
||||
const authMethod = ref<'token' | 'userpass' | 'ldap'>('token')
|
||||
@@ -16,8 +16,48 @@ const token = ref('')
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const isLoading = ref(false)
|
||||
const saveCredentials = ref(false)
|
||||
const showSecurityWarning = ref(false)
|
||||
|
||||
// Function to load credentials from server
|
||||
const loadCredentialsFromServer = (server: VaultServer) => {
|
||||
if (server.savedCredentials) {
|
||||
// Load saved credentials
|
||||
authMethod.value = server.savedCredentials.authMethod
|
||||
token.value = server.savedCredentials.token || ''
|
||||
username.value = server.savedCredentials.username || ''
|
||||
password.value = server.savedCredentials.password || ''
|
||||
saveCredentials.value = true
|
||||
} else {
|
||||
// Clear form when no saved credentials
|
||||
authMethod.value = 'token'
|
||||
token.value = ''
|
||||
username.value = ''
|
||||
password.value = ''
|
||||
saveCredentials.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load credentials on initial mount
|
||||
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 })
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Show warning if user is trying to save credentials for the first time
|
||||
if (saveCredentials.value && !props.server.savedCredentials) {
|
||||
showSecurityWarning.value = true
|
||||
return
|
||||
}
|
||||
|
||||
await performLogin()
|
||||
}
|
||||
|
||||
const performLogin = async () => {
|
||||
isLoading.value = true
|
||||
|
||||
const credentials: VaultCredentials = {
|
||||
@@ -29,7 +69,7 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
await emit('login', credentials)
|
||||
await emit('login', credentials, saveCredentials.value)
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
alert('Login failed. Please check your credentials.')
|
||||
@@ -37,6 +77,16 @@ const handleSubmit = async () => {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmSaveCredentials = () => {
|
||||
showSecurityWarning.value = false
|
||||
performLogin()
|
||||
}
|
||||
|
||||
const cancelSaveCredentials = () => {
|
||||
showSecurityWarning.value = false
|
||||
saveCredentials.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -114,6 +164,25 @@ const handleSubmit = async () => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 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"
|
||||
/>
|
||||
<span class="label-text">
|
||||
<span class="font-semibold 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>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
@@ -125,6 +194,64 @@ const handleSubmit = async () => {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 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>
|
||||
<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" />
|
||||
</svg>
|
||||
<span class="font-semibold">This is NOT recommended for security reasons!</span>
|
||||
</div>
|
||||
|
||||
<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>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>
|
||||
<li>This violates most security policies</li>
|
||||
</ul>
|
||||
|
||||
<p class="font-semibold mt-4">Only use this if:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li>You're on a personal, secure device</li>
|
||||
<li>You understand the security risks</li>
|
||||
<li>You're using a development/test Vault server</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
• 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
|
||||
@@ -15,8 +15,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const searchTerm = ref('')
|
||||
const basePath = ref('secret/')
|
||||
const searchAllMounts = ref(false)
|
||||
const results = ref<SearchResult[]>([])
|
||||
const isSearching = ref(false)
|
||||
const searchTime = ref<number | null>(null)
|
||||
@@ -34,7 +32,7 @@ const handleSearch = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (searchAllMounts.value && !mountPointsAvailable.value) {
|
||||
if (!mountPointsAvailable.value) {
|
||||
alert('No mount points available. Please ensure you are connected to Vault.')
|
||||
return
|
||||
}
|
||||
@@ -46,25 +44,13 @@ const handleSearch = async () => {
|
||||
const startTime = performance.now()
|
||||
|
||||
try {
|
||||
let searchResults: SearchResult[]
|
||||
|
||||
if (searchAllMounts.value && props.mountPoints) {
|
||||
// Search across all mount points
|
||||
searchResults = await vaultApi.searchAllMounts(
|
||||
props.server,
|
||||
props.credentials,
|
||||
props.mountPoints,
|
||||
searchTerm.value
|
||||
)
|
||||
} else {
|
||||
// Search in specific base path
|
||||
searchResults = await vaultApi.searchPaths(
|
||||
props.server,
|
||||
props.credentials,
|
||||
basePath.value,
|
||||
searchTerm.value
|
||||
)
|
||||
}
|
||||
// Always search across all mount points
|
||||
const searchResults = await vaultApi.searchAllMounts(
|
||||
props.server,
|
||||
props.credentials,
|
||||
props.mountPoints!,
|
||||
searchTerm.value
|
||||
)
|
||||
|
||||
const endTime = performance.now()
|
||||
searchTime.value = endTime - startTime
|
||||
@@ -91,49 +77,20 @@ const handleKeyPress = (event: KeyboardEvent) => {
|
||||
|
||||
<!-- Search Controls -->
|
||||
<div class="space-y-4">
|
||||
<!-- Search All Mounts Checkbox -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
v-model="searchAllMounts"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
:disabled="!mountPointsAvailable"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="label-text">
|
||||
Search across all mount points
|
||||
<span v-if="mountPointsAvailable" class="text-primary font-semibold">
|
||||
({{ mountPoints?.length }} available)
|
||||
</span>
|
||||
<span v-else class="text-error italic text-sm">
|
||||
(none detected - logout and login again)
|
||||
</span>
|
||||
</span>
|
||||
<p class="label-text-alt mt-1">
|
||||
{{ !mountPointsAvailable
|
||||
? 'Mount points are detected on login. Please logout and login again to enable this feature.'
|
||||
: 'When enabled, searches all KV mount points instead of a specific base path'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Base Path (only shown when not searching all mounts) -->
|
||||
<div v-if="!searchAllMounts" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Base Path</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="basePath"
|
||||
type="text"
|
||||
placeholder="secret/"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Starting path for recursive search</span>
|
||||
</label>
|
||||
<!-- Search Info -->
|
||||
<div 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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Search Term -->
|
||||
@@ -194,7 +151,7 @@ const handleKeyPress = (event: KeyboardEvent) => {
|
||||
<span class="text-2xl">{{ result.isDirectory ? '📁' : '📄' }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-mono text-sm break-all">{{ result.path }}</p>
|
||||
<p v-if="result.mountPoint && searchAllMounts" class="text-xs opacity-60 italic">
|
||||
<p v-if="result.mountPoint" class="text-xs opacity-60 italic">
|
||||
📌 {{ result.mountPoint }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -217,10 +174,8 @@ const handleKeyPress = (event: KeyboardEvent) => {
|
||||
<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>
|
||||
</svg>
|
||||
<div>
|
||||
<p>No results found for "{{ searchTerm }}"
|
||||
{{ searchAllMounts ? ' across all mount points' : ` in ${basePath}` }}
|
||||
</p>
|
||||
<p class="text-sm">Try a different search term{{ !searchAllMounts ? ' or base path' : '' }}</p>
|
||||
<p>No results found for "{{ searchTerm }}" across all mount points</p>
|
||||
<p class="text-sm">Try a different search term or check if the secret exists</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -233,15 +188,10 @@ const handleKeyPress = (event: KeyboardEvent) => {
|
||||
<h4 class="font-bold">ℹ️ 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>Results are cached to prevent excessive API calls</li>
|
||||
<li>
|
||||
<strong>Search all mounts:</strong> Enable to search across all KV secret engines
|
||||
<span v-if="mountPointsAvailable">
|
||||
(detected: {{ mountPoints?.map(m => m.path).join(', ') }})
|
||||
</span>
|
||||
</li>
|
||||
<li><strong>Base path:</strong> When not searching all mounts, specify a starting path</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>Click "View" on secrets to open detailed modal with metadata</li>
|
||||
<li>Maximum search depth and results can be configured in settings</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import type { VaultServer, VaultCredentials } from "../types";
|
||||
import { vaultApi, VaultError } from "../services/vaultApi";
|
||||
|
||||
interface Props {
|
||||
server: VaultServer;
|
||||
credentials: VaultCredentials;
|
||||
secretPath: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
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>>({});
|
||||
|
||||
onMounted(() => {
|
||||
loadSecret();
|
||||
});
|
||||
|
||||
const loadSecret = async () => {
|
||||
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);
|
||||
|
||||
// For KV v2, the response includes both data and metadata
|
||||
if (response && typeof response === "object") {
|
||||
// Extract secret data (usually under 'data' key)
|
||||
secretData.value = response.data || response;
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
// 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 {
|
||||
secretData.value = response;
|
||||
}
|
||||
|
||||
// Try to load full metadata and version history from metadata endpoint
|
||||
await loadMetadataAndVersions();
|
||||
} catch (err) {
|
||||
console.error("Error loading secret:", err);
|
||||
if (err instanceof VaultError) {
|
||||
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 {
|
||||
error.value = err instanceof Error ? err.message : "Unknown error";
|
||||
}
|
||||
} finally {
|
||||
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,
|
||||
);
|
||||
|
||||
if (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) {
|
||||
secretVersions.value = Object.entries(fullMetadata.versions)
|
||||
.map(([version, versionData]: [string, any]) => ({
|
||||
version: parseInt(version),
|
||||
...versionData,
|
||||
created_time: new Date(versionData.created_time).toLocaleString(),
|
||||
}))
|
||||
.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",
|
||||
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,
|
||||
);
|
||||
// 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;
|
||||
|
||||
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";
|
||||
} catch (err) {
|
||||
console.error("Error loading version:", err);
|
||||
error.value = err instanceof Error ? err.message : "Unknown error";
|
||||
} finally {
|
||||
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);
|
||||
// Could add a toast notification here
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleValueVisibility = (key: string) => {
|
||||
visibleValues.value[key] = !visibleValues.value[key];
|
||||
};
|
||||
|
||||
const isValueVisible = (key: string): boolean => {
|
||||
return visibleValues.value[key] || false;
|
||||
};
|
||||
|
||||
const maskValue = (value: string): string => {
|
||||
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 toggleAllValues = () => {
|
||||
if (!secretData.value) return;
|
||||
|
||||
// Check if any values are currently visible
|
||||
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;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Modal Overlay -->
|
||||
<div class="modal modal-open" @click.self="emit('close')">
|
||||
<div class="modal-box max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<!-- 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">
|
||||
{{ secretPath }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm btn-circle btn-ghost ml-4"
|
||||
@click="emit('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>
|
||||
<p class="mt-4">Loading secret...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<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>
|
||||
<div>
|
||||
<h3 class="font-bold">Failed to load secret</h3>
|
||||
<pre class="text-xs mt-2 whitespace-pre-wrap">{{ error }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<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>
|
||||
<button
|
||||
class="tab"
|
||||
:class="{ 'tab-active': activeTab === 'json' }"
|
||||
@click="activeTab = 'json'"
|
||||
>
|
||||
📋 JSON Data
|
||||
</button>
|
||||
<button
|
||||
v-if="secretMetadata"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': activeTab === 'metadata' }"
|
||||
@click="activeTab = 'metadata'"
|
||||
>
|
||||
ℹ️ Metadata
|
||||
</button>
|
||||
<button
|
||||
v-if="secretVersions.length > 0"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': activeTab === 'versions' }"
|
||||
@click="activeTab = 'versions'"
|
||||
>
|
||||
🕒 Versions ({{ secretVersions.length }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<!-- Current Data Tab (Table View) -->
|
||||
<div v-if="activeTab === 'current'" class="h-full flex flex-col">
|
||||
<div class="flex justify-between items-center mb-3 flex-shrink-0">
|
||||
<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"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div
|
||||
v-if="secretData && Object.keys(secretData).length > 0"
|
||||
class="overflow-x-auto"
|
||||
>
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-1/3">Key</th>
|
||||
<th class="w-1/2">Value</th>
|
||||
<th class="w-1/6">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
:title="
|
||||
isValueVisible(key) ? 'Hide value' : 'Show value'
|
||||
"
|
||||
@click="toggleValueVisibility(key)"
|
||||
>
|
||||
{{ isValueVisible(key) ? "🙈" : "👁️" }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
title="Copy value"
|
||||
@click="
|
||||
copyToClipboard(
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: JSON.stringify(value),
|
||||
)
|
||||
"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center h-full text-base-content/60"
|
||||
>
|
||||
<p>No secret data available</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JSON Data Tab -->
|
||||
<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>
|
||||
</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
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Tab -->
|
||||
<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">
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="font-semibold text-sm">General Info</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div>
|
||||
<strong>Current Version:</strong>
|
||||
{{ secretMetadata.current_version || "N/A" }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Max Versions:</strong>
|
||||
{{ secretMetadata.max_versions || "N/A" }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Oldest Version:</strong>
|
||||
{{ secretMetadata.oldest_version || "N/A" }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Created:</strong>
|
||||
{{
|
||||
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"
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="font-semibold text-sm">Status</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div>
|
||||
<strong>Destroyed:</strong>
|
||||
{{ secretMetadata.destroyed ? "Yes" : "No" }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Delete Version After:</strong>
|
||||
{{ 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Versions Tab -->
|
||||
<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 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
|
||||
>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
<button class="btn" @click="emit('close')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,7 +19,6 @@ const newServer = ref({
|
||||
name: '',
|
||||
url: '',
|
||||
description: '',
|
||||
kvVersion: 2 as 1 | 2,
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
@@ -30,11 +29,10 @@ const handleSubmit = () => {
|
||||
name: newServer.value.name,
|
||||
url: newServer.value.url,
|
||||
description: newServer.value.description || undefined,
|
||||
kvVersion: newServer.value.kvVersion,
|
||||
}
|
||||
|
||||
emit('addServer', server)
|
||||
newServer.value = { name: '', url: '', description: '', kvVersion: 2 }
|
||||
newServer.value = { name: '', url: '', description: '' }
|
||||
showAddForm.value = false
|
||||
}
|
||||
|
||||
@@ -100,21 +98,7 @@ const handleRemove = (serverId: string, serverName: string) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">KV Secret Engine Version</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="newServer.kvVersion"
|
||||
class="select select-bordered w-full"
|
||||
>
|
||||
<option :value="2">KV v2 (recommended)</option>
|
||||
<option :value="1">KV v1 (legacy)</option>
|
||||
</select>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Most Vault servers use KV v2. Choose v1 only for legacy installations.</span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- KV v2 is enforced - no version selection needed -->
|
||||
|
||||
<button type="submit" class="btn btn-success w-full">
|
||||
Add Server
|
||||
@@ -143,8 +127,11 @@ const handleRemove = (serverId: string, serverName: string) => {
|
||||
<p v-if="server.description" class="text-sm italic opacity-60 mt-1">
|
||||
{{ server.description }}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<span class="badge badge-sm badge-outline">KV v{{ server.kvVersion || 2 }}</span>
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
+42
-29
@@ -22,15 +22,14 @@ class VaultApiService {
|
||||
*/
|
||||
private createClient(
|
||||
server: VaultServer,
|
||||
credentials: VaultCredentials,
|
||||
kvVersion: 1 | 2 = 2
|
||||
credentials: VaultCredentials
|
||||
): VaultClient {
|
||||
return new VaultClient({
|
||||
server,
|
||||
credentials,
|
||||
timeout: 30000,
|
||||
retries: 2,
|
||||
kvVersion, // KV v2 by default (most common)
|
||||
kvVersion: 2, // KV v2 is enforced
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,7 +64,7 @@ class VaultApiService {
|
||||
console.log(`⚡ API call for list: ${path}`);
|
||||
|
||||
try {
|
||||
const client = this.createClient(server, credentials, server.kvVersion);
|
||||
const client = this.createClient(server, credentials);
|
||||
const keys = await client.list(path);
|
||||
|
||||
// Cache the result
|
||||
@@ -86,33 +85,20 @@ class VaultApiService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a secret from Vault with caching
|
||||
* 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> {
|
||||
const cacheKey = this.getCacheKey(server, path, 'read');
|
||||
|
||||
// Check cache first
|
||||
const cached = vaultCache.get<Record<string, unknown>>(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`✓ Cache hit for read: ${path}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
console.log(`⚡ API call for read: ${path}`);
|
||||
console.log(`⚡ API call for read (no cache): ${path}`);
|
||||
|
||||
try {
|
||||
const client = this.createClient(server, credentials, server.kvVersion);
|
||||
const client = this.createClient(server, credentials);
|
||||
const secretData = await client.read<Record<string, unknown>>(path);
|
||||
|
||||
if (secretData) {
|
||||
// Cache the result
|
||||
vaultCache.set(cacheKey, secretData);
|
||||
}
|
||||
|
||||
// SECURITY: Never cache secret data - always fetch fresh
|
||||
return secretData;
|
||||
} catch (error) {
|
||||
if (error instanceof VaultError) {
|
||||
@@ -129,6 +115,36 @@ 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}`);
|
||||
|
||||
try {
|
||||
const client = this.createClient(server, credentials);
|
||||
const metadata = await client.readMetadata(path);
|
||||
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
if (error instanceof VaultError) {
|
||||
console.error(`Vault error reading metadata ${path}:`, error.message);
|
||||
if (error.errors) {
|
||||
console.error('Details:', error.errors);
|
||||
}
|
||||
// Re-throw to let the caller handle it
|
||||
throw error;
|
||||
} else {
|
||||
console.error(`Error reading metadata at ${path}:`, error);
|
||||
throw new VaultError('Failed to read metadata');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a secret to Vault (no caching)
|
||||
*/
|
||||
@@ -141,7 +157,7 @@ class VaultApiService {
|
||||
console.log(`⚡ API call for write: ${path}`);
|
||||
|
||||
try {
|
||||
const client = this.createClient(server, credentials, server.kvVersion);
|
||||
const client = this.createClient(server, credentials);
|
||||
await client.write(path, data);
|
||||
|
||||
// Invalidate cache for this path
|
||||
@@ -171,7 +187,7 @@ class VaultApiService {
|
||||
console.log(`⚡ API call for delete: ${path}`);
|
||||
|
||||
try {
|
||||
const client = this.createClient(server, credentials, server.kvVersion);
|
||||
const client = this.createClient(server, credentials);
|
||||
await client.delete(path);
|
||||
|
||||
// Invalidate cache for this path
|
||||
@@ -200,7 +216,7 @@ class VaultApiService {
|
||||
console.log('⚡ Verifying login and fetching mount points...');
|
||||
|
||||
try {
|
||||
const client = this.createClient(server, credentials, server.kvVersion);
|
||||
const client = this.createClient(server, credentials);
|
||||
const mounts = await client.listMounts();
|
||||
|
||||
console.log('📋 Raw mount points from API:', mounts);
|
||||
@@ -326,12 +342,9 @@ class VaultApiService {
|
||||
console.log(` → Searching in ${mount.path}/`);
|
||||
|
||||
try {
|
||||
// Determine KV version from mount options
|
||||
const kvVersion = mount.options?.version === '2' ? 2 : 1;
|
||||
|
||||
// Search this mount point
|
||||
// Search this mount point (KV v2 enforced)
|
||||
const results = await this.searchPaths(
|
||||
{ ...server, kvVersion },
|
||||
server,
|
||||
credentials,
|
||||
`${mount.path}/`,
|
||||
searchTerm,
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@ export interface VaultServer {
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
kvVersion?: 1 | 2; // KV secret engine version (default: 2)
|
||||
// KV v2 is enforced - no version selection needed
|
||||
savedCredentials?: VaultCredentials; // Optional saved credentials (WARNING: stored in localStorage)
|
||||
}
|
||||
|
||||
export interface VaultCredentials {
|
||||
|
||||
Reference in New Issue
Block a user