global search + clear saved creds

This commit is contained in:
Loïc Gremaud 2025-10-21 16:00:51 +02:00
parent 5ab7004477
commit 2f605ac82b
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
11 changed files with 755 additions and 43 deletions

View File

@ -13,7 +13,8 @@
}, },
"dependencies": { "dependencies": {
"sweetalert2": "^11.26.3", "sweetalert2": "^11.26.3",
"vue": "^3.4.15" "vue": "^3.4.15",
"vue-router": "^4.6.3"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.14.0",

View File

@ -14,6 +14,9 @@ importers:
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)
vue-router:
specifier: ^4.6.3
version: 4.6.3(vue@3.5.22(typescript@5.9.3))
devDependencies: devDependencies:
'@typescript-eslint/eslint-plugin': '@typescript-eslint/eslint-plugin':
specifier: ^6.14.0 specifier: ^6.14.0
@ -494,6 +497,9 @@ packages:
'@vue/compiler-ssr@3.5.22': '@vue/compiler-ssr@3.5.22':
resolution: {integrity: sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==} resolution: {integrity: sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==}
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/eslint-config-typescript@12.0.0': '@vue/eslint-config-typescript@12.0.0':
resolution: {integrity: sha512-StxLFet2Qe97T8+7L8pGlhYBBr8Eg05LPuTDVopQV6il+SK6qqom59BA/rcFipUef2jD8P2X44Vd8tMFytfvlg==} resolution: {integrity: sha512-StxLFet2Qe97T8+7L8pGlhYBBr8Eg05LPuTDVopQV6il+SK6qqom59BA/rcFipUef2jD8P2X44Vd8tMFytfvlg==}
engines: {node: ^14.17.0 || >=16.0.0} engines: {node: ^14.17.0 || >=16.0.0}
@ -1345,6 +1351,11 @@ packages:
peerDependencies: peerDependencies:
eslint: '>=6.0.0' eslint: '>=6.0.0'
vue-router@4.6.3:
resolution: {integrity: sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==}
peerDependencies:
vue: ^3.5.0
vue-template-compiler@2.7.16: vue-template-compiler@2.7.16:
resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==} resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==}
@ -1757,6 +1768,8 @@ snapshots:
'@vue/compiler-dom': 3.5.22 '@vue/compiler-dom': 3.5.22
'@vue/shared': 3.5.22 '@vue/shared': 3.5.22
'@vue/devtools-api@6.6.4': {}
'@vue/eslint-config-typescript@12.0.0(eslint-plugin-vue@9.33.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3)': '@vue/eslint-config-typescript@12.0.0(eslint-plugin-vue@9.33.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
@ -2621,6 +2634,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
vue-router@4.6.3(vue@3.5.22(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.22(typescript@5.9.3)
vue-template-compiler@2.7.16: vue-template-compiler@2.7.16:
dependencies: dependencies:
de-indent: 1.0.2 de-indent: 1.0.2

View File

@ -3,8 +3,10 @@ import { ref, onMounted, watch } from 'vue'
import type { VaultServer, VaultCredentials, VaultConnection } from './types' 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 RouterWrapper from './components/RouterWrapper.vue'
import PolicyModal from './components/PolicyModal.vue' import PolicyModal from './components/PolicyModal.vue'
import GlobalSearch from './components/GlobalSearch.vue'
import SecretModal from './components/SecretModal.vue'
import { useSweetAlert } from './composables/useSweetAlert' import { useSweetAlert } from './composables/useSweetAlert'
import { usePolicyModal } from './composables/usePolicyModal' import { usePolicyModal } from './composables/usePolicyModal'
@ -15,6 +17,10 @@ const activeConnection = ref<VaultConnection | null>(null)
const { error } = useSweetAlert() const { error } = useSweetAlert()
const { modalState, closePolicyModal } = usePolicyModal() const { modalState, closePolicyModal } = usePolicyModal()
// Global search state
const showSecretModal = ref(false)
const selectedSecretPath = ref('')
// Load servers from localStorage on mount // Load servers from localStorage on mount
onMounted(() => { onMounted(() => {
const savedServers = localStorage.getItem('vaultServers') const savedServers = localStorage.getItem('vaultServers')
@ -93,6 +99,30 @@ const handleLogin = async (credentials: VaultCredentials, shouldSaveCredentials:
const handleLogout = () => { const handleLogout = () => {
activeConnection.value = null activeConnection.value = null
} }
const handleOpenSecret = (path: string) => {
selectedSecretPath.value = path
showSecretModal.value = true
}
const handleClearCredentials = (serverId: string) => {
const serverIndex = servers.value.findIndex(s => s.id === serverId)
if (serverIndex !== -1 && servers.value[serverIndex].savedCredentials) {
delete servers.value[serverIndex].savedCredentials
console.log(`✓ Cleared saved credentials for server: ${servers.value[serverIndex].name}`)
}
}
const handleClearAllCredentials = () => {
let clearedCount = 0
servers.value.forEach(server => {
if (server.savedCredentials) {
delete server.savedCredentials
clearedCount++
}
})
console.log(`✓ Cleared saved credentials for ${clearedCount} server(s)`)
}
</script> </script>
<template> <template>
@ -100,12 +130,26 @@ const handleLogout = () => {
<!-- Header --> <!-- Header -->
<header class="bg-gradient-to-r from-primary to-secondary text-primary-content shadow-lg"> <header class="bg-gradient-to-r from-primary to-secondary text-primary-content shadow-lg">
<div class="container mx-auto px-4 py-6"> <div class="container mx-auto px-4 py-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div class="flex-shrink-0">
<h1 class="text-4xl font-bold mb-2 flex items-center gap-3"> <h1 class="text-4xl font-bold mb-2 flex items-center gap-3">
<i class="mdi mdi-shield-lock text-primary-content" /> <i class="mdi mdi-shield-lock text-primary-content" />
Browser Vault GUI Browser Vault GUI
</h1> </h1>
<p class="text-lg opacity-90">Alternative frontend for HashiCorp Vault</p> <p class="text-lg opacity-90">Alternative frontend for HashiCorp Vault</p>
</div> </div>
<!-- Global Search (only when connected) -->
<div v-if="activeConnection" class="flex-1 max-w-2xl">
<GlobalSearch
:server="activeConnection.server"
:credentials="activeConnection.credentials"
:mount-points="activeConnection.mountPoints"
@open-secret="handleOpenSecret"
/>
</div>
</div>
</div>
</header> </header>
<!-- Main Content --> <!-- Main Content -->
@ -119,17 +163,18 @@ const handleLogout = () => {
@add-server="handleAddServer" @add-server="handleAddServer"
@remove-server="handleRemoveServer" @remove-server="handleRemoveServer"
@select-server="handleSelectServer" @select-server="handleSelectServer"
@clear-all-credentials="handleClearAllCredentials"
/> />
</div> </div>
<!-- Login Form --> <!-- Login Form -->
<div v-if="selectedServer"> <div v-if="selectedServer">
<LoginForm :server="selectedServer" @login="handleLogin" /> <LoginForm :server="selectedServer" @login="handleLogin" @clear-credentials="handleClearCredentials" />
</div> </div>
</div> </div>
<!-- Dashboard --> <!-- Router Wrapper -->
<Dashboard v-else :connection="activeConnection" @logout="handleLogout" /> <RouterWrapper v-else :connection="activeConnection" @logout="handleLogout" />
</main> </main>
<!-- Footer --> <!-- Footer -->
@ -146,5 +191,14 @@ const handleLogout = () => {
:original-error="modalState.originalError" :original-error="modalState.originalError"
@close="closePolicyModal" @close="closePolicyModal"
/> />
<!-- Global Secret Modal -->
<SecretModal
v-if="showSecretModal && activeConnection"
:server="activeConnection.server"
:credentials="activeConnection.credentials"
:secret-path="selectedSecretPath"
@close="showSecretModal = false"
/>
</div> </div>
</template> </template>

View File

@ -1,9 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import type { VaultConnection } from '../types' import type { VaultConnection } from '../types'
import PathSearch from './PathSearch.vue'
import Settings from './Settings.vue' import Settings from './Settings.vue'
import SecretModal from './SecretModal.vue'
interface Props { interface Props {
connection: VaultConnection connection: VaultConnection
@ -15,14 +13,6 @@ const emit = defineEmits<{
}>() }>()
const showSettings = ref(false) const showSettings = ref(false)
const showSecretModal = ref(false)
const selectedSecretPath = ref('')
const handleSelectPath = (path: string) => {
// Open the selected secret in the modal
selectedSecretPath.value = path
showSecretModal.value = true
}
</script> </script>
<template> <template>
@ -58,24 +48,38 @@ const handleSelectPath = (path: string) => {
</div> </div>
</div> </div>
<!-- Search Component --> <!-- Mount Points Browser -->
<PathSearch <div class="card bg-base-100 shadow-xl">
:server="connection.server" <div class="card-body">
:credentials="connection.credentials" <h3 class="card-title mb-4">
:mount-points="connection.mountPoints" <i class="mdi mdi-folder-multiple mr-2"></i>
@select-path="handleSelectPath" Secret Engines
/> </h3>
<div class="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
<router-link
v-for="mount in connection.mountPoints"
:key="mount.path"
:to="`/browse/${mount.path.replace('/', '')}`"
class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer"
>
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="mdi mdi-database text-2xl text-primary mr-3"></i>
<div>
<h4 class="font-semibold">{{ mount.path.replace('/', '') }}</h4>
<p class="text-sm text-base-content/70">{{ mount.type }} v{{ mount.options?.version || '1' }}</p>
</div>
</div>
<i class="mdi mdi-chevron-right text-base-content/50"></i>
</div>
</div>
</router-link>
</div>
</div>
</div>
<!-- Settings Modal --> <!-- Settings Modal -->
<Settings v-if="showSettings" @close="showSettings = false" /> <Settings 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> </div>
</template> </template>

View File

@ -0,0 +1,232 @@
<template>
<div class="relative">
<!-- Search Input -->
<div class="join w-full">
<input
v-model="searchTerm"
type="text"
placeholder="Search secrets across all mounts..."
class="input input-bordered join-item flex-1 bg-base-100 text-base-content placeholder-base-content/70 border-base-300"
:disabled="isSearching"
@keyup.enter="performSearch"
@input="handleInput"
/>
<button
class="btn btn-primary join-item"
:class="{ loading: isSearching }"
:disabled="isSearching || !searchTerm.trim()"
@click="performSearch"
>
<i v-if="!isSearching" class="mdi mdi-magnify mr-2"></i>
{{ isSearching ? 'Searching...' : 'Search' }}
</button>
</div>
<!-- Search Progress -->
<div v-if="isSearching" class="absolute top-full left-0 right-0 mt-2 bg-base-100 rounded-lg shadow-lg border border-base-300 p-3 z-50">
<div class="flex items-center gap-3">
<span class="loading loading-spinner loading-sm"></span>
<div class="flex-1">
<p class="text-sm font-medium">Searching recursively... This may take a moment.</p>
<p v-if="searchProgress.current > 0" class="text-xs text-base-content/70 mt-1">
Paths searched: <strong>{{ searchProgress.current }}</strong>
</p>
</div>
</div>
</div>
<!-- Search Results -->
<div
v-if="showResults && results.length > 0"
class="absolute top-full left-0 right-0 mt-2 bg-base-100 rounded-lg shadow-lg border border-base-300 max-h-96 overflow-y-auto z-50"
>
<div class="p-3 border-b">
<p class="text-sm font-medium">
Found <strong>{{ results.length }}</strong> result{{ results.length !== 1 ? 's' : '' }} in
<strong>{{ searchTime ? (searchTime / 1000).toFixed(2) : '0' }}s</strong>
</p>
</div>
<div class="space-y-2 p-2 bg-base-200 max-h-80 overflow-y-auto">
<div
v-for="(result, index) in results"
:key="index"
class="card bg-base-100 hover:bg-base-300 transition-colors cursor-pointer"
@click="selectResult(result)"
>
<div class="card-body p-3 flex flex-row items-center gap-3">
<i :class="result.isDirectory ? 'mdi mdi-folder text-warning' : 'mdi mdi-file-document text-info'" class="text-xl"></i>
<div class="flex-1 min-w-0">
<p class="font-mono text-sm break-all">{{ result.path }}</p>
<p v-if="result.mountPoint" class="text-xs opacity-60 italic flex items-center gap-1">
<i class="mdi mdi-pin text-xs"></i>
{{ result.mountPoint }}
</p>
</div>
<span class="badge badge-sm">Depth: {{ result.depth }}</span>
<button class="btn btn-primary btn-xs" @click.stop="selectResult(result)">
<i :class="result.isDirectory ? 'mdi mdi-folder-open mr-1' : 'mdi mdi-eye mr-1'"></i>
{{ result.isDirectory ? 'Browse' : 'View' }}
</button>
</div>
</div>
</div>
<div class="p-3 border-t bg-base-200">
<button class="btn btn-sm btn-ghost w-full" @click="clearResults">
<i class="mdi mdi-close mr-1"></i>
Close Results
</button>
</div>
</div>
<!-- No Results -->
<div
v-if="showResults && results.length === 0 && !isSearching && hasSearched"
class="absolute top-full left-0 right-0 mt-2 bg-base-100 rounded-lg shadow-lg border border-base-300 p-4 z-50"
>
<div class="text-center">
<i class="mdi mdi-magnify-close text-4xl text-base-content/30 mb-2"></i>
<p class="font-medium">No results found for "{{ searchTerm }}" across all mount points</p>
<p class="text-sm text-base-content/70">Try a different search term or check if the secret exists</p>
<button class="btn btn-sm btn-ghost mt-2" @click="clearResults">Close</button>
</div>
</div>
<!-- Click outside to close -->
<div v-if="showResults" class="fixed inset-0 z-40" @click="clearResults"></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import type { VaultServer, VaultCredentials, MountPoint } from '../types'
import { vaultApi, type SearchResult } from '../services/vaultApi'
import { VaultError } from '../services/vaultClient'
import { useSweetAlert } from '../composables/useSweetAlert'
import { usePolicyModal } from '../composables/usePolicyModal'
interface Props {
server: VaultServer
credentials: VaultCredentials
mountPoints?: MountPoint[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
openSecret: [path: string]
}>()
const router = useRouter()
const { warning, error } = useSweetAlert()
const { showPolicyModal } = usePolicyModal()
// State
const searchTerm = ref('')
const results = ref<SearchResult[]>([])
const isSearching = ref(false)
const searchTime = ref<number | null>(null)
const searchProgress = ref({ current: 0, total: 0 })
const showResults = ref(false)
const hasSearched = ref(false)
// Computed
const availableMounts = computed(() => {
return props.mountPoints?.filter(mount => mount.type === 'kv') || []
})
// Methods
const handleInput = () => {
if (!searchTerm.value.trim()) {
clearResults()
}
}
const performSearch = async () => {
const term = searchTerm.value.trim()
if (!term || isSearching.value) return
if (availableMounts.value.length === 0) {
warning('No KV secret engines available for searching.')
return
}
isSearching.value = true
hasSearched.value = true
showResults.value = true
results.value = []
searchProgress.value = { current: 0, total: 0 }
const startTime = Date.now()
let totalPathsSearched = 0
try {
const searchResults = await vaultApi.searchAllMounts(props.server, props.credentials, availableMounts.value, term, (pathsSearched: number) => {
totalPathsSearched += pathsSearched
searchProgress.value.current = totalPathsSearched
})
results.value = searchResults
searchTime.value = Date.now() - startTime
} catch (err) {
console.error('Search error:', err)
if (err instanceof VaultError && err.statusCode === 403) {
showPolicyModal('*/metadata/*', 'list', err.message, 'Permission Denied - Cannot Search Secrets')
} else {
error('Search failed. Check console for details.')
}
showResults.value = false
} finally {
isSearching.value = false
totalPathsSearched = 0
searchProgress.value = { current: 0, total: 0 }
}
}
const selectResult = (result: SearchResult) => {
clearResults()
searchTerm.value = '' // Clear search after selection
// Remove mount point from the path since it's already in the router structure
// Example: "secret/myapp/config" -> "myapp/config" (when mountPoint is "secret")
const pathWithoutMount = result.path.startsWith(`${result.mountPoint}/`) ? result.path.slice(`${result.mountPoint}/`.length) : result.path
// Determine if this is a folder or a secret
const isFolder = result.isDirectory
if (isFolder) {
// Navigate to the folder in the secret browser
// Remove trailing slash if present
const folderPath = pathWithoutMount.endsWith('/') ? pathWithoutMount.slice(0, -1) : pathWithoutMount
if (folderPath) {
router.push(`/browse/${result.mountPoint}/${folderPath}`)
} else {
// Root of mount point
router.push(`/browse/${result.mountPoint}`)
}
} else {
// It's a secret - navigate to parent folder and open the secret
const pathParts = pathWithoutMount.split('/')
const secretName = pathParts.pop() // Get the secret name
const parentPath = pathParts.join('/') // Get the parent path
if (parentPath) {
// Navigate to parent folder (without mount point prefix)
router.push(`/browse/${result.mountPoint}/${parentPath}`)
} else {
// Secret is in root of mount point
router.push(`/browse/${result.mountPoint}`)
}
// Emit event to open the secret modal (with original path including mount)
emit('openSecret', result.path)
}
}
const clearResults = () => {
showResults.value = false
results.value = []
hasSearched.value = false
}
</script>

View File

@ -10,9 +10,10 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
login: [credentials: VaultCredentials, saveCredentials: boolean] login: [credentials: VaultCredentials, saveCredentials: boolean]
clearCredentials: [serverId: string]
}>() }>()
const { error } = useSweetAlert() const { confirm, error } = useSweetAlert()
const authMethod = ref<'token' | 'userpass' | 'ldap'>('token') const authMethod = ref<'token' | 'userpass' | 'ldap'>('token')
const token = ref('') const token = ref('')
@ -90,6 +91,17 @@ const confirmSaveCredentials = () => {
performLogin() performLogin()
} }
const handleClearCredentials = async (serverId: string, serverName: string) => {
const result = await confirm(`Clear saved credentials for "${serverName}"?`, 'Clear Credentials')
if (result.isConfirmed) {
emit('clearCredentials', serverId)
token.value = ''
username.value = ''
password.value = ''
saveCredentials.value = false
}
}
const cancelSaveCredentials = () => { const cancelSaveCredentials = () => {
showSecurityWarning.value = false showSecurityWarning.value = false
saveCredentials.value = false saveCredentials.value = false
@ -190,6 +202,11 @@ const cancelSaveCredentials = () => {
<button type="submit" class="btn btn-primary w-full" :class="{ loading: isLoading }" :disabled="isLoading"> <button type="submit" class="btn btn-primary w-full" :class="{ loading: isLoading }" :disabled="isLoading">
{{ isLoading ? 'Connecting...' : 'Connect' }} {{ isLoading ? 'Connecting...' : 'Connect' }}
</button> </button>
<button v-if="server.savedCredentials" class="btn btn-warning btn-sm" @click.prevent.stop="handleClearCredentials(server.id, server.name)">
<i class="mdi mdi-key-remove mr-1" />
Clear Credentials
</button>
</form> </form>
<!-- Security Warning Modal --> <!-- Security Warning Modal -->

View File

@ -0,0 +1,16 @@
<template>
<router-view :connection="connection" :server="connection.server" :credentials="connection.credentials" @logout="emit('logout')" />
</template>
<script setup lang="ts">
import type { VaultConnection } from '../types'
interface Props {
connection: VaultConnection
}
defineProps<Props>()
const emit = defineEmits<{
logout: []
}>()
</script>

View File

@ -0,0 +1,312 @@
<template>
<div class="h-full flex flex-col">
<!-- Breadcrumb Navigation -->
<div class="bg-base-100 border-b border-base-300 px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<router-link to="/" class="btn btn-ghost btn-sm">
<i class="mdi mdi-home mr-1"></i>
Home
</router-link>
<i class="mdi mdi-chevron-right text-base-content/50"></i>
<router-link :to="`/browse/${mount}`" class="btn btn-ghost btn-sm">
<i class="mdi mdi-folder mr-1"></i>
{{ mount }}
</router-link>
<template v-for="(segment, index) in pathSegments" :key="index">
<i class="mdi mdi-chevron-right text-base-content/50"></i>
<router-link :to="`/browse/${mount}/${pathSegments.slice(0, index + 1).join('/')}`" class="btn btn-ghost btn-sm">
{{ segment }}
</router-link>
</template>
</div>
</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 secrets...</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="flex-1 flex items-center justify-center">
<div class="alert alert-error max-w-md">
<i class="mdi mdi-alert-circle"></i>
<div>
<h3 class="font-bold">Failed to load secrets</h3>
<div class="text-sm">{{ error }}</div>
</div>
</div>
</div>
<!-- Content -->
<div v-else class="flex-1 overflow-auto p-4">
<!-- Toolbar -->
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold flex items-center">
<i class="mdi mdi-folder-open mr-2"></i>
{{ currentPath || mount }}
</h2>
<div class="flex gap-2">
<button class="btn btn-primary btn-sm" @click="showCreateModal = true">
<i class="mdi mdi-plus mr-1"></i>
Create Secret
</button>
<button class="btn btn-secondary btn-sm" @click="refreshSecrets">
<i class="mdi mdi-refresh mr-1"></i>
Refresh
</button>
</div>
</div>
<!-- Empty State -->
<div v-if="secrets.length === 0" class="text-center py-12">
<i class="mdi mdi-folder-open-outline text-6xl text-base-content/30 mb-4"></i>
<h3 class="text-lg font-semibold mb-2">No secrets found</h3>
<p class="text-base-content/70 mb-4">This folder is empty or you don't have permission to list its contents.</p>
<button class="btn btn-primary" @click="showCreateModal = true">
<i class="mdi mdi-plus mr-2"></i>
Create your first secret
</button>
</div>
<!-- Secrets List -->
<div v-else class="grid gap-2">
<!-- Folders -->
<div
v-for="folder in folders"
:key="folder.name"
class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer"
@click="navigateToFolder(folder.name)"
>
<div class="card-body p-4 flex flex-row items-center">
<i class="mdi mdi-folder text-2xl text-warning mr-3"></i>
<div class="flex-1">
<h3 class="font-semibold">{{ folder.name }}</h3>
<p class="text-sm text-base-content/70">Folder</p>
</div>
<i class="mdi mdi-chevron-right text-base-content/50"></i>
</div>
</div>
<!-- Secrets -->
<div
v-for="secret in secretFiles"
:key="secret.name"
class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer"
@click="openSecret(secret.name)"
>
<div class="card-body p-4 flex flex-row items-center">
<i class="mdi mdi-key text-2xl text-primary mr-3"></i>
<div class="flex-1">
<h3 class="font-semibold">{{ secret.name }}</h3>
<p class="text-sm text-base-content/70">Secret</p>
</div>
<div class="flex gap-1">
<button class="btn btn-ghost btn-sm" @click.stop="openSecret(secret.name)" title="View secret">
<i class="mdi mdi-eye"></i>
</button>
<button class="btn btn-ghost btn-sm" @click.stop="copySecretPath(secret.name)" title="Copy path">
<i class="mdi mdi-content-copy"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Create Secret Modal -->
<div v-if="showCreateModal" class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">Create New Secret</h3>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Secret Name</span>
</label>
<input v-model="newSecretName" type="text" placeholder="Enter secret name" class="input input-bordered" @keyup.enter="createSecret" />
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Initial Key-Value Pair</span>
</label>
<div class="flex gap-2">
<input v-model="newSecretKey" type="text" placeholder="Key" class="input input-bordered flex-1" />
<input v-model="newSecretValue" type="text" placeholder="Value" class="input input-bordered flex-1" />
</div>
</div>
<div class="modal-action">
<button class="btn btn-primary" :disabled="!newSecretName || !newSecretKey" @click="createSecret">Create Secret</button>
<button class="btn" @click="closeCreateModal">Cancel</button>
</div>
</div>
</div>
<!-- Secret Modal -->
<SecretModal v-if="selectedSecret" :server="server" :credentials="credentials" :secret-path="selectedSecret" @close="selectedSecret = null" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import type { VaultServer, VaultCredentials } from '../types'
import { VaultClient, VaultError } from '../services/vaultClient'
import { useSweetAlert } from '../composables/useSweetAlert'
import { usePolicyModal } from '../composables/usePolicyModal'
import SecretModal from './SecretModal.vue'
interface Props {
mount: string
path?: string
server: VaultServer
credentials: VaultCredentials
}
interface SecretItem {
name: string
isDirectory: boolean
}
const props = defineProps<Props>()
const router = useRouter()
const { success, error: showError } = useSweetAlert()
const { showPolicyModal } = usePolicyModal()
// State
const isLoading = ref(false)
const error = ref<string | null>(null)
const secrets = ref<SecretItem[]>([])
const selectedSecret = ref<string | null>(null)
const vaultClient = ref<VaultClient | null>(null)
// Create secret modal
const showCreateModal = ref(false)
const newSecretName = ref('')
const newSecretKey = ref('')
const newSecretValue = ref('')
// Computed
const currentPath = computed(() => props.path || '')
const pathSegments = computed(() => {
if (!props.path || typeof props.path !== 'string') return []
return props.path.split('/').filter(Boolean)
})
const folders = computed(() => secrets.value.filter(item => item.isDirectory))
const secretFiles = computed(() => secrets.value.filter(item => !item.isDirectory))
// Initialize Vault client
onMounted(() => {
vaultClient.value = new VaultClient({
server: props.server,
credentials: props.credentials,
timeout: 30000,
retries: 2,
kvVersion: 2,
})
loadSecrets()
})
// Watch for route changes
watch(
() => [props.mount, props.path],
() => {
loadSecrets()
}
)
// Methods
const loadSecrets = async () => {
if (!vaultClient.value) return
isLoading.value = true
error.value = null
try {
const listPath = props.path ? `${props.mount}/${props.path}` : props.mount
const result = await vaultClient.value.list(listPath)
if (result && Array.isArray(result)) {
secrets.value = result.map(item => ({
name: item.replace(/\/$/, ''), // Remove trailing slash
isDirectory: item.endsWith('/'),
}))
} else {
secrets.value = []
}
} catch (err) {
console.error('Error loading secrets:', err)
if (err instanceof VaultError && err.statusCode === 403) {
const fullPath = props.path ? `${props.mount}/${props.path}` : props.mount
showPolicyModal(fullPath, 'list', err.message, 'Permission Denied - Cannot List Secrets')
error.value = 'Permission denied. Check the policy guidance modal for details.'
} else {
error.value = err instanceof Error ? err.message : 'Failed to load secrets'
}
} finally {
isLoading.value = false
}
}
const refreshSecrets = () => {
loadSecrets()
}
const navigateToFolder = (folderName: string) => {
const newPath = props.path ? `${props.path}/${folderName}` : folderName
router.push(`/browse/${props.mount}/${newPath}`)
}
const openSecret = (secretName: string) => {
const secretPath = props.path ? `${props.mount}/${props.path}/${secretName}` : `${props.mount}/${secretName}`
selectedSecret.value = secretPath
}
const copySecretPath = async (secretName: string) => {
const secretPath = props.path ? `${props.mount}/${props.path}/${secretName}` : `${props.mount}/${secretName}`
try {
await navigator.clipboard.writeText(secretPath)
success('Secret path copied to clipboard!')
} catch (err) {
showError('Failed to copy to clipboard')
}
}
const createSecret = async () => {
if (!vaultClient.value || !newSecretName.value || !newSecretKey.value) return
try {
const secretPath = props.path ? `${props.mount}/${props.path}/${newSecretName.value}` : `${props.mount}/${newSecretName.value}`
const secretData = {
[newSecretKey.value]: newSecretValue.value,
}
await vaultClient.value.write(secretPath, secretData)
success('Secret created successfully!')
closeCreateModal()
await loadSecrets()
} catch (err) {
console.error('Error creating secret:', err)
if (err instanceof VaultError && err.statusCode === 403) {
const secretPath = props.path ? `${props.mount}/${props.path}/${newSecretName.value}` : `${props.mount}/${newSecretName.value}`
showPolicyModal(secretPath, 'write', err.message, 'Permission Denied - Cannot Create Secret')
} else {
showError(err instanceof Error ? err.message : 'Failed to create secret')
}
}
}
const closeCreateModal = () => {
showCreateModal.value = false
newSecretName.value = ''
newSecretKey.value = ''
newSecretValue.value = ''
}
</script>

View File

@ -13,6 +13,7 @@ const emit = defineEmits<{
addServer: [server: VaultServer] addServer: [server: VaultServer]
removeServer: [serverId: string] removeServer: [serverId: string]
selectServer: [server: VaultServer] selectServer: [server: VaultServer]
clearAllCredentials: []
}>() }>()
const { confirm } = useSweetAlert() const { confirm } = useSweetAlert()
@ -45,6 +46,18 @@ const handleRemove = async (serverId: string, serverName: string) => {
emit('removeServer', serverId) emit('removeServer', serverId)
} }
} }
const handleClearAllCredentials = async () => {
const serversWithCredentials = props.servers.filter(s => s.savedCredentials)
if (serversWithCredentials.length === 0) {
return
}
const result = await confirm(`Clear saved credentials for all ${serversWithCredentials.length} server(s)?`, 'Clear All Credentials')
if (result.isConfirmed) {
emit('clearAllCredentials')
}
}
</script> </script>
<template> <template>
@ -53,11 +66,13 @@ const handleRemove = async (serverId: string, serverName: string) => {
<!-- Header --> <!-- Header -->
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="card-title text-2xl">Vault Servers</h2> <h2 class="card-title text-2xl">Vault Servers</h2>
<div class="flex gap-2">
<button class="btn btn-primary btn-sm" @click="showAddForm = !showAddForm"> <button class="btn btn-primary btn-sm" @click="showAddForm = !showAddForm">
<i :class="showAddForm ? 'mdi mdi-close' : 'mdi mdi-plus'" class="mr-2" /> <i :class="showAddForm ? 'mdi mdi-close' : 'mdi mdi-plus'" class="mr-2" />
{{ showAddForm ? 'Cancel' : 'Add Server' }} {{ showAddForm ? 'Cancel' : 'Add Server' }}
</button> </button>
</div> </div>
</div>
<!-- Add Server Form --> <!-- Add Server Form -->
<div v-if="showAddForm" class="bg-base-200 p-4 rounded-lg mb-4"> <div v-if="showAddForm" class="bg-base-200 p-4 rounded-lg mb-4">
@ -122,10 +137,18 @@ const handleRemove = async (serverId: string, serverName: string) => {
</span> </span>
</div> </div>
</div> </div>
<div class="flex gap-2">
<button class="btn btn-error btn-sm" @click.stop="handleRemove(server.id, server.name)">Remove</button> <button class="btn btn-error btn-sm" @click.stop="handleRemove(server.id, server.name)">Remove</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="space-y-3 flex mt-2">
<button v-if="servers.some(s => s.savedCredentials)" class="flex-grow btn btn-warning btn-sm" @click="handleClearAllCredentials">
<i class="mdi mdi-key-remove mr-2" />
Clear All Credentials
</button>
</div>
</div>
</div> </div>
</template> </template>

View File

@ -1,5 +1,6 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import './style.css' import './style.css'
import App from './App.vue' import App from './App.vue'
import router from './router'
createApp(App).mount('#app') createApp(App).use(router).mount('#app')

34
src/router/index.ts Normal file
View File

@ -0,0 +1,34 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '../components/Dashboard.vue'
import SecretBrowser from '../components/SecretBrowser.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'dashboard',
component: Dashboard,
},
{
path: '/browse/:mount',
name: 'secret-browser-root',
component: SecretBrowser,
props: route => ({
mount: route.params.mount,
path: undefined,
}),
},
{
path: '/browse/:mount/:path+',
name: 'secret-browser',
component: SecretBrowser,
props: route => ({
mount: route.params.mount,
path: Array.isArray(route.params.path) ? route.params.path.join('/') : route.params.path,
}),
},
],
})
export default router