first version

This commit is contained in:
2025-10-20 18:45:52 +02:00
commit 19eebd72df
41 changed files with 8080 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
width: 100%;
}
.app-header {
background: linear-gradient(135deg, var(--primary-color) 0%, #4338ca 100%);
color: white;
padding: 2rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
}
.app-header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: white;
}
.subtitle {
font-size: 1.1rem;
opacity: 0.9;
color: rgba(255, 255, 255, 0.9);
}
.app-main {
flex: 1;
padding: 2rem;
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
.login-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-top: 2rem;
}
@media (max-width: 968px) {
.login-container {
grid-template-columns: 1fr;
}
}
.server-section,
.auth-section {
background: var(--surface);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
border: 1px solid var(--border);
}
.app-footer {
background: var(--surface);
border-top: 1px solid var(--border);
padding: 1.5rem;
text-align: center;
color: var(--text-secondary);
font-size: 0.9rem;
}
/* Button Styles */
.btn {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.25s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5em;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-hover);
}
.btn-success {
background-color: var(--success-color);
color: white;
}
.btn-success:hover:not(:disabled) {
background-color: var(--success-hover);
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-danger:hover:not(:disabled) {
background-color: var(--danger-hover);
}
.btn-sm {
padding: 0.4em 0.8em;
font-size: 0.875em;
}
.btn-block {
width: 100%;
margin-top: 1rem;
}
/* Form Styles */
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-primary);
}
.form-group input,
.form-group select {
width: 100%;
}
.form-select {
width: 100%;
}
.form-hint {
display: block;
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.section-header {
margin-bottom: 1.5rem;
}
.section-header h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.section-header p {
color: var(--text-secondary);
}
+127
View File
@@ -0,0 +1,127 @@
import { useState, useEffect } from 'react';
import './App.css';
import { VaultServer, VaultCredentials, VaultConnection } from './types';
import ServerSelector from './components/ServerSelector';
import LoginForm from './components/LoginForm';
import Dashboard from './components/Dashboard';
function App() {
const [servers, setServers] = useState<VaultServer[]>([]);
const [selectedServer, setSelectedServer] = useState<VaultServer | null>(null);
const [activeConnection, setActiveConnection] = useState<VaultConnection | null>(null);
// Load servers from localStorage on mount
useEffect(() => {
const savedServers = localStorage.getItem('vaultServers');
if (savedServers) {
setServers(JSON.parse(savedServers));
}
}, []);
// Save servers to localStorage whenever they change
useEffect(() => {
if (servers.length > 0) {
localStorage.setItem('vaultServers', JSON.stringify(servers));
}
}, [servers]);
const handleAddServer = (server: VaultServer) => {
setServers([...servers, server]);
};
const handleRemoveServer = (serverId: string) => {
setServers(servers.filter(s => s.id !== serverId));
if (selectedServer?.id === serverId) {
setSelectedServer(null);
setActiveConnection(null);
}
};
const handleSelectServer = (server: VaultServer) => {
setSelectedServer(server);
setActiveConnection(null);
};
const handleLogin = async (credentials: VaultCredentials) => {
if (!selectedServer) return;
try {
// Verify login and get mount points
const { vaultApi } = await import('./services/vaultApi');
const mountPoints = await vaultApi.verifyLoginAndGetMounts(
selectedServer,
credentials
);
const connection: VaultConnection = {
server: selectedServer,
credentials,
isConnected: true,
lastConnected: new Date(),
mountPoints,
};
setActiveConnection(connection);
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.'
);
}
};
const handleLogout = () => {
setActiveConnection(null);
};
return (
<div className="app">
<header className="app-header">
<div className="header-content">
<h1>🔐 Browser Vault GUI</h1>
<p className="subtitle">Alternative frontend for HashiCorp Vault</p>
</div>
</header>
<main className="app-main">
{!activeConnection ? (
<div className="login-container">
<div className="server-section">
<ServerSelector
servers={servers}
selectedServer={selectedServer}
onAddServer={handleAddServer}
onRemoveServer={handleRemoveServer}
onSelectServer={handleSelectServer}
/>
</div>
{selectedServer && (
<div className="auth-section">
<LoginForm
server={selectedServer}
onLogin={handleLogin}
/>
</div>
)}
</div>
) : (
<Dashboard
connection={activeConnection}
onLogout={handleLogout}
/>
)}
</main>
<footer className="app-footer">
<p>Browser Vault GUI - An alternative frontend for HashiCorp Vault</p>
</footer>
</div>
);
}
export default App;
+175
View File
@@ -0,0 +1,175 @@
.dashboard {
background: var(--surface);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
border: 1px solid var(--border);
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border);
margin-bottom: 2rem;
}
.dashboard-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.connection-info h2 {
margin: 0 0 0.5rem 0;
font-size: 1.75rem;
}
.connection-info .server-url {
color: var(--text-secondary);
font-size: 0.875rem;
font-family: 'Courier New', monospace;
margin: 0.25rem 0;
}
.connection-info .auth-info {
color: var(--text-secondary);
font-size: 0.875rem;
margin-top: 0.5rem;
}
.connection-time {
font-style: italic;
}
.dashboard-content {
max-width: 900px;
}
.secret-browser h3 {
margin-bottom: 1.5rem;
font-size: 1.5rem;
}
.secret-path-input {
margin-bottom: 2rem;
}
.secret-path-input label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.input-group {
display: flex;
gap: 0.5rem;
}
.input-group input {
flex: 1;
}
.secret-display {
background: var(--surface-light);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
border: 1px solid var(--border);
}
.secret-display h4 {
margin: 0 0 1rem 0;
font-size: 1.25rem;
}
.secret-data {
background: var(--surface);
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
font-size: 0.875rem;
line-height: 1.6;
margin: 0;
}
.info-box {
background: linear-gradient(135deg, rgba(100, 108, 255, 0.1) 0%, rgba(67, 56, 202, 0.1) 100%);
border: 1px solid var(--primary-color);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.info-box h4 {
margin: 0 0 1rem 0;
color: var(--primary-color);
}
.info-box ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.info-box li {
margin: 0.5rem 0;
line-height: 1.6;
}
.api-info {
background: var(--surface-light);
border-radius: 8px;
padding: 1.5rem;
border: 1px solid var(--border);
}
.api-info h4 {
margin: 0 0 1rem 0;
font-size: 1.25rem;
}
.api-info p {
margin: 0.75rem 0;
line-height: 1.6;
color: var(--text-secondary);
}
.api-info ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.api-info li {
margin: 0.5rem 0;
line-height: 1.6;
color: var(--text-secondary);
}
.api-info strong {
color: var(--text-primary);
}
@media (max-width: 768px) {
.dashboard-header {
flex-direction: column;
gap: 1rem;
}
.dashboard-actions {
width: 100%;
}
.dashboard-actions button {
flex: 1;
}
.input-group {
flex-direction: column;
}
.input-group button {
width: 100%;
}
}
+198
View File
@@ -0,0 +1,198 @@
import { useState } from 'react';
import { VaultConnection } from '../types';
import { vaultApi, VaultError } from '../services/vaultApi';
import PathSearch from './PathSearch';
import Settings from './Settings';
import './Dashboard.css';
interface DashboardProps {
connection: VaultConnection;
onLogout: () => void;
}
function Dashboard({ connection, onLogout }: DashboardProps) {
const [currentPath, setCurrentPath] = useState('');
const [secretData, setSecretData] = useState<Record<string, unknown> | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const handleReadSecret = async (path?: string) => {
const pathToRead = path || currentPath;
if (!pathToRead) {
alert('Please enter a secret path');
return;
}
setIsLoading(true);
setSecretData(null);
try {
const data = await vaultApi.readSecret(
connection.server,
connection.credentials,
pathToRead
);
if (data) {
setSecretData(data);
setCurrentPath(pathToRead);
} else {
alert('Secret not found or empty.');
}
} catch (error) {
console.error('Error reading secret:', error);
if (error instanceof VaultError) {
let message = `Failed to read secret: ${error.message}`;
if (error.statusCode) {
message += ` (HTTP ${error.statusCode})`;
}
if (error.errors && error.errors.length > 0) {
message += `\n\nDetails:\n${error.errors.join('\n')}`;
}
// Special handling for common errors
if (error.statusCode === 403) {
message += '\n\nYou may not have permission to read this secret.';
} else if (error.statusCode === 404) {
message = 'Secret not found at this path.';
} 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.');
}
} finally {
setIsLoading(false);
}
};
const handleSelectPath = (path: string) => {
setCurrentPath(path);
handleReadSecret(path);
setShowSearch(false);
};
return (
<div className="dashboard">
<div className="dashboard-header">
<div className="connection-info">
<h2>Connected to {connection.server.name}</h2>
<p className="server-url">{connection.server.url}</p>
<p className="auth-info">
Authenticated via {connection.credentials.authMethod}
{connection.lastConnected && (
<span className="connection-time">
{' '} Connected at {connection.lastConnected.toLocaleTimeString()}
</span>
)}
</p>
</div>
<div className="dashboard-actions">
<button
className="btn btn-primary"
onClick={() => setShowSearch(!showSearch)}
>
{showSearch ? 'Hide Search' : '🔍 Search'}
</button>
<button
className="btn btn-secondary"
onClick={() => setShowSettings(true)}
>
Settings
</button>
<button className="btn btn-danger" onClick={onLogout}>
Logout
</button>
</div>
</div>
<div className="dashboard-content">
{showSearch && (
<PathSearch
server={connection.server}
credentials={connection.credentials}
mountPoints={connection.mountPoints}
onSelectPath={handleSelectPath}
/>
)}
<div className="secret-browser">
<h3>Browse Secrets</h3>
<div className="secret-path-input">
<label htmlFor="secret-path">Secret Path</label>
<div className="input-group">
<input
id="secret-path"
type="text"
value={currentPath}
onChange={(e) => setCurrentPath(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && !isLoading && handleReadSecret()}
placeholder="secret/data/myapp/config"
disabled={isLoading}
/>
<button
className="btn btn-primary"
onClick={() => handleReadSecret()}
disabled={isLoading}
>
{isLoading ? 'Loading...' : 'Read Secret'}
</button>
</div>
</div>
{secretData && (
<div className="secret-display">
<h4>Secret Data</h4>
<pre className="secret-data">
{JSON.stringify(secretData, null, 2)}
</pre>
</div>
)}
{!showSearch && (
<div className="info-box">
<h4>Getting Started</h4>
<ul>
<li>Enter a secret path to read from your Vault server</li>
<li>Example paths: <code>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>
</ul>
</div>
)}
<div className="api-info">
<h4>Implementation Notes</h4>
<p>
This application uses the Vault HTTP API with caching enabled.
The following endpoints are used:
</p>
<ul>
<li><strong>List secrets:</strong> GET /v1/{'<'}path{'>'}?list=true</li>
<li><strong>Read secret:</strong> GET /v1/{'<'}path{'>'}</li>
<li><strong>Write secret:</strong> POST/PUT /v1/{'<'}path{'>'}</li>
<li><strong>Delete secret:</strong> DELETE /v1/{'<'}path{'>'}</li>
</ul>
<p>
All requests include the <code>X-Vault-Token</code> header for authentication.
Configure cache settings and search limits in Settings.
</p>
</div>
</div>
</div>
{showSettings && (
<Settings onClose={() => setShowSettings(false)} />
)}
</div>
);
}
export default Dashboard;
+42
View File
@@ -0,0 +1,42 @@
.login-form {
height: 100%;
}
.login-form .section-header {
display: block;
margin-bottom: 1.5rem;
}
.login-form .section-header h2 {
margin-bottom: 0.5rem;
}
.login-form .server-url {
color: var(--text-secondary);
font-size: 0.875rem;
font-family: 'Courier New', monospace;
}
.login-form form {
margin-bottom: 2rem;
}
.security-notice {
background: var(--surface-light);
border-left: 4px solid var(--primary-color);
padding: 1rem;
border-radius: 4px;
margin-top: 2rem;
}
.security-notice p {
margin: 0;
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.6;
}
.security-notice strong {
color: var(--text-primary);
}
+128
View File
@@ -0,0 +1,128 @@
import { useState } from 'react';
import { VaultServer, VaultCredentials } from '../types';
import './LoginForm.css';
interface LoginFormProps {
server: VaultServer;
onLogin: (credentials: VaultCredentials) => void;
}
function LoginForm({ server, onLogin }: LoginFormProps) {
const [authMethod, setAuthMethod] = useState<'token' | 'userpass' | 'ldap'>('token');
const [token, setToken] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
const credentials: VaultCredentials = {
serverId: server.id,
authMethod,
token: authMethod === 'token' ? token : undefined,
username: authMethod !== 'token' ? username : undefined,
password: authMethod !== 'token' ? password : undefined,
};
try {
await onLogin(credentials);
} catch (error) {
console.error('Login error:', error);
alert('Login failed. Please check your credentials.');
} finally {
setIsLoading(false);
}
};
return (
<div className="login-form">
<div className="section-header">
<h2>Connect to {server.name}</h2>
<p className="server-url">{server.url}</p>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="auth-method">Authentication Method</label>
<select
id="auth-method"
value={authMethod}
onChange={(e) => setAuthMethod(e.target.value as 'token' | 'userpass' | 'ldap')}
className="form-select"
>
<option value="token">Token</option>
<option value="userpass">Username & Password</option>
<option value="ldap">LDAP</option>
</select>
</div>
{authMethod === 'token' ? (
<div className="form-group">
<label htmlFor="token">Vault Token *</label>
<input
id="token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Enter your vault token"
required
autoComplete="off"
/>
<small className="form-hint">
Your token will be used to authenticate with the vault server
</small>
</div>
) : (
<>
<div className="form-group">
<label htmlFor="username">Username *</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
autoComplete="username"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password *</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
autoComplete="current-password"
/>
</div>
</>
)}
<button
type="submit"
className="btn btn-primary btn-block"
disabled={isLoading}
>
{isLoading ? 'Connecting...' : 'Connect'}
</button>
</form>
<div className="security-notice">
<p>
<strong> Security Notice:</strong> This application connects directly to your
Vault server. Credentials are not stored permanently and are only kept in memory
during your session.
</p>
</div>
</div>
);
}
export default LoginForm;
+205
View File
@@ -0,0 +1,205 @@
.path-search {
background: var(--surface-light);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
border: 1px solid var(--border);
}
.path-search h3 {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
}
.search-controls {
margin-bottom: 1rem;
}
.search-progress {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
background: var(--surface);
border-radius: 6px;
margin: 1rem 0;
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid var(--border);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.search-stats {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.1) 0%, rgba(22, 163, 74, 0.1) 100%);
border: 1px solid var(--success-color);
border-radius: 6px;
padding: 1rem;
margin: 1rem 0;
}
.search-stats p {
margin: 0;
color: var(--text-primary);
}
.search-results {
margin-top: 1.5rem;
}
.search-results h4 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
}
.results-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem;
background: var(--surface);
}
.result-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--surface-light);
border-radius: 6px;
border: 1px solid var(--border);
transition: all 0.2s;
}
.result-item:not(.directory) {
cursor: pointer;
}
.result-item:not(.directory):hover {
border-color: var(--primary-color);
transform: translateX(4px);
}
.result-item.directory {
opacity: 0.8;
}
.result-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.result-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.result-path {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
word-break: break-all;
}
.result-mount {
font-size: 0.75rem;
color: var(--text-secondary);
font-style: italic;
}
.result-depth {
font-size: 0.75rem;
color: var(--text-secondary);
padding: 0.25rem 0.5rem;
background: var(--surface);
border-radius: 4px;
flex-shrink: 0;
}
.result-item .btn {
flex-shrink: 0;
}
.no-results {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.no-results p {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.no-results small {
font-size: 0.875rem;
font-style: italic;
}
.search-info {
background: var(--surface);
border-radius: 6px;
padding: 1rem;
margin-top: 1.5rem;
border: 1px solid var(--border);
}
.search-info h4 {
margin: 0 0 0.75rem 0;
font-size: 1rem;
}
.search-info ul {
margin: 0;
padding-left: 1.5rem;
}
.search-info li {
margin: 0.5rem 0;
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.5;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
cursor: pointer;
}
.checkbox-label input[type="checkbox"]:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.mount-count {
color: var(--primary-color);
font-weight: 600;
}
.mount-warning {
color: var(--danger-color);
font-size: 0.875rem;
font-style: italic;
}
+234
View File
@@ -0,0 +1,234 @@
import { useState } from 'react';
import { VaultServer, VaultCredentials, MountPoint } from '../types';
import { vaultApi, SearchResult } from '../services/vaultApi';
import './PathSearch.css';
interface PathSearchProps {
server: VaultServer;
credentials: VaultCredentials;
mountPoints?: MountPoint[];
onSelectPath: (path: string) => void;
}
function PathSearch({ server, credentials, mountPoints, onSelectPath }: PathSearchProps) {
const [searchTerm, setSearchTerm] = useState('');
const [basePath, setBasePath] = useState('secret/');
const [searchAllMounts, setSearchAllMounts] = useState(true);
const [results, setResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [searchTime, setSearchTime] = useState<number | null>(null);
// Debug: Log mount points when component mounts or they change
console.log('PathSearch - mountPoints:', mountPoints);
const handleSearch = async () => {
if (!searchTerm.trim()) {
alert('Please enter a search term');
return;
}
if (searchAllMounts && (!mountPoints || mountPoints.length === 0)) {
alert('No mount points available. Please ensure you are connected to Vault.');
return;
}
setIsSearching(true);
setResults([]);
setSearchTime(null);
const startTime = performance.now();
try {
let searchResults: SearchResult[];
if (searchAllMounts && mountPoints) {
// Search across all mount points
searchResults = await vaultApi.searchAllMounts(
server,
credentials,
mountPoints,
searchTerm
);
} else {
// Search in specific base path
searchResults = await vaultApi.searchPaths(
server,
credentials,
basePath,
searchTerm
);
}
const endTime = performance.now();
setSearchTime(endTime - startTime);
setResults(searchResults);
} catch (error) {
console.error('Search error:', error);
alert('Search failed. Check console for details.');
} finally {
setIsSearching(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isSearching) {
handleSearch();
}
};
return (
<div className="path-search">
<h3>🔍 Search Paths</h3>
<div className="search-controls">
<div className="form-group">
<label htmlFor="search-all-mounts" className="checkbox-label">
<input
id="search-all-mounts"
type="checkbox"
checked={searchAllMounts}
onChange={(e) => setSearchAllMounts(e.target.checked)}
disabled={!mountPoints || mountPoints.length === 0}
/>
Search across all mount points
{mountPoints && mountPoints.length > 0 ? (
<span className="mount-count"> ({mountPoints.length} available)</span>
) : (
<span className="mount-warning"> (none detected - logout and login again)</span>
)}
</label>
<small className="form-hint">
{!mountPoints || mountPoints.length === 0 ? (
<>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</>
)}
</small>
</div>
{!searchAllMounts && (
<div className="form-group">
<label htmlFor="base-path">Base Path</label>
<input
id="base-path"
type="text"
value={basePath}
onChange={(e) => setBasePath(e.target.value)}
placeholder="secret/"
/>
<small className="form-hint">
Starting path for recursive search
</small>
</div>
)}
<div className="form-group">
<label htmlFor="search-term">Search Term</label>
<div className="input-group">
<input
id="search-term"
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Enter path or keyword..."
disabled={isSearching}
/>
<button
className="btn btn-primary"
onClick={handleSearch}
disabled={isSearching}
>
{isSearching ? 'Searching...' : 'Search'}
</button>
</div>
</div>
</div>
{isSearching && (
<div className="search-progress">
<div className="spinner"></div>
<p>Searching recursively... This may take a moment.</p>
</div>
)}
{searchTime !== null && (
<div className="search-stats">
<p>
Found <strong>{results.length}</strong> result{results.length !== 1 ? 's' : ''}
in <strong>{(searchTime / 1000).toFixed(2)}s</strong>
</p>
</div>
)}
{results.length > 0 && (
<div className="search-results">
<h4>Search Results</h4>
<div className="results-list">
{results.map((result, index) => (
<div
key={index}
className={`result-item ${result.isDirectory ? 'directory' : 'secret'}`}
onClick={() => !result.isDirectory && onSelectPath(result.path)}
>
<span className="result-icon">
{result.isDirectory ? '📁' : '📄'}
</span>
<div className="result-details">
<span className="result-path">{result.path}</span>
{result.mountPoint && searchAllMounts && (
<span className="result-mount">📌 {result.mountPoint}</span>
)}
</div>
<span className="result-depth">Depth: {result.depth}</span>
{!result.isDirectory && (
<button
className="btn btn-sm btn-primary"
onClick={(e) => {
e.stopPropagation();
onSelectPath(result.path);
}}
>
View
</button>
)}
</div>
))}
</div>
</div>
)}
{!isSearching && results.length === 0 && searchTime !== null && (
<div className="no-results">
<p>
No results found for "{searchTerm}"
{searchAllMounts ? ' across all mount points' : ` in ${basePath}`}
</p>
<small>Try a different search term{!searchAllMounts && ' or base path'}</small>
</div>
)}
<div className="search-info">
<h4> Search Tips</h4>
<ul>
<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
{mountPoints && mountPoints.length > 0 && (
<> (detected: {mountPoints.map(m => m.path).join(', ')})</>
)}
</li>
<li>
<strong>Base path:</strong> When not searching all mounts, specify a starting path
</li>
<li>Directories are marked with 📁, secrets with 📄</li>
<li>Maximum search depth and results can be configured in settings</li>
</ul>
</div>
</div>
);
}
export default PathSearch;
+109
View File
@@ -0,0 +1,109 @@
.server-selector {
height: 100%;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.section-header h2 {
margin: 0;
}
.add-server-form {
background: var(--surface-light);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem;
border: 1px solid var(--border);
}
.server-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary);
}
.empty-state p {
margin: 0.5rem 0;
}
.empty-state .hint {
font-size: 0.875rem;
font-style: italic;
}
.server-card {
background: var(--surface-light);
border: 2px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
}
.server-card:hover {
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.server-card.selected {
border-color: var(--primary-color);
background: var(--surface);
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
}
.server-info {
flex: 1;
}
.server-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
}
.server-url {
color: var(--text-secondary);
font-size: 0.875rem;
margin: 0.25rem 0;
font-family: 'Courier New', monospace;
}
.server-description {
color: var(--text-secondary);
font-size: 0.875rem;
margin: 0.5rem 0 0 0;
font-style: italic;
}
.server-kv-version {
margin: 0.5rem 0 0 0;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-secondary);
}
.server-card .btn-danger {
margin-left: 1rem;
}
+160
View File
@@ -0,0 +1,160 @@
import { useState } from 'react';
import { VaultServer } from '../types';
import './ServerSelector.css';
interface ServerSelectorProps {
servers: VaultServer[];
selectedServer: VaultServer | null;
onAddServer: (server: VaultServer) => void;
onRemoveServer: (serverId: string) => void;
onSelectServer: (server: VaultServer) => void;
}
function ServerSelector({
servers,
selectedServer,
onAddServer,
onRemoveServer,
onSelectServer,
}: ServerSelectorProps) {
const [showAddForm, setShowAddForm] = useState(false);
const [newServer, setNewServer] = useState({
name: '',
url: '',
description: '',
kvVersion: 2 as 1 | 2,
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!newServer.name || !newServer.url) return;
const server: VaultServer = {
id: newServer.name,
// id: crypto.randomUUID(),
name: newServer.name,
url: newServer.url,
description: newServer.description || undefined,
kvVersion: newServer.kvVersion,
};
onAddServer(server);
setNewServer({ name: '', url: '', description: '', kvVersion: 2 });
setShowAddForm(false);
};
return (
<div className="server-selector">
<div className="section-header">
<h2>Vault Servers</h2>
<button
className="btn btn-primary"
onClick={() => setShowAddForm(!showAddForm)}
>
{showAddForm ? 'Cancel' : '+ Add Server'}
</button>
</div>
{showAddForm && (
<form className="add-server-form" onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="server-name">Server Name *</label>
<input
id="server-name"
type="text"
value={newServer.name}
onChange={(e) => setNewServer({ ...newServer, name: e.target.value })}
placeholder="Production Vault"
required
/>
</div>
<div className="form-group">
<label htmlFor="server-url">Server URL *</label>
<input
id="server-url"
type="url"
value={newServer.url}
onChange={(e) => setNewServer({ ...newServer, url: e.target.value })}
placeholder="https://vault.example.com"
required
/>
</div>
<div className="form-group">
<label htmlFor="server-description">Description</label>
<input
id="server-description"
type="text"
value={newServer.description}
onChange={(e) => setNewServer({ ...newServer, description: e.target.value })}
placeholder="Optional description"
/>
</div>
<div className="form-group">
<label htmlFor="kv-version">KV Secret Engine Version</label>
<select
id="kv-version"
value={newServer.kvVersion}
onChange={(e) => setNewServer({ ...newServer, kvVersion: parseInt(e.target.value) as 1 | 2 })}
className="form-select"
>
<option value="2">KV v2 (recommended)</option>
<option value="1">KV v1 (legacy)</option>
</select>
<small className="form-hint">
Most Vault servers use KV v2. Choose v1 only for legacy installations.
</small>
</div>
<button type="submit" className="btn btn-success">
Add Server
</button>
</form>
)}
<div className="server-list">
{servers.length === 0 ? (
<div className="empty-state">
<p>No vault servers configured yet.</p>
<p className="hint">Click "Add Server" to get started.</p>
</div>
) : (
servers.map((server) => (
<div
key={server.id}
className={`server-card ${selectedServer?.id === server.id ? 'selected' : ''}`}
onClick={() => onSelectServer(server)}
>
<div className="server-info">
<h3>{server.name}</h3>
<p className="server-url">{server.url}</p>
{server.description && (
<p className="server-description">{server.description}</p>
)}
<p className="server-kv-version">
<span className="badge">KV v{server.kvVersion || 2}</span>
</p>
</div>
<button
className="btn btn-danger btn-sm"
onClick={(e) => {
e.stopPropagation();
if (confirm(`Remove server "${server.name}"?`)) {
onRemoveServer(server.id);
}
}}
>
Remove
</button>
</div>
))
)}
</div>
</div>
);
}
export default ServerSelector;
+151
View File
@@ -0,0 +1,151 @@
.settings-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.settings-modal {
background: var(--surface);
border-radius: 12px;
max-width: 700px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
border: 1px solid var(--border);
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border);
}
.settings-header h2 {
margin: 0;
font-size: 1.5rem;
}
.btn-close {
background: none;
border: none;
font-size: 2rem;
line-height: 1;
cursor: pointer;
padding: 0;
width: 40px;
height: 40px;
border-radius: 6px;
color: var(--text-secondary);
transition: all 0.2s;
}
.btn-close:hover {
background: var(--surface-light);
color: var(--text-primary);
}
.settings-content {
padding: 1.5rem;
}
.settings-section {
margin-bottom: 2rem;
}
.settings-section:last-child {
margin-bottom: 0;
}
.settings-section h3 {
margin: 0 0 1rem 0;
font-size: 1.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.settings-section .form-group label[for] {
cursor: pointer;
}
.settings-section input[type="checkbox"] {
width: auto;
margin-right: 0.5rem;
cursor: pointer;
}
.cache-stats {
background: var(--surface-light);
border-radius: 6px;
padding: 1rem;
margin-top: 1.5rem;
border: 1px solid var(--border);
}
.cache-stats h4 {
margin: 0 0 1rem 0;
font-size: 1rem;
}
.cache-stats dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
margin: 0 0 1rem 0;
}
.cache-stats dt {
font-weight: 500;
color: var(--text-secondary);
}
.cache-stats dd {
margin: 0;
font-family: 'Courier New', monospace;
color: var(--text-primary);
}
.settings-footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1.5rem;
border-top: 1px solid var(--border);
}
.btn-secondary {
background-color: var(--surface-light);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background-color: var(--border);
}
@media (max-width: 768px) {
.settings-modal {
max-height: 100vh;
border-radius: 0;
}
.cache-stats dl {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.cache-stats dt {
font-weight: 600;
}
}
+200
View File
@@ -0,0 +1,200 @@
import { useState, useEffect } from 'react';
import { AppConfig, loadConfig, saveConfig } from '../config';
import { vaultCache } from '../utils/cache';
import './Settings.css';
interface SettingsProps {
onClose: () => void;
}
function Settings({ onClose }: SettingsProps) {
const [config, setConfig] = useState<AppConfig>(loadConfig());
const [cacheStats, setCacheStats] = useState(vaultCache.getStats());
useEffect(() => {
// Update cache stats
const interval = setInterval(() => {
setCacheStats(vaultCache.getStats());
}, 1000);
return () => clearInterval(interval);
}, []);
const handleSave = () => {
saveConfig(config);
alert('Settings saved successfully!');
onClose();
};
const handleClearCache = () => {
if (confirm('Are you sure you want to clear the cache?')) {
vaultCache.clear();
setCacheStats(vaultCache.getStats());
alert('Cache cleared successfully!');
}
};
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 formatDate = (timestamp: number | null): string => {
if (!timestamp) return 'N/A';
return new Date(timestamp).toLocaleString();
};
return (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
<div className="settings-header">
<h2> Settings</h2>
<button className="btn-close" onClick={onClose}>×</button>
</div>
<div className="settings-content">
<section className="settings-section">
<h3>Cache Settings</h3>
<div className="form-group">
<label htmlFor="cache-enabled">
<input
id="cache-enabled"
type="checkbox"
checked={config.cache.enabled}
onChange={(e) => setConfig({
...config,
cache: { ...config.cache, enabled: e.target.checked }
})}
/>
Enable cache
</label>
<small className="form-hint">
Cache API responses to reduce load on Vault server
</small>
</div>
<div className="form-group">
<label htmlFor="cache-size">
Maximum cache size (MB)
</label>
<input
id="cache-size"
type="number"
min="1"
max="100"
value={config.cache.maxSizeMB}
onChange={(e) => setConfig({
...config,
cache: { ...config.cache, maxSizeMB: parseInt(e.target.value) || 10 }
})}
/>
<small className="form-hint">
Maximum size of cached data in megabytes
</small>
</div>
<div className="form-group">
<label htmlFor="cache-age">
Cache expiration (minutes)
</label>
<input
id="cache-age"
type="number"
min="1"
max="1440"
value={Math.round(config.cache.maxAge / 1000 / 60)}
onChange={(e) => setConfig({
...config,
cache: { ...config.cache, maxAge: (parseInt(e.target.value) || 30) * 60 * 1000 }
})}
/>
<small className="form-hint">
How long cached entries remain valid
</small>
</div>
<div className="cache-stats">
<h4>Cache Statistics</h4>
<dl>
<dt>Total Size:</dt>
<dd>{formatBytes(cacheStats.totalSize)}</dd>
<dt>Entry Count:</dt>
<dd>{cacheStats.entryCount}</dd>
<dt>Oldest Entry:</dt>
<dd>{formatDate(cacheStats.oldestEntry)}</dd>
<dt>Newest Entry:</dt>
<dd>{formatDate(cacheStats.newestEntry)}</dd>
</dl>
<button className="btn btn-danger" onClick={handleClearCache}>
Clear Cache
</button>
</div>
</section>
<section className="settings-section">
<h3>Search Settings</h3>
<div className="form-group">
<label htmlFor="search-depth">
Maximum search depth
</label>
<input
id="search-depth"
type="number"
min="1"
max="50"
value={config.search.maxDepth}
onChange={(e) => setConfig({
...config,
search: { ...config.search, maxDepth: parseInt(e.target.value) || 10 }
})}
/>
<small className="form-hint">
Maximum recursion depth for path searches
</small>
</div>
<div className="form-group">
<label htmlFor="search-results">
Maximum search results
</label>
<input
id="search-results"
type="number"
min="10"
max="10000"
value={config.search.maxResults}
onChange={(e) => setConfig({
...config,
search: { ...config.search, maxResults: parseInt(e.target.value) || 1000 }
})}
/>
<small className="form-hint">
Maximum number of results to return from a search
</small>
</div>
</section>
</div>
<div className="settings-footer">
<button className="btn btn-secondary" onClick={onClose}>
Cancel
</button>
<button className="btn btn-success" onClick={handleSave}>
Save Settings
</button>
</div>
</div>
</div>
);
}
export default Settings;
+48
View File
@@ -0,0 +1,48 @@
// Application configuration
export interface AppConfig {
cache: {
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
};
}
// Default configuration
export const defaultConfig: AppConfig = {
cache: {
maxSizeMB: 10, // 10 MB default
maxAge: 1000 * 60 * 30, // 30 minutes
enabled: true,
},
search: {
maxDepth: 10,
maxResults: 1000,
},
};
// Load configuration from localStorage
export function loadConfig(): AppConfig {
try {
const saved = localStorage.getItem('vaultGuiConfig');
if (saved) {
return { ...defaultConfig, ...JSON.parse(saved) };
}
} catch (error) {
console.error('Failed to load config:', error);
}
return defaultConfig;
}
// Save configuration to localStorage
export function saveConfig(config: AppConfig): void {
try {
localStorage.setItem('vaultGuiConfig', JSON.stringify(config));
} catch (error) {
console.error('Failed to save config:', error);
}
}
+133
View File
@@ -0,0 +1,133 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--primary-color: #646cff;
--primary-hover: #535bf2;
--success-color: #22c55e;
--success-hover: #16a34a;
--danger-color: #ef4444;
--danger-hover: #dc2626;
--background: #242424;
--surface: #1a1a1a;
--surface-light: #2d2d2d;
--border: #3d3d3d;
--text-primary: rgba(255, 255, 255, 0.87);
--text-secondary: rgba(255, 255, 255, 0.6);
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
--primary-color: #646cff;
--primary-hover: #535bf2;
--background: #ffffff;
--surface: #f9fafb;
--surface-light: #f3f4f6;
--border: #e5e7eb;
--text-primary: #213547;
--text-secondary: #6b7280;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
background: var(--background);
}
#root {
width: 100%;
min-height: 100vh;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.2;
color: var(--text-primary);
}
a {
font-weight: 500;
color: var(--primary-color);
text-decoration: inherit;
}
a:hover {
color: var(--primary-hover);
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.25s;
}
button:hover {
border-color: var(--primary-color);
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
input,
select,
textarea {
font-family: inherit;
font-size: 1em;
padding: 0.6em;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text-primary);
transition: border-color 0.25s;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--primary-color);
}
code {
background-color: var(--surface-light);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
}
pre {
background-color: var(--surface);
padding: 1em;
border-radius: 8px;
overflow-x: auto;
border: 1px solid var(--border);
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+434
View File
@@ -0,0 +1,434 @@
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;
}
/**
* High-level Vault API service with caching
*
* This service wraps the VaultClient and adds caching functionality
* to prevent excessive API calls and improve performance.
*/
class VaultApiService {
/**
* Create a VaultClient instance for the given server and credentials
*/
private createClient(
server: VaultServer,
credentials: VaultCredentials,
kvVersion: 1 | 2 = 2
): VaultClient {
return new VaultClient({
server,
credentials,
timeout: 30000,
retries: 2,
kvVersion, // KV v2 by default (most common)
});
}
/**
* Generate a cache key for a given operation
*/
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<string[]> {
const cacheKey = this.getCacheKey(server, path, 'list');
// Check cache first
const cached = vaultCache.get<string[]>(cacheKey);
if (cached) {
console.log(`✓ Cache hit for list: ${path}`);
return cached;
}
console.log(`⚡ API call for list: ${path}`);
try {
const client = this.createClient(server, credentials, server.kvVersion);
const keys = await client.list(path);
// Cache the result
vaultCache.set(cacheKey, keys);
return keys;
} catch (error) {
if (error instanceof VaultError) {
console.error(`Vault error listing ${path}:`, error.message);
if (error.errors) {
console.error('Details:', error.errors);
}
} else {
console.error(`Error listing secrets at ${path}:`, error);
}
return [];
}
}
/**
* Read a secret from Vault with caching
*/
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}`);
try {
const client = this.createClient(server, credentials, server.kvVersion);
const secretData = await client.read<Record<string, unknown>>(path);
if (secretData) {
// Cache the result
vaultCache.set(cacheKey, secretData);
}
return secretData;
} catch (error) {
if (error instanceof VaultError) {
console.error(`Vault error reading ${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 secret at ${path}:`, error);
throw new VaultError('Failed to read secret');
}
}
}
/**
* Write a secret to Vault (no caching)
*/
async writeSecret(
server: VaultServer,
credentials: VaultCredentials,
path: string,
data: Record<string, unknown>
): Promise<void> {
console.log(`⚡ API call for write: ${path}`);
try {
const client = this.createClient(server, credentials, server.kvVersion);
await client.write(path, data);
// Invalidate cache for this path
const cacheKey = this.getCacheKey(server, path, 'read');
vaultCache.delete(cacheKey);
console.log(`✓ Secret written successfully: ${path}`);
} catch (error) {
if (error instanceof VaultError) {
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');
}
}
}
/**
* Delete a secret from Vault (no caching)
*/
async deleteSecret(
server: VaultServer,
credentials: VaultCredentials,
path: string
): Promise<void> {
console.log(`⚡ API call for delete: ${path}`);
try {
const client = this.createClient(server, credentials, server.kvVersion);
await client.delete(path);
// Invalidate cache for this path
const cacheKey = this.getCacheKey(server, path, 'read');
vaultCache.delete(cacheKey);
console.log(`✓ Secret deleted successfully: ${path}`);
} catch (error) {
if (error instanceof VaultError) {
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');
}
}
}
/**
* Verify login and get available mount points
*/
async verifyLoginAndGetMounts(
server: VaultServer,
credentials: VaultCredentials
): Promise<MountPoint[]> {
console.log('⚡ Verifying login and fetching mount points...');
try {
const client = this.createClient(server, credentials, server.kvVersion);
const mounts = await client.listMounts();
console.log('📋 Raw mount points from API:', mounts);
// Convert to array and filter for KV secret engines
const mountPoints: MountPoint[] = [];
for (const [path, mount] of Object.entries(mounts)) {
// Only include KV secret engines
if (mount.type === 'kv' || mount.type === 'generic') {
mountPoints.push({
path: path.replace(/\/$/, ''), // Remove trailing slash
type: mount.type,
description: mount.description,
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;
} catch (error) {
if (error instanceof VaultError) {
console.error('✗ Login verification failed:', error.message);
throw error;
}
throw new VaultError('Failed to verify login');
}
}
/**
* Recursively search for paths matching a search term
*/
async searchPaths(
server: VaultServer,
credentials: VaultCredentials,
basePath: string,
searchTerm: string,
currentDepth: number = 0,
mountPoint?: string
): Promise<SearchResult[]> {
const config = loadConfig();
// Check depth limit
if (currentDepth >= config.search.maxDepth) {
console.warn(`⚠ Max depth ${config.search.maxDepth} reached at ${basePath}`);
return [];
}
const results: SearchResult[] = [];
try {
// List items at current path
const items = await this.listSecrets(server, credentials, basePath);
for (const item of items) {
const fullPath = basePath ? `${basePath}${item}` : item;
const isDirectory = item.endsWith('/');
// Check if this path matches the search term
if (fullPath.toLowerCase().includes(searchTerm.toLowerCase())) {
results.push({
path: fullPath,
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;
}
}
// 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);
// 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);
}
}
}
} catch (error) {
console.error(`Error searching path ${basePath}:`, error);
}
return results;
}
/**
* Search across all mount points
*/
async searchAllMounts(
server: VaultServer,
credentials: VaultCredentials,
mountPoints: MountPoint[],
searchTerm: string
): Promise<SearchResult[]> {
console.log(`🔍 Searching across ${mountPoints.length} mount point(s)...`);
const allResults: SearchResult[] = [];
const config = loadConfig();
for (const mount of mountPoints) {
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
const results = await this.searchPaths(
{ ...server, kvVersion },
credentials,
`${mount.path}/`,
searchTerm,
0,
mount.path
);
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);
}
} catch (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;
}
/**
* Test connection to Vault server
*/
async testConnection(server: VaultServer): Promise<boolean> {
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;
} catch (error) {
console.error('✗ Failed to connect to Vault:', error);
return false;
}
}
/**
* Authenticate with username/password
*/
async loginUserpass(
server: VaultServer,
username: string,
password: string
): Promise<string> {
const client = this.createClient(server, {
serverId: server.id,
authMethod: 'userpass',
});
return await client.loginUserpass(username, password);
}
/**
* Authenticate with LDAP
*/
async loginLdap(
server: VaultServer,
username: string,
password: string
): Promise<string> {
const client = this.createClient(server, {
serverId: server.id,
authMethod: 'ldap',
});
return await client.loginLdap(username, password);
}
/**
* Get current token information
*/
async getTokenInfo(
server: VaultServer,
credentials: VaultCredentials
): Promise<unknown> {
const client = this.createClient(server, credentials);
return await client.tokenLookupSelf();
}
/**
* Revoke current token (logout)
*/
async logout(
server: VaultServer,
credentials: VaultCredentials
): Promise<void> {
const client = this.createClient(server, credentials);
await client.tokenRevokeSelf();
}
}
// Export singleton instance
export const vaultApi = new VaultApiService();
// Export VaultError for error handling
export { VaultError };
+445
View File
@@ -0,0 +1,445 @@
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
}
/**
* 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';
}
}
/**
* 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;
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;
}
// 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;
}
/**
* Make an HTTP request to the Vault API
*/
private async request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
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 {
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<T>(
path: string,
options: RequestInit = {},
attempt = 0
): Promise<T> {
try {
return await this.request<T>(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<T>(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<string[]> {
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<T = Record<string, unknown>>(path: string): Promise<T | null> {
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<T = Record<string, unknown>>(
path: string,
data: T
): Promise<void> {
const normalizedPath = this.transformPath(path, 'data');
const body = this.kvVersion === 2 ? { data } : data;
await this.requestWithRetry<void>(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<void> {
const normalizedPath = this.transformPath(path, 'data');
await this.requestWithRetry<void>(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<string> {
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<string> {
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<string, string>;
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<void> {
await this.requestWithRetry<void>('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<string, unknown>;
options: Record<string, unknown> | 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;
}
}
}
+49
View File
@@ -0,0 +1,49 @@
export interface VaultServer {
id: string;
name: string;
url: string;
description?: string;
kvVersion?: 1 | 2; // KV secret engine version (default: 2)
}
export interface VaultCredentials {
serverId: string;
token?: string;
username?: string;
password?: string;
authMethod: 'token' | 'userpass' | 'ldap';
}
export interface MountPoint {
path: string;
type: string;
description: string;
accessor: string;
config: {
default_lease_ttl: number;
max_lease_ttl: number;
};
options: {
version?: string;
} | Record<string, never>;
}
export interface VaultConnection {
server: VaultServer;
credentials: VaultCredentials;
isConnected: boolean;
lastConnected?: Date;
mountPoints?: MountPoint[];
}
export interface VaultSecret {
path: string;
data: Record<string, unknown>;
metadata?: {
created_time: string;
deletion_time: string;
destroyed: boolean;
version: number;
};
}
+202
View File
@@ -0,0 +1,202 @@
import { loadConfig } from '../config';
export interface CacheEntry<T> {
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;
}
class VaultCache {
private readonly CACHE_KEY = 'vaultApiCache';
private cache: Map<string, CacheEntry<unknown>>;
constructor() {
this.cache = this.loadFromStorage();
}
private loadFromStorage(): Map<string, CacheEntry<unknown>> {
try {
const stored = localStorage.getItem(this.CACHE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return new Map(Object.entries(parsed));
}
} catch (error) {
console.error('Failed to load cache from storage:', error);
}
return new Map();
}
private saveToStorage(): void {
try {
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);
// If quota exceeded, clear old entries and retry
this.evictOldEntries(0.5); // Remove 50% of entries
try {
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);
}
}
}
private calculateSize(data: unknown): number {
// Rough estimation of size in bytes
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);
for (let i = 0; i < toRemove; i++) {
this.cache.delete(entries[i][0]);
}
}
private enforceSizeLimit(): void {
const config = loadConfig();
if (!config.cache.enabled) return;
const maxBytes = config.cache.maxSizeMB * 1024 * 1024;
let totalSize = 0;
// Calculate total size
for (const entry of this.cache.values()) {
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);
for (const [key, entry] of entries) {
if (totalSize <= maxBytes * 0.8) break; // Remove until 80% of limit
totalSize -= entry.size;
this.cache.delete(key);
}
}
}
get<T>(key: string): T | null {
const config = loadConfig();
if (!config.cache.enabled) return null;
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
if (!entry) return null;
// Check if entry is expired
const age = Date.now() - entry.timestamp;
if (age > config.cache.maxAge) {
this.cache.delete(key);
return null;
}
return entry.data;
}
set<T>(key: string, data: T): void {
const config = loadConfig();
if (!config.cache.enabled) return;
const size = this.calculateSize(data);
const entry: CacheEntry<T> = {
data,
timestamp: Date.now(),
size,
};
this.cache.set(key, entry as CacheEntry<unknown>);
this.enforceSizeLimit();
this.saveToStorage();
}
has(key: string): boolean {
const config = loadConfig();
if (!config.cache.enabled) return false;
const entry = this.cache.get(key);
if (!entry) return false;
const age = Date.now() - entry.timestamp;
if (age > config.cache.maxAge) {
this.cache.delete(key);
return false;
}
return true;
}
delete(key: string): void {
this.cache.delete(key);
this.saveToStorage();
}
clear(): void {
this.cache.clear();
this.saveToStorage();
}
getStats(): CacheStats {
let totalSize = 0;
let oldestEntry: number | null = null;
let newestEntry: number | null = null;
for (const entry of this.cache.values()) {
totalSize += entry.size;
if (oldestEntry === null || entry.timestamp < oldestEntry) {
oldestEntry = entry.timestamp;
}
if (newestEntry === null || entry.timestamp > newestEntry) {
newestEntry = entry.timestamp;
}
}
return {
totalSize,
entryCount: this.cache.size,
oldestEntry,
newestEntry,
};
}
// Clean up expired entries
cleanup(): void {
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);
}
}
for (const key of keysToDelete) {
this.cache.delete(key);
}
if (keysToDelete.length > 0) {
this.saveToStorage();
}
}
}
// Singleton instance
export const vaultCache = new VaultCache();
// Cleanup expired entries on page load
vaultCache.cleanup();
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />