browser-vault-gui/src/components/SecretModal.vue
2025-10-20 19:34:11 +02:00

555 lines
19 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>