This commit is contained in:
2025-10-20 19:34:11 +02:00
parent 4527fc8c76
commit 823e377e4b
14 changed files with 2066 additions and 441 deletions
+17 -1
View File
@@ -41,7 +41,7 @@ const handleSelectServer = (server: VaultServer) => {
activeConnection.value = null
}
const handleLogin = async (credentials: VaultCredentials) => {
const handleLogin = async (credentials: VaultCredentials, shouldSaveCredentials: boolean) => {
if (!selectedServer.value) return
try {
@@ -60,6 +60,22 @@ const handleLogin = async (credentials: VaultCredentials) => {
mountPoints,
}
// Save credentials if requested
if (shouldSaveCredentials) {
const serverIndex = servers.value.findIndex(s => s.id === selectedServer.value!.id)
if (serverIndex !== -1) {
servers.value[serverIndex].savedCredentials = credentials
console.log('⚠️ Credentials saved to localStorage (insecure!)')
}
} else {
// Remove saved credentials if user unchecked the option
const serverIndex = servers.value.findIndex(s => s.id === selectedServer.value!.id)
if (serverIndex !== -1 && servers.value[serverIndex].savedCredentials) {
delete servers.value[serverIndex].savedCredentials
console.log('✓ Saved credentials removed from localStorage')
}
}
console.log(`✓ Logged in successfully. Found ${mountPoints.length} KV mount point(s).`)
} catch (error) {
console.error('Login failed:', error)
+118 -33
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import type { VaultConnection } from '../types'
import { vaultApi, VaultError } from '../services/vaultApi'
import PathSearch from './PathSearch.vue'
import Settings from './Settings.vue'
import SecretModal from './SecretModal.vue'
interface Props {
connection: VaultConnection
@@ -14,18 +15,32 @@ const emit = defineEmits<{
logout: []
}>()
const currentPath = ref('')
const selectedMountPoint = ref('')
const secretPath = ref('')
const secretData = ref<Record<string, unknown> | null>(null)
const isLoading = ref(false)
const showSettings = ref(false)
const showSearch = ref(false)
const showSearch = ref(true) // Show search by default
const showSecretModal = ref(false)
const selectedSecretPath = ref('')
// Select first mount point by default
onMounted(() => {
if (props.connection.mountPoints && props.connection.mountPoints.length > 0) {
selectedMountPoint.value = props.connection.mountPoints[0].path
}
})
const handleReadSecret = async (path?: string) => {
const pathToRead = path || currentPath.value
let pathToRead = path
if (!pathToRead) {
alert('Please enter a secret path')
return
// Build path from mount point + secret path
if (!selectedMountPoint.value || !secretPath.value) {
alert('Please select a mount point and enter a secret path')
return
}
pathToRead = `${selectedMountPoint.value}/${secretPath.value}`
}
isLoading.value = true
@@ -40,7 +55,10 @@ const handleReadSecret = async (path?: string) => {
if (data) {
secretData.value = data
currentPath.value = pathToRead
// Update the form fields if this was a manual read
if (!path) {
// Keep the current mount point and path
}
} else {
alert('Secret not found or empty.')
}
@@ -74,16 +92,48 @@ const handleReadSecret = async (path?: string) => {
}
const handleSelectPath = (path: string) => {
currentPath.value = path
handleReadSecret(path)
showSearch.value = false
// Parse the path to extract mount point and secret path
const mountPoints = props.connection.mountPoints || []
let foundMount = ''
let remainingPath = path
// Find the longest matching mount point
for (const mount of mountPoints) {
const mountPath = mount.path + '/'
if (path.startsWith(mountPath)) {
if (mountPath.length > foundMount.length) {
foundMount = mount.path
remainingPath = path.substring(mountPath.length)
}
}
}
if (foundMount) {
selectedMountPoint.value = foundMount
secretPath.value = remainingPath
}
// Open secret in modal instead of inline
selectedSecretPath.value = path
showSecretModal.value = true
}
const handleKeyPress = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !isLoading.value) {
handleReadSecret()
handleViewSecret()
}
}
const handleViewSecret = () => {
if (!selectedMountPoint.value || !secretPath.value) {
alert('Please select a mount point and enter a secret path')
return
}
const fullPath = `${selectedMountPoint.value}/${secretPath.value}`
selectedSecretPath.value = fullPath
showSecretModal.value = true
}
</script>
<template>
@@ -107,7 +157,7 @@ const handleKeyPress = (event: KeyboardEvent) => {
class="btn btn-primary btn-sm"
@click="showSearch = !showSearch"
>
{{ showSearch ? 'Hide Search' : '🔍 Search' }}
{{ showSearch ? 'Hide Search' : '🔍 Show Search' }}
</button>
<button
class="btn btn-sm"
@@ -140,36 +190,60 @@ const handleKeyPress = (event: KeyboardEvent) => {
<div class="card-body">
<h3 class="text-xl font-bold mb-4">Browse Secrets</h3>
<!-- Path Input -->
<!-- Mount Point Selector -->
<div class="form-control">
<label class="label">
<span class="label-text">Mount Point</span>
</label>
<select
v-model="selectedMountPoint"
class="select select-bordered w-full"
:disabled="isLoading"
>
<option value="">Select a mount point...</option>
<option
v-for="mount in connection.mountPoints"
:key="mount.path"
:value="mount.path"
>
{{ mount.path }}/ ({{ mount.type }} v2)
</option>
</select>
</div>
<!-- Secret Path Input -->
<div class="form-control">
<label class="label">
<span class="label-text">Secret Path</span>
</label>
<div class="join w-full">
<span class="join-item bg-base-300 px-3 py-2 text-sm font-mono border border-base-300">
{{ selectedMountPoint || 'mount' }}/
</span>
<input
v-model="currentPath"
v-model="secretPath"
type="text"
placeholder="secret/data/myapp/config"
placeholder="data/myapp/config"
class="input input-bordered join-item flex-1"
:disabled="isLoading"
:disabled="isLoading || !selectedMountPoint"
@keypress="handleKeyPress"
/>
<button
class="btn btn-primary join-item"
:class="{ 'loading': isLoading }"
:disabled="isLoading"
@click="handleReadSecret()"
:disabled="!selectedMountPoint || !secretPath"
@click="handleViewSecret()"
>
{{ isLoading ? 'Loading...' : 'Read Secret' }}
View Secret
</button>
</div>
<label class="label">
<span class="label-text-alt">
Full path: {{ selectedMountPoint ? `${selectedMountPoint}/${secretPath || 'path'}` : 'Select mount point first' }}
</span>
</label>
</div>
<!-- Secret Data Display -->
<div v-if="secretData" class="mt-6">
<h4 class="text-lg font-semibold mb-2">Secret Data</h4>
<pre class="bg-base-300 p-4 rounded-lg overflow-x-auto text-sm">{{ JSON.stringify(secretData, null, 2) }}</pre>
</div>
<!-- Removed inline secret display - now using modal -->
<!-- Info Box -->
<div v-if="!showSearch" class="alert alert-info mt-6">
@@ -177,12 +251,13 @@ const handleKeyPress = (event: KeyboardEvent) => {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div class="text-sm">
<h4 class="font-bold">Getting Started</h4>
<h4 class="font-bold">Browse Secrets</h4>
<ul class="list-disc list-inside mt-2 space-y-1">
<li>Enter a secret path to read from your Vault server</li>
<li>Example paths: <code class="bg-base-200 px-1 rounded">secret/data/myapp/config</code></li>
<li>Use the Search feature to find secrets recursively</li>
<li>Results are cached to prevent excessive API calls</li>
<li>Select a mount point from the detected KV secret engines</li>
<li>Enter the secret path (without the mount point prefix)</li>
<li>Example: Mount <code class="bg-base-200 px-1 rounded">secret</code> + Path <code class="bg-base-200 px-1 rounded">data/myapp/config</code></li>
<li>Use Search (shown above) to find secrets across all mount points</li>
<li><strong>Security:</strong> Secret data is never cached - always fetched fresh</li>
</ul>
</div>
</div>
@@ -193,9 +268,10 @@ const handleKeyPress = (event: KeyboardEvent) => {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<div class="text-xs">
<h4 class="font-semibold">Implementation Notes</h4>
<p class="mt-1">This application uses the Vault HTTP API with caching enabled.</p>
<p class="mt-1">All requests include the <code class="bg-base-200 px-1 rounded">X-Vault-Token</code> header for authentication. Configure cache settings and search limits in Settings.</p>
<h4 class="font-semibold">Security & Caching</h4>
<p class="mt-1">🔒 <strong>Secret data is NEVER cached</strong> - always fetched fresh for security.</p>
<p class="mt-1">📂 Directory listings are cached to improve search performance.</p>
<p class="mt-1">🔑 All requests include the <code class="bg-base-200 px-1 rounded">X-Vault-Token</code> header for authentication.</p>
</div>
</div>
</div>
@@ -206,6 +282,15 @@ const handleKeyPress = (event: KeyboardEvent) => {
v-if="showSettings"
@close="showSettings = false"
/>
<!-- Secret Viewer Modal -->
<SecretModal
v-if="showSecretModal"
:server="connection.server"
:credentials="connection.credentials"
:secret-path="selectedSecretPath"
@close="showSecretModal = false"
/>
</div>
</template>
+130 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
import type { VaultServer, VaultCredentials } from '../types'
interface Props {
@@ -8,7 +8,7 @@ interface Props {
const props = defineProps<Props>()
const emit = defineEmits<{
login: [credentials: VaultCredentials]
login: [credentials: VaultCredentials, saveCredentials: boolean]
}>()
const authMethod = ref<'token' | 'userpass' | 'ldap'>('token')
@@ -16,8 +16,48 @@ const token = ref('')
const username = ref('')
const password = ref('')
const isLoading = ref(false)
const saveCredentials = ref(false)
const showSecurityWarning = ref(false)
// Function to load credentials from server
const loadCredentialsFromServer = (server: VaultServer) => {
if (server.savedCredentials) {
// Load saved credentials
authMethod.value = server.savedCredentials.authMethod
token.value = server.savedCredentials.token || ''
username.value = server.savedCredentials.username || ''
password.value = server.savedCredentials.password || ''
saveCredentials.value = true
} else {
// Clear form when no saved credentials
authMethod.value = 'token'
token.value = ''
username.value = ''
password.value = ''
saveCredentials.value = false
}
}
// Load credentials on initial mount
loadCredentialsFromServer(props.server)
// Watch for server changes and reload credentials
watch(() => props.server, (newServer) => {
loadCredentialsFromServer(newServer)
showSecurityWarning.value = false // Close any open warning modal
}, { immediate: false })
const handleSubmit = async () => {
// Show warning if user is trying to save credentials for the first time
if (saveCredentials.value && !props.server.savedCredentials) {
showSecurityWarning.value = true
return
}
await performLogin()
}
const performLogin = async () => {
isLoading.value = true
const credentials: VaultCredentials = {
@@ -29,7 +69,7 @@ const handleSubmit = async () => {
}
try {
await emit('login', credentials)
await emit('login', credentials, saveCredentials.value)
} catch (error) {
console.error('Login error:', error)
alert('Login failed. Please check your credentials.')
@@ -37,6 +77,16 @@ const handleSubmit = async () => {
isLoading.value = false
}
}
const confirmSaveCredentials = () => {
showSecurityWarning.value = false
performLogin()
}
const cancelSaveCredentials = () => {
showSecurityWarning.value = false
saveCredentials.value = false
}
</script>
<template>
@@ -114,6 +164,25 @@ const handleSubmit = async () => {
</div>
</template>
<!-- Save Credentials Option -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
v-model="saveCredentials"
type="checkbox"
class="checkbox checkbox-warning"
/>
<span class="label-text">
<span class="font-semibold text-warning"> Save credentials locally</span>
</span>
</label>
<label class="label">
<span class="label-text-alt text-warning">
Not recommended! Credentials will be stored in plain text in localStorage
</span>
</label>
</div>
<!-- Submit Button -->
<button
type="submit"
@@ -125,6 +194,64 @@ const handleSubmit = async () => {
</button>
</form>
<!-- Security Warning Modal -->
<div v-if="showSecurityWarning" class="modal modal-open">
<div class="modal-box border-2 border-error">
<h3 class="font-bold text-lg text-error mb-4"> Security Warning</h3>
<div class="space-y-4">
<div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-semibold">This is NOT recommended for security reasons!</span>
</div>
<div class="text-sm space-y-2">
<p class="font-semibold">If you save credentials:</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>Your token/password will be stored in <strong>plain text</strong></li>
<li>Anyone with access to your browser can read them</li>
<li>Browser extensions can access localStorage</li>
<li>If your computer is compromised, credentials are exposed</li>
<li>This violates most security policies</li>
</ul>
<p class="font-semibold mt-4">Only use this if:</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>You're on a personal, secure device</li>
<li>You understand the security risks</li>
<li>You're using a development/test Vault server</li>
</ul>
</div>
<div class="bg-base-300 p-3 rounded text-xs">
<p class="font-mono">
<strong>Better alternatives:</strong><br>
Use short-lived tokens<br>
Re-login each session<br>
Use a password manager<br>
Enable auto-logout timeout
</p>
</div>
</div>
<div class="modal-action">
<button
class="btn btn-ghost"
@click="cancelSaveCredentials"
>
Cancel - Don't Save
</button>
<button
class="btn btn-error"
@click="confirmSaveCredentials"
>
I Understand the Risks - Save Anyway
</button>
</div>
</div>
</div>
<!-- Security Notice -->
<div class="alert mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info shrink-0 w-6 h-6">
+28 -78
View File
@@ -15,8 +15,6 @@ const emit = defineEmits<{
}>()
const searchTerm = ref('')
const basePath = ref('secret/')
const searchAllMounts = ref(false)
const results = ref<SearchResult[]>([])
const isSearching = ref(false)
const searchTime = ref<number | null>(null)
@@ -34,7 +32,7 @@ const handleSearch = async () => {
return
}
if (searchAllMounts.value && !mountPointsAvailable.value) {
if (!mountPointsAvailable.value) {
alert('No mount points available. Please ensure you are connected to Vault.')
return
}
@@ -46,25 +44,13 @@ const handleSearch = async () => {
const startTime = performance.now()
try {
let searchResults: SearchResult[]
if (searchAllMounts.value && props.mountPoints) {
// Search across all mount points
searchResults = await vaultApi.searchAllMounts(
props.server,
props.credentials,
props.mountPoints,
searchTerm.value
)
} else {
// Search in specific base path
searchResults = await vaultApi.searchPaths(
props.server,
props.credentials,
basePath.value,
searchTerm.value
)
}
// Always search across all mount points
const searchResults = await vaultApi.searchAllMounts(
props.server,
props.credentials,
props.mountPoints!,
searchTerm.value
)
const endTime = performance.now()
searchTime.value = endTime - startTime
@@ -91,49 +77,20 @@ const handleKeyPress = (event: KeyboardEvent) => {
<!-- Search Controls -->
<div class="space-y-4">
<!-- Search All Mounts Checkbox -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
v-model="searchAllMounts"
type="checkbox"
class="checkbox checkbox-primary"
:disabled="!mountPointsAvailable"
/>
<div class="flex-1">
<span class="label-text">
Search across all mount points
<span v-if="mountPointsAvailable" class="text-primary font-semibold">
({{ mountPoints?.length }} available)
</span>
<span v-else class="text-error italic text-sm">
(none detected - logout and login again)
</span>
</span>
<p class="label-text-alt mt-1">
{{ !mountPointsAvailable
? 'Mount points are detected on login. Please logout and login again to enable this feature.'
: 'When enabled, searches all KV mount points instead of a specific base path'
}}
</p>
</div>
</label>
</div>
<!-- Base Path (only shown when not searching all mounts) -->
<div v-if="!searchAllMounts" class="form-control">
<label class="label">
<span class="label-text">Base Path</span>
</label>
<input
v-model="basePath"
type="text"
placeholder="secret/"
class="input input-bordered w-full"
/>
<label class="label">
<span class="label-text-alt">Starting path for recursive search</span>
</label>
<!-- Search Info -->
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div class="text-sm">
<p class="font-semibold">🌐 Searching across all mount points</p>
<p v-if="mountPointsAvailable">
Found {{ mountPoints?.length }} KV mount point(s): {{ mountPoints?.map(m => m.path).join(', ') }}
</p>
<p v-else class="text-error">
No mount points detected - logout and login again to refresh
</p>
</div>
</div>
<!-- Search Term -->
@@ -194,7 +151,7 @@ const handleKeyPress = (event: KeyboardEvent) => {
<span class="text-2xl">{{ result.isDirectory ? '📁' : '📄' }}</span>
<div class="flex-1 min-w-0">
<p class="font-mono text-sm break-all">{{ result.path }}</p>
<p v-if="result.mountPoint && searchAllMounts" class="text-xs opacity-60 italic">
<p v-if="result.mountPoint" class="text-xs opacity-60 italic">
📌 {{ result.mountPoint }}
</p>
</div>
@@ -217,10 +174,8 @@ const handleKeyPress = (event: KeyboardEvent) => {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<div>
<p>No results found for "{{ searchTerm }}"
{{ searchAllMounts ? ' across all mount points' : ` in ${basePath}` }}
</p>
<p class="text-sm">Try a different search term{{ !searchAllMounts ? ' or base path' : '' }}</p>
<p>No results found for "{{ searchTerm }}" across all mount points</p>
<p class="text-sm">Try a different search term or check if the secret exists</p>
</div>
</div>
@@ -233,15 +188,10 @@ const handleKeyPress = (event: KeyboardEvent) => {
<h4 class="font-bold"> Search Tips</h4>
<ul class="list-disc list-inside mt-2 space-y-1">
<li>Search is case-insensitive and matches partial paths</li>
<li>Results are cached to prevent excessive API calls</li>
<li>
<strong>Search all mounts:</strong> Enable to search across all KV secret engines
<span v-if="mountPointsAvailable">
(detected: {{ mountPoints?.map(m => m.path).join(', ') }})
</span>
</li>
<li><strong>Base path:</strong> When not searching all mounts, specify a starting path</li>
<li>Searches across all detected KV secret engines automatically</li>
<li>Directory listings are cached to improve performance</li>
<li>Directories are marked with 📁, secrets with 📄</li>
<li>Click "View" on secrets to open detailed modal with metadata</li>
<li>Maximum search depth and results can be configured in settings</li>
</ul>
</div>
+554
View File
@@ -0,0 +1,554 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import type { VaultServer, VaultCredentials } from "../types";
import { vaultApi, VaultError } from "../services/vaultApi";
interface Props {
server: VaultServer;
credentials: VaultCredentials;
secretPath: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
close: [];
}>();
const secretData = ref<Record<string, unknown> | null>(null);
const secretMetadata = ref<any>(null);
const secretVersions = ref<any[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"current" | "json" | "metadata" | "versions">("current");
const visibleValues = ref<Record<string, boolean>>({});
onMounted(() => {
loadSecret();
});
const loadSecret = async () => {
isLoading.value = true;
error.value = null;
try {
// Load current secret data
const response = await vaultApi.readSecret(
props.server,
props.credentials,
props.secretPath,
);
console.log("Secret response structure:", response);
// For KV v2, the response includes both data and metadata
if (response && typeof response === "object") {
// Extract secret data (usually under 'data' key)
secretData.value = response.data || response;
// Extract metadata if present in the response
if (response.metadata) {
secretMetadata.value = {
...response.metadata,
// Add any additional metadata fields from the response root
current_version: response.metadata.version,
created_time: response.metadata.created_time,
updated_time: response.metadata.created_time, // KV v2 doesn't have separate updated_time in single secret response
destroyed: response.metadata.destroyed,
deletion_time: response.metadata.deletion_time,
custom_metadata: response.metadata.custom_metadata,
};
// Create a single version entry from the current metadata
if (response.metadata.version) {
secretVersions.value = [
{
version: response.metadata.version,
created_time: new Date(
response.metadata.created_time,
).toLocaleString(),
destroyed: response.metadata.destroyed,
deletion_time: response.metadata.deletion_time,
},
];
}
}
} else {
secretData.value = response;
}
// Try to load full metadata and version history from metadata endpoint
await loadMetadataAndVersions();
} catch (err) {
console.error("Error loading secret:", err);
if (err instanceof VaultError) {
error.value = `${err.message} (HTTP ${err.statusCode || "Unknown"})`;
if (err.errors && err.errors.length > 0) {
error.value += `\n\nDetails:\n${err.errors.join("\n")}`;
}
} else {
error.value = err instanceof Error ? err.message : "Unknown error";
}
} finally {
isLoading.value = false;
}
};
const loadMetadataAndVersions = async () => {
try {
// Use the dedicated readSecretMetadata method from VaultApi
const fullMetadata = await vaultApi.readSecretMetadata(
props.server,
props.credentials,
props.secretPath,
);
if (fullMetadata) {
console.log("Full metadata response:", fullMetadata);
// Merge with existing metadata or replace it
secretMetadata.value = {
...secretMetadata.value, // Keep any metadata from the secret response
...fullMetadata, // Override with full metadata
};
// Extract complete version history from full metadata
if (fullMetadata.versions) {
secretVersions.value = Object.entries(fullMetadata.versions)
.map(([version, versionData]: [string, any]) => ({
version: parseInt(version),
...versionData,
created_time: new Date(versionData.created_time).toLocaleString(),
}))
.sort((a, b) => b.version - a.version); // Latest first
} else if (secretMetadata.value?.current_version) {
// Fallback: if no versions array but we have current version info
secretVersions.value = [
{
version: secretMetadata.value.current_version,
created_time: secretMetadata.value.created_time
? new Date(secretMetadata.value.created_time).toLocaleString()
: "Unknown",
destroyed: secretMetadata.value.destroyed || false,
deletion_time: secretMetadata.value.deletion_time,
},
];
}
}
} catch (err) {
console.warn(
"Could not load full metadata (using basic metadata from secret response):",
err,
);
// If we can't load full metadata, we'll use what we extracted from the secret response
}
};
const loadVersion = async (version: number) => {
isLoading.value = true;
error.value = null;
try {
// For KV v2, append ?version=X to get specific version
const versionPath = `${props.secretPath}?version=${version}`;
const data = await vaultApi.readSecret(
props.server,
props.credentials,
versionPath,
);
secretData.value = data;
activeTab.value = "current";
} catch (err) {
console.error("Error loading version:", err);
error.value = err instanceof Error ? err.message : "Unknown error";
} finally {
isLoading.value = false;
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
// Could add a toast notification here
} catch (err) {
console.error("Failed to copy:", err);
}
};
const toggleValueVisibility = (key: string) => {
visibleValues.value[key] = !visibleValues.value[key];
};
const isValueVisible = (key: string): boolean => {
return visibleValues.value[key] || false;
};
const maskValue = (value: string): string => {
return "•".repeat(Math.min(value.length, 12));
};
const getDisplayValue = (key: string, value: unknown): string => {
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
return isValueVisible(key) ? stringValue : maskValue(stringValue);
};
const toggleAllValues = () => {
if (!secretData.value) return;
// Check if any values are currently visible
const hasVisibleValues = Object.values(visibleValues.value).some((v) => v);
// Set all keys to the opposite state
Object.keys(secretData.value).forEach((key) => {
visibleValues.value[key] = !hasVisibleValues;
});
};
</script>
<template>
<!-- Modal Overlay -->
<div class="modal modal-open" @click.self="emit('close')">
<div class="modal-box max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
<!-- Header -->
<div class="flex justify-between items-start mb-4 flex-shrink-0">
<div class="flex-1 min-w-0">
<h2 class="text-xl font-bold truncate">🔐 Secret Viewer</h2>
<p class="text-sm font-mono opacity-70 truncate mt-1">
{{ secretPath }}
</p>
</div>
<button
class="btn btn-sm btn-circle btn-ghost ml-4"
@click="emit('close')"
>
</button>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex-1 flex items-center justify-center">
<div class="text-center">
<span class="loading loading-spinner loading-lg"></span>
<p class="mt-4">Loading secret...</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="flex-1">
<div class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<h3 class="font-bold">Failed to load secret</h3>
<pre class="text-xs mt-2 whitespace-pre-wrap">{{ error }}</pre>
</div>
</div>
</div>
<!-- Content -->
<div v-else class="flex-1 flex flex-col overflow-hidden">
<!-- Tabs -->
<div class="tabs tabs-bordered mb-4 flex-shrink-0">
<button
class="tab"
:class="{ 'tab-active': activeTab === 'current' }"
@click="activeTab = 'current'"
>
📄 Current Data
</button>
<button
class="tab"
:class="{ 'tab-active': activeTab === 'json' }"
@click="activeTab = 'json'"
>
📋 JSON Data
</button>
<button
v-if="secretMetadata"
class="tab"
:class="{ 'tab-active': activeTab === 'metadata' }"
@click="activeTab = 'metadata'"
>
Metadata
</button>
<button
v-if="secretVersions.length > 0"
class="tab"
:class="{ 'tab-active': activeTab === 'versions' }"
@click="activeTab = 'versions'"
>
🕒 Versions ({{ secretVersions.length }})
</button>
</div>
<!-- Tab Content -->
<div class="flex-1 overflow-hidden">
<!-- Current Data Tab (Table View) -->
<div v-if="activeTab === 'current'" class="h-full flex flex-col">
<div class="flex justify-between items-center mb-3 flex-shrink-0">
<h3 class="font-semibold">Secret Data</h3>
<div class="flex gap-2">
<button class="btn btn-sm btn-outline" @click="toggleAllValues">
{{
Object.values(visibleValues).some((v) => v)
? "🙈 Hide All"
: "👁️ Show All"
}}
</button>
</div>
</div>
<div class="flex-1 overflow-auto">
<div
v-if="secretData && Object.keys(secretData).length > 0"
class="overflow-x-auto"
>
<table class="table table-zebra w-full">
<thead>
<tr>
<th class="w-1/3">Key</th>
<th class="w-1/2">Value</th>
<th class="w-1/6">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="[key, value] in Object.entries(secretData)"
:key="key"
>
<td class="font-mono font-semibold">{{ key }}</td>
<td class="font-mono text-sm">
<span class="select-all">{{
getDisplayValue(key, value)
}}</span>
</td>
<td>
<div class="flex gap-1">
<button
class="btn btn-xs btn-ghost"
:title="
isValueVisible(key) ? 'Hide value' : 'Show value'
"
@click="toggleValueVisibility(key)"
>
{{ isValueVisible(key) ? "🙈" : "👁️" }}
</button>
<button
class="btn btn-xs btn-ghost"
title="Copy value"
@click="
copyToClipboard(
typeof value === 'string'
? value
: JSON.stringify(value),
)
"
>
📋
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-else
class="flex items-center justify-center h-full text-base-content/60"
>
<p>No secret data available</p>
</div>
</div>
</div>
<!-- JSON Data Tab -->
<div v-else-if="activeTab === 'json'" class="h-full flex flex-col">
<div class="flex justify-between items-center mb-3 flex-shrink-0">
<h3 class="font-semibold">JSON Data</h3>
<button
class="btn btn-sm btn-outline"
@click="copyToClipboard(JSON.stringify(secretData, null, 2))"
>
📋 Copy JSON
</button>
</div>
<div class="flex-1 overflow-auto">
<pre
class="bg-base-300 p-4 rounded-lg text-sm h-full overflow-auto"
>{{ JSON.stringify(secretData, null, 2) }}</pre
>
</div>
</div>
<!-- Metadata Tab -->
<div
v-else-if="activeTab === 'metadata' && secretMetadata"
class="h-full flex flex-col"
>
<h3 class="font-semibold mb-3 flex-shrink-0">Secret Metadata</h3>
<div class="flex-1 overflow-auto">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="card bg-base-200">
<div class="card-body p-4">
<h4 class="font-semibold text-sm">General Info</h4>
<div class="space-y-2 text-sm">
<div>
<strong>Current Version:</strong>
{{ secretMetadata.current_version || "N/A" }}
</div>
<div>
<strong>Max Versions:</strong>
{{ secretMetadata.max_versions || "N/A" }}
</div>
<div>
<strong>Oldest Version:</strong>
{{ secretMetadata.oldest_version || "N/A" }}
</div>
<div>
<strong>Created:</strong>
{{
secretMetadata.created_time
? new Date(
secretMetadata.created_time,
).toLocaleString()
: "N/A"
}}
</div>
<div>
<strong>Updated:</strong>
{{
secretMetadata.updated_time
? new Date(
secretMetadata.updated_time,
).toLocaleString()
: "N/A"
}}
</div>
</div>
</div>
</div>
<div class="card bg-base-200">
<div class="card-body p-4">
<h4 class="font-semibold text-sm">Status</h4>
<div class="space-y-2 text-sm">
<div>
<strong>Destroyed:</strong>
{{ secretMetadata.destroyed ? "Yes" : "No" }}
</div>
<div>
<strong>Delete Version After:</strong>
{{ secretMetadata.delete_version_after || "Never" }}
</div>
<div v-if="secretMetadata.custom_metadata">
<strong>Custom Metadata:</strong>
<pre class="text-xs mt-1 bg-base-300 p-2 rounded">{{
JSON.stringify(
secretMetadata.custom_metadata,
null,
2,
)
}}</pre>
</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-200">
<div class="card-body p-4">
<h4 class="font-semibold text-sm mb-2">Raw Metadata</h4>
<pre class="bg-base-300 p-4 rounded text-xs overflow-auto">{{
JSON.stringify(secretMetadata, null, 2)
}}</pre>
</div>
</div>
</div>
</div>
<!-- Versions Tab -->
<div
v-else-if="activeTab === 'versions'"
class="h-full flex flex-col"
>
<h3 class="font-semibold mb-3 flex-shrink-0">Version History</h3>
<div class="flex-1 overflow-auto">
<div class="space-y-2">
<div
v-for="version in secretVersions"
:key="version.version"
class="card bg-base-200 hover:bg-base-300 transition-colors"
>
<div
class="card-body p-4 flex flex-row items-center justify-between"
>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="badge badge-primary"
>v{{ version.version }}</span
>
<span
v-if="
version.version === secretMetadata?.current_version
"
class="badge badge-success"
>Current</span
>
<span v-if="version.destroyed" class="badge badge-error"
>Destroyed</span
>
</div>
<p class="text-sm opacity-70">
Created: {{ version.created_time }}
</p>
<p
v-if="version.deletion_time"
class="text-sm opacity-70"
>
Deleted:
{{ new Date(version.deletion_time).toLocaleString() }}
</p>
</div>
<button
v-if="!version.destroyed"
class="btn btn-sm btn-primary"
@click="loadVersion(version.version)"
>
View Version
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="modal-action flex-shrink-0">
<div class="flex-1 text-xs opacity-70">
<p>🔒 Secret data is never cached - always fetched fresh</p>
<p>📊 KV v2: Metadata and version history available</p>
</div>
<button class="btn" @click="emit('close')">Close</button>
</div>
</div>
</div>
</template>
+7 -20
View File
@@ -19,7 +19,6 @@ const newServer = ref({
name: '',
url: '',
description: '',
kvVersion: 2 as 1 | 2,
})
const handleSubmit = () => {
@@ -30,11 +29,10 @@ const handleSubmit = () => {
name: newServer.value.name,
url: newServer.value.url,
description: newServer.value.description || undefined,
kvVersion: newServer.value.kvVersion,
}
emit('addServer', server)
newServer.value = { name: '', url: '', description: '', kvVersion: 2 }
newServer.value = { name: '', url: '', description: '' }
showAddForm.value = false
}
@@ -100,21 +98,7 @@ const handleRemove = (serverId: string, serverName: string) => {
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">KV Secret Engine Version</span>
</label>
<select
v-model="newServer.kvVersion"
class="select select-bordered w-full"
>
<option :value="2">KV v2 (recommended)</option>
<option :value="1">KV v1 (legacy)</option>
</select>
<label class="label">
<span class="label-text-alt">Most Vault servers use KV v2. Choose v1 only for legacy installations.</span>
</label>
</div>
<!-- KV v2 is enforced - no version selection needed -->
<button type="submit" class="btn btn-success w-full">
Add Server
@@ -143,8 +127,11 @@ const handleRemove = (serverId: string, serverName: string) => {
<p v-if="server.description" class="text-sm italic opacity-60 mt-1">
{{ server.description }}
</p>
<div class="mt-2">
<span class="badge badge-sm badge-outline">KV v{{ server.kvVersion || 2 }}</span>
<div class="mt-2 flex gap-2 flex-wrap">
<span class="badge badge-sm badge-outline">KV v2</span>
<span v-if="server.savedCredentials" class="badge badge-sm badge-warning">
🔓 Saved Credentials
</span>
</div>
</div>
<button
+42 -29
View File
@@ -22,15 +22,14 @@ class VaultApiService {
*/
private createClient(
server: VaultServer,
credentials: VaultCredentials,
kvVersion: 1 | 2 = 2
credentials: VaultCredentials
): VaultClient {
return new VaultClient({
server,
credentials,
timeout: 30000,
retries: 2,
kvVersion, // KV v2 by default (most common)
kvVersion: 2, // KV v2 is enforced
});
}
@@ -65,7 +64,7 @@ class VaultApiService {
console.log(`⚡ API call for list: ${path}`);
try {
const client = this.createClient(server, credentials, server.kvVersion);
const client = this.createClient(server, credentials);
const keys = await client.list(path);
// Cache the result
@@ -86,33 +85,20 @@ class VaultApiService {
}
/**
* Read a secret from Vault with caching
* Read a secret from Vault (NO CACHING - secrets are never cached for security)
*/
async readSecret(
server: VaultServer,
credentials: VaultCredentials,
path: string
): Promise<Record<string, unknown> | null> {
const cacheKey = this.getCacheKey(server, path, 'read');
// Check cache first
const cached = vaultCache.get<Record<string, unknown>>(cacheKey);
if (cached) {
console.log(`✓ Cache hit for read: ${path}`);
return cached;
}
console.log(`⚡ API call for read: ${path}`);
console.log(`⚡ API call for read (no cache): ${path}`);
try {
const client = this.createClient(server, credentials, server.kvVersion);
const client = this.createClient(server, credentials);
const secretData = await client.read<Record<string, unknown>>(path);
if (secretData) {
// Cache the result
vaultCache.set(cacheKey, secretData);
}
// SECURITY: Never cache secret data - always fetch fresh
return secretData;
} catch (error) {
if (error instanceof VaultError) {
@@ -129,6 +115,36 @@ class VaultApiService {
}
}
/**
* Read metadata for a secret (KV v2 only)
*/
async readSecretMetadata(
server: VaultServer,
credentials: VaultCredentials,
path: string
): Promise<any> {
console.log(`⚡ API call for metadata (no cache): ${path}`);
try {
const client = this.createClient(server, credentials);
const metadata = await client.readMetadata(path);
return metadata;
} catch (error) {
if (error instanceof VaultError) {
console.error(`Vault error reading metadata ${path}:`, error.message);
if (error.errors) {
console.error('Details:', error.errors);
}
// Re-throw to let the caller handle it
throw error;
} else {
console.error(`Error reading metadata at ${path}:`, error);
throw new VaultError('Failed to read metadata');
}
}
}
/**
* Write a secret to Vault (no caching)
*/
@@ -141,7 +157,7 @@ class VaultApiService {
console.log(`⚡ API call for write: ${path}`);
try {
const client = this.createClient(server, credentials, server.kvVersion);
const client = this.createClient(server, credentials);
await client.write(path, data);
// Invalidate cache for this path
@@ -171,7 +187,7 @@ class VaultApiService {
console.log(`⚡ API call for delete: ${path}`);
try {
const client = this.createClient(server, credentials, server.kvVersion);
const client = this.createClient(server, credentials);
await client.delete(path);
// Invalidate cache for this path
@@ -200,7 +216,7 @@ class VaultApiService {
console.log('⚡ Verifying login and fetching mount points...');
try {
const client = this.createClient(server, credentials, server.kvVersion);
const client = this.createClient(server, credentials);
const mounts = await client.listMounts();
console.log('📋 Raw mount points from API:', mounts);
@@ -326,12 +342,9 @@ class VaultApiService {
console.log(` → Searching in ${mount.path}/`);
try {
// Determine KV version from mount options
const kvVersion = mount.options?.version === '2' ? 2 : 1;
// Search this mount point
// Search this mount point (KV v2 enforced)
const results = await this.searchPaths(
{ ...server, kvVersion },
server,
credentials,
`${mount.path}/`,
searchTerm,
+2 -1
View File
@@ -3,7 +3,8 @@ export interface VaultServer {
name: string;
url: string;
description?: string;
kvVersion?: 1 | 2; // KV secret engine version (default: 2)
// KV v2 is enforced - no version selection needed
savedCredentials?: VaultCredentials; // Optional saved credentials (WARNING: stored in localStorage)
}
export interface VaultCredentials {