first version
This commit is contained in:
+169
@@ -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
@@ -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;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
Reference in New Issue
Block a user