global search + clear saved creds
This commit is contained in:
parent
5ab7004477
commit
2f605ac82b
@ -13,7 +13,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"sweetalert2": "^11.26.3",
|
||||
"vue": "^3.4.15"
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
|
||||
@ -14,6 +14,9 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.4.15
|
||||
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:
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^6.14.0
|
||||
@ -494,6 +497,9 @@ packages:
|
||||
'@vue/compiler-ssr@3.5.22':
|
||||
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':
|
||||
resolution: {integrity: sha512-StxLFet2Qe97T8+7L8pGlhYBBr8Eg05LPuTDVopQV6il+SK6qqom59BA/rcFipUef2jD8P2X44Vd8tMFytfvlg==}
|
||||
engines: {node: ^14.17.0 || >=16.0.0}
|
||||
@ -1345,6 +1351,11 @@ packages:
|
||||
peerDependencies:
|
||||
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:
|
||||
resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==}
|
||||
|
||||
@ -1757,6 +1768,8 @@ snapshots:
|
||||
'@vue/compiler-dom': 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)':
|
||||
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)
|
||||
@ -2621,6 +2634,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
dependencies:
|
||||
de-indent: 1.0.2
|
||||
|
||||
62
src/App.vue
62
src/App.vue
@ -3,8 +3,10 @@ import { ref, onMounted, watch } from 'vue'
|
||||
import type { VaultServer, VaultCredentials, VaultConnection } from './types'
|
||||
import ServerSelector from './components/ServerSelector.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 GlobalSearch from './components/GlobalSearch.vue'
|
||||
import SecretModal from './components/SecretModal.vue'
|
||||
import { useSweetAlert } from './composables/useSweetAlert'
|
||||
import { usePolicyModal } from './composables/usePolicyModal'
|
||||
|
||||
@ -15,6 +17,10 @@ const activeConnection = ref<VaultConnection | null>(null)
|
||||
const { error } = useSweetAlert()
|
||||
const { modalState, closePolicyModal } = usePolicyModal()
|
||||
|
||||
// Global search state
|
||||
const showSecretModal = ref(false)
|
||||
const selectedSecretPath = ref('')
|
||||
|
||||
// Load servers from localStorage on mount
|
||||
onMounted(() => {
|
||||
const savedServers = localStorage.getItem('vaultServers')
|
||||
@ -93,6 +99,30 @@ const handleLogin = async (credentials: VaultCredentials, shouldSaveCredentials:
|
||||
const handleLogout = () => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@ -100,12 +130,26 @@ const handleLogout = () => {
|
||||
<!-- Header -->
|
||||
<header class="bg-gradient-to-r from-primary to-secondary text-primary-content shadow-lg">
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<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">
|
||||
<i class="mdi mdi-shield-lock text-primary-content" />
|
||||
Browser Vault GUI
|
||||
</h1>
|
||||
<p class="text-lg opacity-90">Alternative frontend for HashiCorp Vault</p>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Main Content -->
|
||||
@ -119,17 +163,18 @@ const handleLogout = () => {
|
||||
@add-server="handleAddServer"
|
||||
@remove-server="handleRemoveServer"
|
||||
@select-server="handleSelectServer"
|
||||
@clear-all-credentials="handleClearAllCredentials"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div v-if="selectedServer">
|
||||
<LoginForm :server="selectedServer" @login="handleLogin" />
|
||||
<LoginForm :server="selectedServer" @login="handleLogin" @clear-credentials="handleClearCredentials" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<Dashboard v-else :connection="activeConnection" @logout="handleLogout" />
|
||||
<!-- Router Wrapper -->
|
||||
<RouterWrapper v-else :connection="activeConnection" @logout="handleLogout" />
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
@ -146,5 +191,14 @@ const handleLogout = () => {
|
||||
:original-error="modalState.originalError"
|
||||
@close="closePolicyModal"
|
||||
/>
|
||||
|
||||
<!-- Global Secret Modal -->
|
||||
<SecretModal
|
||||
v-if="showSecretModal && activeConnection"
|
||||
:server="activeConnection.server"
|
||||
:credentials="activeConnection.credentials"
|
||||
:secret-path="selectedSecretPath"
|
||||
@close="showSecretModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { VaultConnection } from '../types'
|
||||
import PathSearch from './PathSearch.vue'
|
||||
import Settings from './Settings.vue'
|
||||
import SecretModal from './SecretModal.vue'
|
||||
|
||||
interface Props {
|
||||
connection: VaultConnection
|
||||
@ -15,14 +13,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@ -58,24 +48,38 @@ const handleSelectPath = (path: string) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Component -->
|
||||
<PathSearch
|
||||
:server="connection.server"
|
||||
:credentials="connection.credentials"
|
||||
:mount-points="connection.mountPoints"
|
||||
@select-path="handleSelectPath"
|
||||
/>
|
||||
<!-- Mount Points Browser -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title mb-4">
|
||||
<i class="mdi mdi-folder-multiple mr-2"></i>
|
||||
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 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>
|
||||
|
||||
232
src/components/GlobalSearch.vue
Normal file
232
src/components/GlobalSearch.vue
Normal 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>
|
||||
@ -10,9 +10,10 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
login: [credentials: VaultCredentials, saveCredentials: boolean]
|
||||
clearCredentials: [serverId: string]
|
||||
}>()
|
||||
|
||||
const { error } = useSweetAlert()
|
||||
const { confirm, error } = useSweetAlert()
|
||||
|
||||
const authMethod = ref<'token' | 'userpass' | 'ldap'>('token')
|
||||
const token = ref('')
|
||||
@ -90,6 +91,17 @@ const confirmSaveCredentials = () => {
|
||||
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 = () => {
|
||||
showSecurityWarning.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">
|
||||
{{ isLoading ? 'Connecting...' : 'Connect' }}
|
||||
</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>
|
||||
|
||||
<!-- Security Warning Modal -->
|
||||
|
||||
16
src/components/RouterWrapper.vue
Normal file
16
src/components/RouterWrapper.vue
Normal 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>
|
||||
312
src/components/SecretBrowser.vue
Normal file
312
src/components/SecretBrowser.vue
Normal 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>
|
||||
@ -13,6 +13,7 @@ const emit = defineEmits<{
|
||||
addServer: [server: VaultServer]
|
||||
removeServer: [serverId: string]
|
||||
selectServer: [server: VaultServer]
|
||||
clearAllCredentials: []
|
||||
}>()
|
||||
|
||||
const { confirm } = useSweetAlert()
|
||||
@ -45,6 +46,18 @@ const handleRemove = async (serverId: string, serverName: string) => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@ -53,11 +66,13 @@ const handleRemove = async (serverId: string, serverName: string) => {
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title text-2xl">Vault Servers</h2>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" @click="showAddForm = !showAddForm">
|
||||
<i :class="showAddForm ? 'mdi mdi-close' : 'mdi mdi-plus'" class="mr-2" />
|
||||
{{ showAddForm ? 'Cancel' : 'Add Server' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Server Form -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-error btn-sm" @click.stop="handleRemove(server.id, server.name)">Remove</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
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
34
src/router/index.ts
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user