global search + clear saved creds
This commit is contained in:
parent
5ab7004477
commit
2f605ac82b
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
72
src/App.vue
72
src/App.vue
@ -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,11 +130,25 @@ 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">
|
||||||
<h1 class="text-4xl font-bold mb-2 flex items-center gap-3">
|
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||||
<i class="mdi mdi-shield-lock text-primary-content" />
|
<div class="flex-shrink-0">
|
||||||
Browser Vault GUI
|
<h1 class="text-4xl font-bold mb-2 flex items-center gap-3">
|
||||||
</h1>
|
<i class="mdi mdi-shield-lock text-primary-content" />
|
||||||
<p class="text-lg opacity-90">Alternative frontend for HashiCorp Vault</p>
|
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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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 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 -->
|
||||||
|
|||||||
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]
|
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,10 +66,12 @@ 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>
|
||||||
<button class="btn btn-primary btn-sm" @click="showAddForm = !showAddForm">
|
<div class="flex gap-2">
|
||||||
<i :class="showAddForm ? 'mdi mdi-close' : 'mdi mdi-plus'" class="mr-2" />
|
<button class="btn btn-primary btn-sm" @click="showAddForm = !showAddForm">
|
||||||
{{ showAddForm ? 'Cancel' : 'Add Server' }}
|
<i :class="showAddForm ? 'mdi mdi-close' : 'mdi mdi-plus'" class="mr-2" />
|
||||||
</button>
|
{{ showAddForm ? 'Cancel' : 'Add Server' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Server Form -->
|
<!-- Add Server Form -->
|
||||||
@ -122,10 +137,18 @@ const handleRemove = async (serverId: string, serverName: string) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-error btn-sm" @click.stop="handleRemove(server.id, server.name)">Remove</button>
|
<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>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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
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