diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 765b225..5dbec82 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -12,6 +12,7 @@ module.exports = { 'plugin:@typescript-eslint/recommended', 'plugin:vue/vue3-recommended', '@vue/eslint-config-typescript', + 'prettier', ], ignorePatterns: ['dist', '.eslintrc.cjs', '*.config.js'], parser: 'vue-eslint-parser', diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c947ee2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,32 @@ +# Dependencies +node_modules/ +pnpm-lock.yaml + +# Build outputs +dist/ +build/ + +# Generated files +*.min.js +*.min.css + +# Documentation +*.md +CHANGELOG.md +README.md + +# Config files that should maintain their format +.prettierrc +.eslintrc* +tsconfig*.json +vite.config.ts +tailwind.config.js +postcss.config.js + +# Other +.git/ +.vscode/ +.idea/ +*.log + +tags diff --git a/index.html b/index.html index 13661a0..8ff9495 100644 --- a/index.html +++ b/index.html @@ -1,14 +1,15 @@ - + Browser Vault GUI + +
- diff --git a/package.json b/package.json index 3c8fce3..fcb0cbb 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vue-tsc && vite build", "preview": "vite preview", - "lint": "eslint . --ext .vue,.ts --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --ext .vue,.ts --report-unused-disable-directives --max-warnings 0", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "dependencies": { "vue": "^3.4.15" @@ -20,11 +22,13 @@ "autoprefixer": "^10.4.16", "daisyui": "^4.4.24", "eslint": "^8.55.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-vue": "^9.19.2", "postcss": "^8.4.33", + "prettier": "^3.6.2", "tailwindcss": "^3.4.1", "typescript": "^5.3.3", "vite": "^5.0.8", "vue-tsc": "^1.8.27" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cc13ce..67a30b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,12 +33,18 @@ importers: eslint: specifier: ^8.55.0 version: 8.57.1 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@8.57.1) eslint-plugin-vue: specifier: ^9.19.2 version: 9.33.0(eslint@8.57.1) postcss: specifier: ^8.4.33 version: 8.5.6 + prettier: + specifier: ^3.6.2 + version: 3.6.2 tailwindcss: specifier: ^3.4.1 version: 3.4.18 @@ -720,6 +726,12 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + eslint-plugin-vue@9.33.0: resolution: {integrity: sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==} engines: {node: ^14.17.0 || >=16.0.0} @@ -1134,6 +1146,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1980,6 +1997,10 @@ snapshots: escape-string-regexp@4.0.0: {} + eslint-config-prettier@10.1.8(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-plugin-vue@9.33.0(eslint@8.57.1): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) @@ -2391,6 +2412,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.6.2: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} diff --git a/src/App.vue b/src/App.vue index 49c755c..2f1322c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -18,11 +18,15 @@ onMounted(() => { }) // Save servers to localStorage whenever they change -watch(servers, (newServers) => { - if (newServers.length > 0) { - localStorage.setItem('vaultServers', JSON.stringify(newServers)) - } -}, { deep: true }) +watch( + servers, + newServers => { + if (newServers.length > 0) { + localStorage.setItem('vaultServers', JSON.stringify(newServers)) + } + }, + { deep: true } +) const handleAddServer = (server: VaultServer) => { servers.value = [...servers.value, server] @@ -47,10 +51,7 @@ const handleLogin = async (credentials: VaultCredentials, shouldSaveCredentials: try { // Verify login and get mount points const { vaultApi } = await import('./services/vaultApi') - const mountPoints = await vaultApi.verifyLoginAndGetMounts( - selectedServer.value, - credentials - ) + const mountPoints = await vaultApi.verifyLoginAndGetMounts(selectedServer.value, credentials) activeConnection.value = { server: selectedServer.value, @@ -79,10 +80,7 @@ const handleLogin = async (credentials: VaultCredentials, shouldSaveCredentials: console.log(`✓ Logged in successfully. Found ${mountPoints.length} KV mount point(s).`) } catch (error) { console.error('Login failed:', error) - alert( - `Login failed: ${error instanceof Error ? error.message : 'Unknown error'}\n\n` + - 'Please check your credentials and server configuration.' - ) + alert(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}\n\n` + 'Please check your credentials and server configuration.') } } @@ -96,7 +94,10 @@ const handleLogout = () => {
-

🔐 Browser Vault GUI

+

+ + Browser Vault GUI +

Alternative frontend for HashiCorp Vault

@@ -117,27 +118,17 @@ const handleLogout = () => {
- +
- +
-
- Browser Vault GUI - An alternative frontend for HashiCorp Vault -
+
Browser Vault GUI - An alternative frontend for HashiCorp Vault
- diff --git a/src/components/Dashboard.vue b/src/components/Dashboard.vue index 313e3fc..b3ced3e 100644 --- a/src/components/Dashboard.vue +++ b/src/components/Dashboard.vue @@ -33,7 +33,7 @@ onMounted(() => { const handleReadSecret = async (path?: string) => { let pathToRead = path - + if (!pathToRead) { // Build path from mount point + secret path if (!selectedMountPoint.value || !secretPath.value) { @@ -47,11 +47,7 @@ const handleReadSecret = async (path?: string) => { secretData.value = null try { - const data = await vaultApi.readSecret( - props.connection.server, - props.connection.credentials, - pathToRead - ) + const data = await vaultApi.readSecret(props.connection.server, props.connection.credentials, pathToRead) if (data) { secretData.value = data @@ -64,7 +60,7 @@ const handleReadSecret = async (path?: string) => { } } catch (error) { console.error('Error reading secret:', error) - + if (error instanceof VaultError) { let message = `Failed to read secret: ${error.message}` if (error.statusCode) { @@ -73,7 +69,7 @@ const handleReadSecret = async (path?: string) => { if (error.errors && error.errors.length > 0) { message += `\n\nDetails:\n${error.errors.join('\n')}` } - + if (error.statusCode === 403) { message += '\n\nYou may not have permission to read this secret.' } else if (error.statusCode === 404) { @@ -81,7 +77,7 @@ const handleReadSecret = async (path?: string) => { } else if (error.message.includes('CORS')) { message += '\n\nCORS error: Make sure your Vault server is configured to allow requests from this origin.' } - + alert(message) } else { alert('Failed to read secret. Check console for details.') @@ -96,7 +92,7 @@ const handleSelectPath = (path: string) => { 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 + '/' @@ -107,12 +103,12 @@ const handleSelectPath = (path: string) => { } } } - + if (foundMount) { selectedMountPoint.value = foundMount secretPath.value = remainingPath } - + // Open secret in modal instead of inline selectedSecretPath.value = path showSecretModal.value = true @@ -129,7 +125,7 @@ const handleViewSecret = () => { alert('Please select a mount point and enter a secret path') return } - + const fullPath = `${selectedMountPoint.value}/${secretPath.value}` selectedSecretPath.value = fullPath showSecretModal.value = true @@ -144,31 +140,28 @@ const handleViewSecret = () => {

Connected to {{ connection.server.name }}

-

{{ connection.server.url }}

+

+ {{ connection.server.url }} +

Authenticated via {{ connection.credentials.authMethod }} - â€ĸ Connected at {{ connection.lastConnected.toLocaleTimeString() }} + â€ĸ Connected at + {{ connection.lastConnected.toLocaleTimeString() }}

- - -
@@ -189,25 +182,15 @@ const handleViewSecret = () => {

Browse Secrets

- +
- - +
@@ -217,10 +200,8 @@ const handleViewSecret = () => { Secret Path
- - {{ selectedMountPoint || 'mount' }}/ - - {{ selectedMountPoint || 'mount' }}/ + { :disabled="isLoading || !selectedMountPoint" @keypress="handleKeyPress" /> -
- -
- +

Browse Secrets

  • Select a mount point from the detected KV secret engines
  • Enter the secret path (without the mount point prefix)
  • -
  • Example: Mount secret + Path data/myapp/config
  • +
  • + Example: Mount + secret + Path + data/myapp/config +
  • Use Search (shown above) to find secrets across all mount points
  • Security: Secret data is never cached - always fetched fresh
@@ -265,23 +246,35 @@ const handleViewSecret = () => {
- +

Security & Caching

-

🔒 Secret data is NEVER cached - always fetched fresh for security.

-

📂 Directory listings are cached to improve search performance.

-

🔑 All requests include the X-Vault-Token header for authentication.

+

+ + Secret data is NEVER cached - always fetched fresh for security. +

+

+ + Directory listings are cached to improve search performance. +

+

+ + All requests include the + X-Vault-Token header for authentication. +

- + { />
- diff --git a/src/components/LoginForm.vue b/src/components/LoginForm.vue index 6fa5004..5b0e771 100644 --- a/src/components/LoginForm.vue +++ b/src/components/LoginForm.vue @@ -42,10 +42,14 @@ const loadCredentialsFromServer = (server: VaultServer) => { 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 }) +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 @@ -95,20 +99,19 @@ const cancelSaveCredentials = () => {

Connect to {{ server.name }}

-

{{ server.url }}

+

+ {{ server.url }} +

-
+
- @@ -120,7 +123,7 @@ const cancelSaveCredentials = () => { - { - { - {
- @@ -197,11 +192,19 @@ const cancelSaveCredentials = () => {
- diff --git a/src/components/PathSearch.vue b/src/components/PathSearch.vue index a90d9ea..52504ac 100644 --- a/src/components/PathSearch.vue +++ b/src/components/PathSearch.vue @@ -45,16 +45,17 @@ const handleSearch = async () => { try { // Always search across all mount points - const searchResults = await vaultApi.searchAllMounts( - props.server, - props.credentials, - props.mountPoints!, - searchTerm.value - ) + const searchResults = await vaultApi.searchAllMounts(props.server, props.credentials, props.mountPoints!, searchTerm.value) const endTime = performance.now() searchTime.value = endTime - startTime results.value = searchResults + + if (results.value.filter(r => !r.isDirectory).length == 1) { + emit('selectPath', results.value.filter(r => !r.isDirectory)[0].path) + } + + console.log(results.value.length) } catch (error) { console.error('Search error:', error) alert('Search failed. Check console for details.') @@ -73,24 +74,19 @@ const handleKeyPress = (event: KeyboardEvent) => { - diff --git a/src/components/SecretModal.vue b/src/components/SecretModal.vue index 4828d2f..60ce040 100644 --- a/src/components/SecretModal.vue +++ b/src/components/SecretModal.vue @@ -1,49 +1,54 @@ - diff --git a/src/components/Settings.vue b/src/components/Settings.vue index 6de2159..4f87758 100644 --- a/src/components/Settings.vue +++ b/src/components/Settings.vue @@ -56,34 +56,27 @@ const formatDate = (timestamp: number | null): string => { - diff --git a/src/config.ts b/src/config.ts index 63dd6a5..ae68439 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,14 +1,14 @@ // Application configuration export interface AppConfig { cache: { - maxSizeMB: number; // Maximum cache size in megabytes - maxAge: number; // Maximum age of cache entries in milliseconds - enabled: boolean; - }; + maxSizeMB: number // Maximum cache size in megabytes + maxAge: number // Maximum age of cache entries in milliseconds + enabled: boolean + } search: { - maxDepth: number; // Maximum recursion depth for path search - maxResults: number; // Maximum number of results to return - }; + maxDepth: number // Maximum recursion depth for path search + maxResults: number // Maximum number of results to return + } } // Default configuration @@ -22,27 +22,26 @@ export const defaultConfig: AppConfig = { maxDepth: 10, maxResults: 1000, }, -}; +} // Load configuration from localStorage export function loadConfig(): AppConfig { try { - const saved = localStorage.getItem('vaultGuiConfig'); + const saved = localStorage.getItem('vaultGuiConfig') if (saved) { - return { ...defaultConfig, ...JSON.parse(saved) }; + return { ...defaultConfig, ...JSON.parse(saved) } } } catch (error) { - console.error('Failed to load config:', error); + console.error('Failed to load config:', error) } - return defaultConfig; + return defaultConfig } // Save configuration to localStorage export function saveConfig(config: AppConfig): void { try { - localStorage.setItem('vaultGuiConfig', JSON.stringify(config)); + localStorage.setItem('vaultGuiConfig', JSON.stringify(config)) } catch (error) { - console.error('Failed to save config:', error); + console.error('Failed to save config:', error) } } - diff --git a/src/env.d.ts b/src/env.d.ts index 230001c..323c78a 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,8 +1,7 @@ /// declare module '*.vue' { - import type { DefineComponent } from 'vue' - const component: DefineComponent<{}, {}, any> - export default component + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component } - diff --git a/src/main.ts b/src/main.ts index 216546d..2425c0f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,4 +3,3 @@ import './style.css' import App from './App.vue' createApp(App).mount('#app') - diff --git a/src/services/vaultApi.ts b/src/services/vaultApi.ts index 85f87f3..7a30145 100644 --- a/src/services/vaultApi.ts +++ b/src/services/vaultApi.ts @@ -1,18 +1,18 @@ -import { VaultServer, VaultCredentials, MountPoint } from '../types'; -import { vaultCache } from '../utils/cache'; -import { loadConfig } from '../config'; -import { VaultClient, VaultError } from './vaultClient'; +import { VaultServer, VaultCredentials, MountPoint } from '../types' +import { vaultCache } from '../utils/cache' +import { loadConfig } from '../config' +import { VaultClient, VaultError } from './vaultClient' export interface SearchResult { - path: string; - isDirectory: boolean; - depth: number; - mountPoint?: string; + path: string + isDirectory: boolean + depth: number + mountPoint?: string } /** * High-level Vault API service with caching - * + * * This service wraps the VaultClient and adds caching functionality * to prevent excessive API calls and improve performance. */ @@ -20,97 +20,82 @@ class VaultApiService { /** * Create a VaultClient instance for the given server and credentials */ - private createClient( - server: VaultServer, - credentials: VaultCredentials - ): VaultClient { + private createClient(server: VaultServer, credentials: VaultCredentials): VaultClient { return new VaultClient({ server, credentials, timeout: 30000, retries: 2, kvVersion: 2, // KV v2 is enforced - }); + }) } /** * Generate a cache key for a given operation */ - private getCacheKey( - server: VaultServer, - path: string, - operation: string - ): string { - return `${server.id}:${operation}:${path}`; + private getCacheKey(server: VaultServer, path: string, operation: string): string { + return `${server.id}:${operation}:${path}` } /** * List secrets at a given path with caching */ - async listSecrets( - server: VaultServer, - credentials: VaultCredentials, - path: string - ): Promise { - const cacheKey = this.getCacheKey(server, path, 'list'); + async listSecrets(server: VaultServer, credentials: VaultCredentials, path: string): Promise { + const cacheKey = this.getCacheKey(server, path, 'list') // Check cache first - const cached = vaultCache.get(cacheKey); + const cached = vaultCache.get(cacheKey) if (cached) { - console.log(`✓ Cache hit for list: ${path}`); - return cached; + console.log(`✓ Cache hit for list: ${path}`) + return cached } - console.log(`⚡ API call for list: ${path}`); + console.log(`⚡ API call for list: ${path}`) try { - const client = this.createClient(server, credentials); - const keys = await client.list(path); + const client = this.createClient(server, credentials) + const keys = await client.list(path) // Cache the result - vaultCache.set(cacheKey, keys); + vaultCache.set(cacheKey, keys) - return keys; + return keys } catch (error) { if (error instanceof VaultError) { - console.error(`Vault error listing ${path}:`, error.message); + console.error(`Vault error listing ${path}:`, error.message) if (error.errors) { - console.error('Details:', error.errors); + console.error('Details:', error.errors) } } else { - console.error(`Error listing secrets at ${path}:`, error); + console.error(`Error listing secrets at ${path}:`, error) } - return []; + return [] } } /** * Read a secret from Vault (NO CACHING - secrets are never cached for security) */ - async readSecret( - server: VaultServer, - credentials: VaultCredentials, - path: string - ): Promise | null> { - console.log(`⚡ API call for read (no cache): ${path}`); + async readSecret(server: VaultServer, credentials: VaultCredentials, path: string): Promise | null> { + console.log(`⚡ API call for read (no cache): ${path}`) try { - const client = this.createClient(server, credentials); - const secretData = await client.read>(path); + const client = this.createClient(server, credentials) + const secretData = await client.read>(path) // SECURITY: Never cache secret data - always fetch fresh - return secretData; + return secretData } catch (error) { if (error instanceof VaultError) { - console.error(`Vault error reading ${path}:`, error.message); + console.error(`Vault error reading ${path}:`, error.message) if (error.errors) { - console.error('Details:', error.errors); + console.error('Details:', error.errors) } // Re-throw to let the caller handle it - throw error; + throw error } else { - console.error(`Error reading secret at ${path}:`, error); - throw new VaultError('Failed to read secret'); + console.error(`Error reading secret at ${path}:`, error) + throw new VaultError('Failed to read secret') } } } @@ -118,29 +103,25 @@ class VaultApiService { /** * Read metadata for a secret (KV v2 only) */ - async readSecretMetadata( - server: VaultServer, - credentials: VaultCredentials, - path: string - ): Promise { - console.log(`⚡ API call for metadata (no cache): ${path}`); + async readSecretMetadata(server: VaultServer, credentials: VaultCredentials, path: string): Promise { + console.log(`⚡ API call for metadata (no cache): ${path}`) try { - const client = this.createClient(server, credentials); - const metadata = await client.readMetadata(path); + const client = this.createClient(server, credentials) + const metadata = await client.readMetadata(path) - return metadata; + return metadata } catch (error) { if (error instanceof VaultError) { - console.error(`Vault error reading metadata ${path}:`, error.message); + console.error(`Vault error reading metadata ${path}:`, error.message) if (error.errors) { - console.error('Details:', error.errors); + console.error('Details:', error.errors) } // Re-throw to let the caller handle it - throw error; + throw error } else { - console.error(`Error reading metadata at ${path}:`, error); - throw new VaultError('Failed to read metadata'); + console.error(`Error reading metadata at ${path}:`, error) + throw new VaultError('Failed to read metadata') } } } @@ -148,30 +129,25 @@ class VaultApiService { /** * Write a secret to Vault (no caching) */ - async writeSecret( - server: VaultServer, - credentials: VaultCredentials, - path: string, - data: Record - ): Promise { - console.log(`⚡ API call for write: ${path}`); + async writeSecret(server: VaultServer, credentials: VaultCredentials, path: string, data: Record): Promise { + console.log(`⚡ API call for write: ${path}`) try { - const client = this.createClient(server, credentials); - await client.write(path, data); + const client = this.createClient(server, credentials) + await client.write(path, data) // Invalidate cache for this path - const cacheKey = this.getCacheKey(server, path, 'read'); - vaultCache.delete(cacheKey); + const cacheKey = this.getCacheKey(server, path, 'read') + vaultCache.delete(cacheKey) - console.log(`✓ Secret written successfully: ${path}`); + console.log(`✓ Secret written successfully: ${path}`) } catch (error) { if (error instanceof VaultError) { - console.error(`Vault error writing ${path}:`, error.message); - throw error; + console.error(`Vault error writing ${path}:`, error.message) + throw error } else { - console.error(`Error writing secret at ${path}:`, error); - throw new VaultError('Failed to write secret'); + console.error(`Error writing secret at ${path}:`, error) + throw new VaultError('Failed to write secret') } } } @@ -179,29 +155,25 @@ class VaultApiService { /** * Delete a secret from Vault (no caching) */ - async deleteSecret( - server: VaultServer, - credentials: VaultCredentials, - path: string - ): Promise { - console.log(`⚡ API call for delete: ${path}`); + async deleteSecret(server: VaultServer, credentials: VaultCredentials, path: string): Promise { + console.log(`⚡ API call for delete: ${path}`) try { - const client = this.createClient(server, credentials); - await client.delete(path); + const client = this.createClient(server, credentials) + await client.delete(path) // Invalidate cache for this path - const cacheKey = this.getCacheKey(server, path, 'read'); - vaultCache.delete(cacheKey); + const cacheKey = this.getCacheKey(server, path, 'read') + vaultCache.delete(cacheKey) - console.log(`✓ Secret deleted successfully: ${path}`); + console.log(`✓ Secret deleted successfully: ${path}`) } catch (error) { if (error instanceof VaultError) { - console.error(`Vault error deleting ${path}:`, error.message); - throw error; + console.error(`Vault error deleting ${path}:`, error.message) + throw error } else { - console.error(`Error deleting secret at ${path}:`, error); - throw new VaultError('Failed to delete secret'); + console.error(`Error deleting secret at ${path}:`, error) + throw new VaultError('Failed to delete secret') } } } @@ -209,20 +181,17 @@ class VaultApiService { /** * Verify login and get available mount points */ - async verifyLoginAndGetMounts( - server: VaultServer, - credentials: VaultCredentials - ): Promise { - console.log('⚡ Verifying login and fetching mount points...'); + async verifyLoginAndGetMounts(server: VaultServer, credentials: VaultCredentials): Promise { + console.log('⚡ Verifying login and fetching mount points...') try { - const client = this.createClient(server, credentials); - const mounts = await client.listMounts(); + const client = this.createClient(server, credentials) + const mounts = await client.listMounts() - console.log('📋 Raw mount points from API:', mounts); + console.log('📋 Raw mount points from API:', mounts) // Convert to array and filter for KV secret engines - const mountPoints: MountPoint[] = []; + const mountPoints: MountPoint[] = [] for (const [path, mount] of Object.entries(mounts)) { // Only include KV secret engines @@ -234,18 +203,21 @@ class VaultApiService { accessor: mount.accessor, config: mount.config, options: mount.options || {}, - }); + }) } } - console.log(`✓ Found ${mountPoints.length} KV mount point(s):`, mountPoints.map(m => `${m.path} (v${m.options?.version || '1'})`)); - return mountPoints; + console.log( + `✓ Found ${mountPoints.length} KV mount point(s):`, + mountPoints.map(m => `${m.path} (v${m.options?.version || '1'})`) + ) + return mountPoints } catch (error) { if (error instanceof VaultError) { - console.error('✗ Login verification failed:', error.message); - throw error; + console.error('✗ Login verification failed:', error.message) + throw error } - throw new VaultError('Failed to verify login'); + throw new VaultError('Failed to verify login') } } @@ -260,23 +232,23 @@ class VaultApiService { currentDepth: number = 0, mountPoint?: string ): Promise { - const config = loadConfig(); + const config = loadConfig() // Check depth limit if (currentDepth >= config.search.maxDepth) { - console.warn(`⚠ Max depth ${config.search.maxDepth} reached at ${basePath}`); - return []; + console.warn(`⚠ Max depth ${config.search.maxDepth} reached at ${basePath}`) + return [] } - const results: SearchResult[] = []; + const results: SearchResult[] = [] try { // List items at current path - const items = await this.listSecrets(server, credentials, basePath); + const items = await this.listSecrets(server, credentials, basePath) for (const item of items) { - const fullPath = basePath ? `${basePath}${item}` : item; - const isDirectory = item.endsWith('/'); + const fullPath = basePath ? `${basePath}${item}` : item + const isDirectory = item.endsWith('/') // Check if this path matches the search term if (fullPath.toLowerCase().includes(searchTerm.toLowerCase())) { @@ -285,88 +257,65 @@ class VaultApiService { isDirectory, depth: currentDepth, mountPoint, - }); + }) // Stop if we've reached max results if (results.length >= config.search.maxResults) { - console.warn( - `⚠ Max results ${config.search.maxResults} reached` - ); - return results; + console.warn(`⚠ Max results ${config.search.maxResults} reached`) + return results } } // If it's a directory, recursively search it if (isDirectory) { - const subResults = await this.searchPaths( - server, - credentials, - fullPath, - searchTerm, - currentDepth + 1, - mountPoint - ); - results.push(...subResults); + const subResults = await this.searchPaths(server, credentials, fullPath, searchTerm, currentDepth + 1, mountPoint) + results.push(...subResults) // Stop if we've reached max results if (results.length >= config.search.maxResults) { - console.warn( - `⚠ Max results ${config.search.maxResults} reached` - ); - return results.slice(0, config.search.maxResults); + console.warn(`⚠ Max results ${config.search.maxResults} reached`) + return results.slice(0, config.search.maxResults) } } } } catch (error) { - console.error(`Error searching path ${basePath}:`, error); + console.error(`Error searching path ${basePath}:`, error) } - return results; + return results } /** * Search across all mount points */ - async searchAllMounts( - server: VaultServer, - credentials: VaultCredentials, - mountPoints: MountPoint[], - searchTerm: string - ): Promise { - console.log(`🔍 Searching across ${mountPoints.length} mount point(s)...`); + async searchAllMounts(server: VaultServer, credentials: VaultCredentials, mountPoints: MountPoint[], searchTerm: string): Promise { + console.log(`🔍 Searching across ${mountPoints.length} mount point(s)...`) - const allResults: SearchResult[] = []; - const config = loadConfig(); + const allResults: SearchResult[] = [] + const config = loadConfig() for (const mount of mountPoints) { - console.log(` → Searching in ${mount.path}/`); + console.log(` → Searching in ${mount.path}/`) try { // Search this mount point (KV v2 enforced) - const results = await this.searchPaths( - server, - credentials, - `${mount.path}/`, - searchTerm, - 0, - mount.path - ); + const results = await this.searchPaths(server, credentials, `${mount.path}/`, searchTerm, 0, mount.path) - allResults.push(...results); + allResults.push(...results) // Stop if we've hit the global max results if (allResults.length >= config.search.maxResults) { - console.warn(`⚠ Max results ${config.search.maxResults} reached`); - return allResults.slice(0, config.search.maxResults); + console.warn(`⚠ Max results ${config.search.maxResults} reached`) + return allResults.slice(0, config.search.maxResults) } } catch (error) { - console.error(` ✗ Error searching ${mount.path}:`, error); + console.error(` ✗ Error searching ${mount.path}:`, error) // Continue with other mount points even if one fails } } - console.log(`✓ Found ${allResults.length} total result(s) across all mounts`); - return allResults; + console.log(`✓ Found ${allResults.length} total result(s) across all mounts`) + return allResults } /** @@ -376,72 +325,58 @@ class VaultApiService { try { const client = this.createClient(server, { serverId: server.id, - authMethod: 'token' - }); - const health = await client.health(); - console.log('✓ Vault server health:', health); - return health.initialized && !health.sealed; + authMethod: 'token', + }) + const health = await client.health() + console.log('✓ Vault server health:', health) + return health.initialized && !health.sealed } catch (error) { - console.error('✗ Failed to connect to Vault:', error); - return false; + console.error('✗ Failed to connect to Vault:', error) + return false } } /** * Authenticate with username/password */ - async loginUserpass( - server: VaultServer, - username: string, - password: string - ): Promise { + async loginUserpass(server: VaultServer, username: string, password: string): Promise { const client = this.createClient(server, { serverId: server.id, authMethod: 'userpass', - }); - return await client.loginUserpass(username, password); + }) + return await client.loginUserpass(username, password) } /** * Authenticate with LDAP */ - async loginLdap( - server: VaultServer, - username: string, - password: string - ): Promise { + async loginLdap(server: VaultServer, username: string, password: string): Promise { const client = this.createClient(server, { serverId: server.id, authMethod: 'ldap', - }); - return await client.loginLdap(username, password); + }) + return await client.loginLdap(username, password) } /** * Get current token information */ - async getTokenInfo( - server: VaultServer, - credentials: VaultCredentials - ): Promise { - const client = this.createClient(server, credentials); - return await client.tokenLookupSelf(); + async getTokenInfo(server: VaultServer, credentials: VaultCredentials): Promise { + const client = this.createClient(server, credentials) + return await client.tokenLookupSelf() } /** * Revoke current token (logout) */ - async logout( - server: VaultServer, - credentials: VaultCredentials - ): Promise { - const client = this.createClient(server, credentials); - await client.tokenRevokeSelf(); + async logout(server: VaultServer, credentials: VaultCredentials): Promise { + const client = this.createClient(server, credentials) + await client.tokenRevokeSelf() } } // Export singleton instance -export const vaultApi = new VaultApiService(); +export const vaultApi = new VaultApiService() // Export VaultError for error handling -export { VaultError }; +export { VaultError } diff --git a/src/services/vaultClient.ts b/src/services/vaultClient.ts index 5766edc..5995183 100644 --- a/src/services/vaultClient.ts +++ b/src/services/vaultClient.ts @@ -1,445 +1,427 @@ -import { VaultServer, VaultCredentials } from '../types'; +import { VaultServer, VaultCredentials } from '../types' /** * Configuration options for VaultClient */ export interface VaultClientOptions { - server: VaultServer; - credentials: VaultCredentials; - timeout?: number; - retries?: number; - kvVersion?: 1 | 2; // KV secret engine version + server: VaultServer + credentials: VaultCredentials + timeout?: number + retries?: number + kvVersion?: 1 | 2 // KV secret engine version } /** * Vault API error with additional context */ export class VaultError extends Error { - constructor( - message: string, - public statusCode?: number, - public errors?: string[] - ) { - super(message); - this.name = 'VaultError'; - } + constructor( + message: string, + public statusCode?: number, + public errors?: string[] + ) { + super(message) + this.name = 'VaultError' + } } /** * Browser-compatible HashiCorp Vault client - * + * * This client provides a clean interface to the Vault HTTP API * with proper error handling, authentication, and type safety. * Supports both KV v1 and KV v2 secret engines. */ export class VaultClient { - private baseUrl: string; - private token?: string; - private timeout: number; - private retries: number; - private kvVersion: 1 | 2; + private baseUrl: string + private token?: string + private timeout: number + private retries: number + private kvVersion: 1 | 2 - constructor(options: VaultClientOptions) { - this.baseUrl = options.server.url.replace(/\/$/, ''); // Remove trailing slash - this.token = options.credentials.token; - this.timeout = options.timeout || 30000; // 30 seconds default - this.retries = options.retries || 2; - this.kvVersion = options.kvVersion || 2; // Default to KV v2 (most common) + constructor(options: VaultClientOptions) { + this.baseUrl = options.server.url.replace(/\/$/, '') // Remove trailing slash + this.token = options.credentials.token + this.timeout = options.timeout || 30000 // 30 seconds default + this.retries = options.retries || 2 + this.kvVersion = options.kvVersion || 2 // Default to KV v2 (most common) + } + + /** + * Transform a path based on KV version + * KV v2 uses /data/ for reads/writes and /metadata/ for lists + */ + private transformPath(path: string, operation: 'data' | 'metadata' | 'none' = 'none'): string { + const normalized = path.replace(/^\/+/, '').replace(/\/+$/, '') + + if (this.kvVersion === 1) { + return normalized } - /** - * Transform a path based on KV version - * KV v2 uses /data/ for reads/writes and /metadata/ for lists - */ - private transformPath(path: string, operation: 'data' | 'metadata' | 'none' = 'none'): string { - const normalized = path.replace(/^\/+/, '').replace(/\/+$/, ''); - - if (this.kvVersion === 1) { - return normalized; - } - - // KV v2 path transformation - // Check if path already has /data/ or /metadata/ - if (normalized.includes('/data/') || normalized.includes('/metadata/')) { - return normalized; - } - - // For KV v2, transform the path - const parts = normalized.split('/'); - const mount = parts[0]; // e.g., "secret" - const rest = parts.slice(1).join('/'); - - if (operation === 'data') { - return `${mount}/data/${rest}`; - } else if (operation === 'metadata') { - return `${mount}/metadata/${rest}`; - } - - return normalized; + // KV v2 path transformation + // Check if path already has /data/ or /metadata/ + if (normalized.includes('/data/') || normalized.includes('/metadata/')) { + return normalized } - /** - * Make an HTTP request to the Vault API - */ - private async request( - path: string, - options: RequestInit = {} - ): Promise { - const url = `${this.baseUrl}/v1/${path.replace(/^\//, '')}`; + // For KV v2, transform the path + const parts = normalized.split('/') + const mount = parts[0] // e.g., "secret" + const rest = parts.slice(1).join('/') - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...options.headers, - }; + if (operation === 'data') { + return `${mount}/data/${rest}` + } else if (operation === 'metadata') { + return `${mount}/metadata/${rest}` + } - // Add authentication token if available - if (this.token) { - headers['X-Vault-Token'] = this.token; - } + return normalized + } - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.timeout); + /** + * Make an HTTP request to the Vault API + */ + private async request(path: string, options: RequestInit = {}): Promise { + const url = `${this.baseUrl}/v1/${path.replace(/^\//, '')}` + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + } + + // Add authentication token if available + if (this.token) { + headers['X-Vault-Token'] = this.token + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), this.timeout) + + try { + const response = await fetch(url, { + ...options, + headers, + signal: controller.signal, + }) + + clearTimeout(timeoutId) + + // Handle non-OK responses + if (!response.ok) { + let errorData: { errors?: string[] } = {} try { - const response = await fetch(url, { - ...options, - headers, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - // Handle non-OK responses - if (!response.ok) { - let errorData: { errors?: string[] } = {}; - try { - errorData = await response.json(); - } catch { - // Response might not be JSON - } - - throw new VaultError( - `Vault API error: ${response.statusText}`, - response.status, - errorData.errors - ); - } - - // Handle empty responses (e.g., 204 No Content) - if (response.status === 204 || response.headers.get('content-length') === '0') { - return null as T; - } - - return await response.json(); - } catch (error) { - clearTimeout(timeoutId); - - if (error instanceof VaultError) { - throw error; - } - - if (error instanceof Error) { - if (error.name === 'AbortError') { - throw new VaultError('Request timeout'); - } - throw new VaultError(`Network error: ${error.message}`); - } - - throw new VaultError('Unknown error occurred'); - } - } - - /** - * Make a request with automatic retries - */ - private async requestWithRetry( - path: string, - options: RequestInit = {}, - attempt = 0 - ): Promise { - try { - return await this.request(path, options); - } catch (error) { - // Only retry on network errors, not on 4xx client errors - if ( - attempt < this.retries && - error instanceof VaultError && - (!error.statusCode || error.statusCode >= 500) - ) { - // Exponential backoff - const delay = Math.pow(2, attempt) * 1000; - await new Promise(resolve => setTimeout(resolve, delay)); - return this.requestWithRetry(path, options, attempt + 1); - } - throw error; - } - } - - /** - * List secrets at a given path - * - * For KV v2, this uses the /metadata/ endpoint - * For KV v1, this uses the path directly - */ - async list(path: string): Promise { - const normalizedPath = this.transformPath(path, 'metadata'); - - // Ensure path ends with / for LIST operations - const listPath = normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`; - - const response = await this.requestWithRetry<{ data: { keys: string[] } }>( - `${listPath}?list=true`, - { method: 'LIST' } - ); - return response?.data?.keys || []; - } - - /** - * Read a secret from Vault - * - * For KV v2, this uses the /data/ endpoint - * For KV v1, this uses the path directly - */ - async read>(path: string): Promise { - const normalizedPath = this.transformPath(path, 'data'); - - if (this.kvVersion === 2) { - // KV v2 returns { data: { data: {...}, metadata: {...} } } - const response = await this.requestWithRetry<{ - data: { data: T; metadata?: unknown }; - }>(normalizedPath, { method: 'GET' }); - return response?.data?.data || null; - } else { - // KV v1 returns { data: {...} } - const response = await this.requestWithRetry<{ data: T }>( - normalizedPath, - { method: 'GET' } - ); - return response?.data || null; - } - } - - /** - * Write a secret to Vault - * - * For KV v2, this uses the /data/ endpoint - * For KV v1, this uses the path directly - */ - async write>( - path: string, - data: T - ): Promise { - const normalizedPath = this.transformPath(path, 'data'); - - const body = this.kvVersion === 2 ? { data } : data; - - await this.requestWithRetry(normalizedPath, { - method: 'POST', - body: JSON.stringify(body), - }); - } - - /** - * Delete a secret from Vault - * - * For KV v2, this uses the /data/ endpoint (soft delete) - * For KV v1, this uses the path directly (hard delete) - */ - async delete(path: string): Promise { - const normalizedPath = this.transformPath(path, 'data'); - await this.requestWithRetry(normalizedPath, { - method: 'DELETE', - }); - } - - /** - * Read secret metadata (KV v2 only) - * Returns version history, created time, etc. - */ - async readMetadata(path: string): Promise<{ - versions: Record; - current_version: number; - oldest_version: number; - created_time: string; - updated_time: string; - } | null> { - if (this.kvVersion !== 2) { - throw new VaultError('Metadata is only available in KV v2'); - } - - const normalizedPath = this.transformPath(path, 'metadata'); - const response = await this.requestWithRetry<{ - data: { - versions: Record; - current_version: number; - oldest_version: number; - created_time: string; - updated_time: string; - }; - }>(normalizedPath, { method: 'GET' }); - - return response?.data || null; - } - - /** - * Get health status of Vault server - */ - async health(): Promise<{ - initialized: boolean; - sealed: boolean; - standby: boolean; - version: string; - }> { - // Health endpoint doesn't require authentication - const url = `${this.baseUrl}/v1/sys/health`; - const response = await fetch(url); - return response.json(); - } - - /** - * Authenticate with username/password - */ - async loginUserpass(username: string, password: string): Promise { - const response = await this.request<{ - auth: { client_token: string }; - }>('auth/userpass/login/' + username, { - method: 'POST', - body: JSON.stringify({ password }), - }); - - this.token = response.auth.client_token; - return this.token; - } - - /** - * Authenticate with LDAP - */ - async loginLdap(username: string, password: string): Promise { - const response = await this.request<{ - auth: { client_token: string }; - }>('auth/ldap/login/' + username, { - method: 'POST', - body: JSON.stringify({ password }), - }); - - this.token = response.auth.client_token; - return this.token; - } - - /** - * Lookup current token info - */ - async tokenLookupSelf(): Promise<{ - data: { - accessor: string; - creation_time: number; - creation_ttl: number; - display_name: string; - entity_id: string; - expire_time: string | null; - explicit_max_ttl: number; - id: string; - issue_time: string; - meta: Record; - num_uses: number; - orphan: boolean; - path: string; - policies: string[]; - renewable: boolean; - ttl: number; - type: string; - }; - }> { - return this.requestWithRetry('auth/token/lookup-self', { - method: 'GET', - }); - } - - /** - * Revoke current token (logout) - */ - async tokenRevokeSelf(): Promise { - await this.requestWithRetry('auth/token/revoke-self', { - method: 'POST', - }); - this.token = undefined; - } - - /** - * List all secret engine mount points - * This also verifies the token is valid - */ - async listMounts(): Promise<{ - [key: string]: { - type: string; - description: string; - accessor: string; - config: { - default_lease_ttl: number; - max_lease_ttl: number; - }; - options: { - version?: string; - } | null; - }; - }> { - const response = await this.requestWithRetry<{ - data: { - auth?: { - [key: string]: { - type: string; - description: string; - accessor: string; - config: Record; - options: Record | null; - }; - }; - secret?: { - [key: string]: { - type: string; - description: string; - accessor: string; - config: { - default_lease_ttl: number; - max_lease_ttl: number; - }; - options: { - version?: string; - } | null; - }; - }; - }; - }>('sys/internal/ui/mounts', { method: 'GET' }); - - // Return only the secret engines (not auth methods) - return response?.data?.secret || {}; - } - - /** - * Detect KV version for a mount point - */ - async detectKvVersion(mountPath: string): Promise<1 | 2> { - try { - const response = await this.requestWithRetry<{ - data: { - options: { version?: string }; - type: string; - }; - }>(`sys/internal/ui/mounts/${mountPath}`, { method: 'GET' }); - - const version = response?.data?.options?.version; - return version === '2' ? 2 : 1; + errorData = await response.json() } catch { - // If detection fails, assume v2 (most common) - return 2; + // Response might not be JSON } + + throw new VaultError(`Vault API error: ${response.statusText}`, response.status, errorData.errors) + } + + // Handle empty responses (e.g., 204 No Content) + if (response.status === 204 || response.headers.get('content-length') === '0') { + return null as T + } + + return await response.json() + } catch (error) { + clearTimeout(timeoutId) + + if (error instanceof VaultError) { + throw error + } + + if (error instanceof Error) { + if (error.name === 'AbortError') { + throw new VaultError('Request timeout') + } + throw new VaultError(`Network error: ${error.message}`) + } + + throw new VaultError('Unknown error occurred') } + } + + /** + * Make a request with automatic retries + */ + private async requestWithRetry(path: string, options: RequestInit = {}, attempt = 0): Promise { + try { + return await this.request(path, options) + } catch (error) { + // Only retry on network errors, not on 4xx client errors + if (attempt < this.retries && error instanceof VaultError && (!error.statusCode || error.statusCode >= 500)) { + // Exponential backoff + const delay = Math.pow(2, attempt) * 1000 + await new Promise(resolve => setTimeout(resolve, delay)) + return this.requestWithRetry(path, options, attempt + 1) + } + throw error + } + } + + /** + * List secrets at a given path + * + * For KV v2, this uses the /metadata/ endpoint + * For KV v1, this uses the path directly + */ + async list(path: string): Promise { + const normalizedPath = this.transformPath(path, 'metadata') + + // Ensure path ends with / for LIST operations + const listPath = normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/` + + const response = await this.requestWithRetry<{ data: { keys: string[] } }>(`${listPath}?list=true`, { method: 'LIST' }) + return response?.data?.keys || [] + } + + /** + * Read a secret from Vault + * + * For KV v2, this uses the /data/ endpoint + * For KV v1, this uses the path directly + */ + async read>(path: string): Promise { + const normalizedPath = this.transformPath(path, 'data') + + if (this.kvVersion === 2) { + // KV v2 returns { data: { data: {...}, metadata: {...} } } + const response = await this.requestWithRetry<{ + data: { data: T; metadata?: unknown } + }>(normalizedPath, { method: 'GET' }) + return response?.data?.data || null + } else { + // KV v1 returns { data: {...} } + const response = await this.requestWithRetry<{ data: T }>(normalizedPath, { method: 'GET' }) + return response?.data || null + } + } + + /** + * Write a secret to Vault + * + * For KV v2, this uses the /data/ endpoint + * For KV v1, this uses the path directly + */ + async write>(path: string, data: T): Promise { + const normalizedPath = this.transformPath(path, 'data') + + const body = this.kvVersion === 2 ? { data } : data + + await this.requestWithRetry(normalizedPath, { + method: 'POST', + body: JSON.stringify(body), + }) + } + + /** + * Delete a secret from Vault + * + * For KV v2, this uses the /data/ endpoint (soft delete) + * For KV v1, this uses the path directly (hard delete) + */ + async delete(path: string): Promise { + const normalizedPath = this.transformPath(path, 'data') + await this.requestWithRetry(normalizedPath, { + method: 'DELETE', + }) + } + + /** + * Read secret metadata (KV v2 only) + * Returns version history, created time, etc. + */ + async readMetadata(path: string): Promise<{ + versions: Record< + string, + { + created_time: string + deletion_time: string + destroyed: boolean + } + > + current_version: number + oldest_version: number + created_time: string + updated_time: string + } | null> { + if (this.kvVersion !== 2) { + throw new VaultError('Metadata is only available in KV v2') + } + + const normalizedPath = this.transformPath(path, 'metadata') + const response = await this.requestWithRetry<{ + data: { + versions: Record< + string, + { + created_time: string + deletion_time: string + destroyed: boolean + } + > + current_version: number + oldest_version: number + created_time: string + updated_time: string + } + }>(normalizedPath, { method: 'GET' }) + + return response?.data || null + } + + /** + * Get health status of Vault server + */ + async health(): Promise<{ + initialized: boolean + sealed: boolean + standby: boolean + version: string + }> { + // Health endpoint doesn't require authentication + const url = `${this.baseUrl}/v1/sys/health` + const response = await fetch(url) + return response.json() + } + + /** + * Authenticate with username/password + */ + async loginUserpass(username: string, password: string): Promise { + const response = await this.request<{ + auth: { client_token: string } + }>('auth/userpass/login/' + username, { + method: 'POST', + body: JSON.stringify({ password }), + }) + + this.token = response.auth.client_token + return this.token + } + + /** + * Authenticate with LDAP + */ + async loginLdap(username: string, password: string): Promise { + const response = await this.request<{ + auth: { client_token: string } + }>('auth/ldap/login/' + username, { + method: 'POST', + body: JSON.stringify({ password }), + }) + + this.token = response.auth.client_token + return this.token + } + + /** + * Lookup current token info + */ + async tokenLookupSelf(): Promise<{ + data: { + accessor: string + creation_time: number + creation_ttl: number + display_name: string + entity_id: string + expire_time: string | null + explicit_max_ttl: number + id: string + issue_time: string + meta: Record + num_uses: number + orphan: boolean + path: string + policies: string[] + renewable: boolean + ttl: number + type: string + } + }> { + return this.requestWithRetry('auth/token/lookup-self', { + method: 'GET', + }) + } + + /** + * Revoke current token (logout) + */ + async tokenRevokeSelf(): Promise { + await this.requestWithRetry('auth/token/revoke-self', { + method: 'POST', + }) + this.token = undefined + } + + /** + * List all secret engine mount points + * This also verifies the token is valid + */ + async listMounts(): Promise<{ + [key: string]: { + type: string + description: string + accessor: string + config: { + default_lease_ttl: number + max_lease_ttl: number + } + options: { + version?: string + } | null + } + }> { + const response = await this.requestWithRetry<{ + data: { + auth?: { + [key: string]: { + type: string + description: string + accessor: string + config: Record + options: Record | null + } + } + secret?: { + [key: string]: { + type: string + description: string + accessor: string + config: { + default_lease_ttl: number + max_lease_ttl: number + } + options: { + version?: string + } | null + } + } + } + }>('sys/internal/ui/mounts', { method: 'GET' }) + + // Return only the secret engines (not auth methods) + return response?.data?.secret || {} + } + + /** + * Detect KV version for a mount point + */ + async detectKvVersion(mountPath: string): Promise<1 | 2> { + try { + const response = await this.requestWithRetry<{ + data: { + options: { version?: string } + type: string + } + }>(`sys/internal/ui/mounts/${mountPath}`, { method: 'GET' }) + + const version = response?.data?.options?.version + return version === '2' ? 2 : 1 + } catch { + // If detection fails, assume v2 (most common) + return 2 + } + } } diff --git a/src/style.css b/src/style.css index 23b87cc..d4c4d30 100644 --- a/src/style.css +++ b/src/style.css @@ -4,27 +4,27 @@ /* Custom scrollbar styling */ ::-webkit-scrollbar { - width: 8px; - height: 8px; + width: 8px; + height: 8px; } ::-webkit-scrollbar-track { - @apply bg-base-300; + @apply bg-base-300; } ::-webkit-scrollbar-thumb { - @apply bg-base-content/30 rounded; + @apply bg-base-content/30 rounded; } ::-webkit-scrollbar-thumb:hover { - @apply bg-base-content/50; + @apply bg-base-content/50; } /* Code blocks */ pre { - @apply bg-base-300 p-4 rounded-lg overflow-x-auto text-sm; + @apply bg-base-300 p-4 rounded-lg overflow-x-auto text-sm; } code { - @apply bg-base-300 px-2 py-1 rounded text-sm font-mono; -} \ No newline at end of file + @apply bg-base-300 px-2 py-1 rounded text-sm font-mono; +} diff --git a/src/types.ts b/src/types.ts index 8a48e9d..74338c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,50 +1,51 @@ export interface VaultServer { - id: string; - name: string; - url: string; - description?: string; + id: string + name: string + url: string + description?: string // KV v2 is enforced - no version selection needed - savedCredentials?: VaultCredentials; // Optional saved credentials (WARNING: stored in localStorage) + savedCredentials?: VaultCredentials // Optional saved credentials (WARNING: stored in localStorage) } export interface VaultCredentials { - serverId: string; - token?: string; - username?: string; - password?: string; - authMethod: 'token' | 'userpass' | 'ldap'; + serverId: string + token?: string + username?: string + password?: string + authMethod: 'token' | 'userpass' | 'ldap' } export interface MountPoint { - path: string; - type: string; - description: string; - accessor: string; + path: string + type: string + description: string + accessor: string config: { - default_lease_ttl: number; - max_lease_ttl: number; - }; - options: { - version?: string; - } | Record; + default_lease_ttl: number + max_lease_ttl: number + } + options: + | { + version?: string + } + | Record } export interface VaultConnection { - server: VaultServer; - credentials: VaultCredentials; - isConnected: boolean; - lastConnected?: Date; - mountPoints?: MountPoint[]; + server: VaultServer + credentials: VaultCredentials + isConnected: boolean + lastConnected?: Date + mountPoints?: MountPoint[] } export interface VaultSecret { - path: string; - data: Record; + path: string + data: Record metadata?: { - created_time: string; - deletion_time: string; - destroyed: boolean; - version: number; - }; + created_time: string + deletion_time: string + destroyed: boolean + version: number + } } - diff --git a/src/utils/cache.ts b/src/utils/cache.ts index 05711b7..2f0a598 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -1,166 +1,166 @@ -import { loadConfig } from '../config'; +import { loadConfig } from '../config' export interface CacheEntry { - data: T; - timestamp: number; - size: number; // Size in bytes + data: T + timestamp: number + size: number // Size in bytes } export interface CacheStats { - totalSize: number; // Total size in bytes - entryCount: number; - oldestEntry: number | null; - newestEntry: number | null; + totalSize: number // Total size in bytes + entryCount: number + oldestEntry: number | null + newestEntry: number | null } class VaultCache { - private readonly CACHE_KEY = 'vaultApiCache'; - private cache: Map>; + private readonly CACHE_KEY = 'vaultApiCache' + private cache: Map> constructor() { - this.cache = this.loadFromStorage(); + this.cache = this.loadFromStorage() } private loadFromStorage(): Map> { try { - const stored = localStorage.getItem(this.CACHE_KEY); + const stored = localStorage.getItem(this.CACHE_KEY) if (stored) { - const parsed = JSON.parse(stored); - return new Map(Object.entries(parsed)); + const parsed = JSON.parse(stored) + return new Map(Object.entries(parsed)) } } catch (error) { - console.error('Failed to load cache from storage:', error); + console.error('Failed to load cache from storage:', error) } - return new Map(); + return new Map() } private saveToStorage(): void { try { - const obj = Object.fromEntries(this.cache); - localStorage.setItem(this.CACHE_KEY, JSON.stringify(obj)); + const obj = Object.fromEntries(this.cache) + localStorage.setItem(this.CACHE_KEY, JSON.stringify(obj)) } catch (error) { - console.error('Failed to save cache to storage:', error); + console.error('Failed to save cache to storage:', error) // If quota exceeded, clear old entries and retry - this.evictOldEntries(0.5); // Remove 50% of entries + this.evictOldEntries(0.5) // Remove 50% of entries try { - const obj = Object.fromEntries(this.cache); - localStorage.setItem(this.CACHE_KEY, JSON.stringify(obj)); + const obj = Object.fromEntries(this.cache) + localStorage.setItem(this.CACHE_KEY, JSON.stringify(obj)) } catch (retryError) { - console.error('Failed to save cache after cleanup:', retryError); + console.error('Failed to save cache after cleanup:', retryError) } } } private calculateSize(data: unknown): number { // Rough estimation of size in bytes - return new Blob([JSON.stringify(data)]).size; + return new Blob([JSON.stringify(data)]).size } private evictOldEntries(fraction: number): void { - const entries = Array.from(this.cache.entries()); - entries.sort((a, b) => a[1].timestamp - b[1].timestamp); - const toRemove = Math.floor(entries.length * fraction); + const entries = Array.from(this.cache.entries()) + entries.sort((a, b) => a[1].timestamp - b[1].timestamp) + const toRemove = Math.floor(entries.length * fraction) for (let i = 0; i < toRemove; i++) { - this.cache.delete(entries[i][0]); + this.cache.delete(entries[i][0]) } } private enforceSizeLimit(): void { - const config = loadConfig(); - if (!config.cache.enabled) return; + const config = loadConfig() + if (!config.cache.enabled) return - const maxBytes = config.cache.maxSizeMB * 1024 * 1024; - let totalSize = 0; + const maxBytes = config.cache.maxSizeMB * 1024 * 1024 + let totalSize = 0 // Calculate total size for (const entry of this.cache.values()) { - totalSize += entry.size; + totalSize += entry.size } // If over limit, remove oldest entries if (totalSize > maxBytes) { - const entries = Array.from(this.cache.entries()); - entries.sort((a, b) => a[1].timestamp - b[1].timestamp); + const entries = Array.from(this.cache.entries()) + entries.sort((a, b) => a[1].timestamp - b[1].timestamp) for (const [key, entry] of entries) { - if (totalSize <= maxBytes * 0.8) break; // Remove until 80% of limit - totalSize -= entry.size; - this.cache.delete(key); + if (totalSize <= maxBytes * 0.8) break // Remove until 80% of limit + totalSize -= entry.size + this.cache.delete(key) } } } get(key: string): T | null { - const config = loadConfig(); - if (!config.cache.enabled) return null; + const config = loadConfig() + if (!config.cache.enabled) return null - const entry = this.cache.get(key) as CacheEntry | undefined; - if (!entry) return null; + const entry = this.cache.get(key) as CacheEntry | undefined + if (!entry) return null // Check if entry is expired - const age = Date.now() - entry.timestamp; + const age = Date.now() - entry.timestamp if (age > config.cache.maxAge) { - this.cache.delete(key); - return null; + this.cache.delete(key) + return null } - return entry.data; + return entry.data } set(key: string, data: T): void { - const config = loadConfig(); - if (!config.cache.enabled) return; + const config = loadConfig() + if (!config.cache.enabled) return - const size = this.calculateSize(data); + const size = this.calculateSize(data) const entry: CacheEntry = { data, timestamp: Date.now(), size, - }; + } - this.cache.set(key, entry as CacheEntry); - this.enforceSizeLimit(); - this.saveToStorage(); + this.cache.set(key, entry as CacheEntry) + this.enforceSizeLimit() + this.saveToStorage() } has(key: string): boolean { - const config = loadConfig(); - if (!config.cache.enabled) return false; + const config = loadConfig() + if (!config.cache.enabled) return false - const entry = this.cache.get(key); - if (!entry) return false; + const entry = this.cache.get(key) + if (!entry) return false - const age = Date.now() - entry.timestamp; + const age = Date.now() - entry.timestamp if (age > config.cache.maxAge) { - this.cache.delete(key); - return false; + this.cache.delete(key) + return false } - return true; + return true } delete(key: string): void { - this.cache.delete(key); - this.saveToStorage(); + this.cache.delete(key) + this.saveToStorage() } clear(): void { - this.cache.clear(); - this.saveToStorage(); + this.cache.clear() + this.saveToStorage() } getStats(): CacheStats { - let totalSize = 0; - let oldestEntry: number | null = null; - let newestEntry: number | null = null; + let totalSize = 0 + let oldestEntry: number | null = null + let newestEntry: number | null = null for (const entry of this.cache.values()) { - totalSize += entry.size; + totalSize += entry.size if (oldestEntry === null || entry.timestamp < oldestEntry) { - oldestEntry = entry.timestamp; + oldestEntry = entry.timestamp } if (newestEntry === null || entry.timestamp > newestEntry) { - newestEntry = entry.timestamp; + newestEntry = entry.timestamp } } @@ -169,34 +169,33 @@ class VaultCache { entryCount: this.cache.size, oldestEntry, newestEntry, - }; + } } // Clean up expired entries cleanup(): void { - const config = loadConfig(); - const now = Date.now(); - const keysToDelete: string[] = []; + const config = loadConfig() + const now = Date.now() + const keysToDelete: string[] = [] for (const [key, entry] of this.cache.entries()) { if (now - entry.timestamp > config.cache.maxAge) { - keysToDelete.push(key); + keysToDelete.push(key) } } for (const key of keysToDelete) { - this.cache.delete(key); + this.cache.delete(key) } if (keysToDelete.length > 0) { - this.saveToStorage(); + this.saveToStorage() } } } // Singleton instance -export const vaultCache = new VaultCache(); +export const vaultCache = new VaultCache() // Cleanup expired entries on page load -vaultCache.cleanup(); - +vaultCache.cleanup() diff --git a/tailwind.config.js b/tailwind.config.js index bccfec8..34c2984 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -9,8 +9,8 @@ export default { }, plugins: [require("daisyui")], daisyui: { - themes: ["dark", "light"], - darkTheme: "dark", + themes: ["business"], + darkTheme: "business", }, }