555 lines
19 KiB
Vue
555 lines
19 KiB
Vue
<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>
|