/** * Tractatus Credential Vault - Frontend Client * * WebSocket client for interactive credential vault access */ class VaultClient { constructor() { this.ws = null; this.sessionId = null; this.locked = true; this.credentials = []; this.autoLockTimer = null; this.currentProject = 'tractatus'; // Default project this.editingCredential = null; // Track credential being edited this.init(); } init() { this.connectWebSocket(); this.setupEventListeners(); this.resetAutoLockTimer(); } connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.hostname || '127.0.0.1'; const port = window.location.port || '8888'; this.ws = new WebSocket(`${protocol}//${host}:${port}`); this.ws.onopen = () => { console.log('WebSocket connected'); this.updateConnectionStatus(true); }; this.ws.onmessage = (event) => { this.handleMessage(JSON.parse(event.data)); }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); this.showError('Connection error. Is the server running?'); }; this.ws.onclose = () => { console.log('WebSocket disconnected'); this.updateConnectionStatus(false); // Attempt to reconnect after 3 seconds setTimeout(() => { if (!this.ws || this.ws.readyState === WebSocket.CLOSED) { this.connectWebSocket(); } }, 3000); }; } send(data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); this.resetAutoLockTimer(); } else { this.showError('Not connected to server'); } } handleMessage(data) { console.log('WebSocket message received:', data); switch (data.type) { case 'unlocked': this.handleUnlocked(data); break; case 'locked': this.handleLocked(data); break; case 'logged_out': this.handleLoggedOut(data); break; case 'list': this.handleList(data); break; case 'show': this.handleShow(data); break; case 'totp': this.handleTotp(data); break; case 'added': this.handleAdded(data); break; case 'edited': this.handleEdited(data); break; case 'deleted': this.handleDeleted(data); break; case 'error': this.showError(data.message); break; default: console.warn('Unknown message type:', data.type); } } handleUnlocked(data) { this.sessionId = data.sessionId; this.locked = false; this.updateLockStatus(false); this.showSuccess(data.message); // Hide unlock form, show credentials document.getElementById('unlock-section').classList.add('hidden'); document.getElementById('credentials-section').classList.remove('hidden'); // Load credentials this.loadCredentials(); // Enable lock button document.getElementById('lock-btn').disabled = false; } handleLocked(data) { this.locked = true; this.sessionId = null; this.updateLockStatus(true); this.showWarning(data.message); // Show unlock form, hide credentials document.getElementById('unlock-section').classList.remove('hidden'); document.getElementById('credentials-section').classList.add('hidden'); // Disable lock button document.getElementById('lock-btn').disabled = true; // Clear password input document.getElementById('master-password').value = ''; } handleLoggedOut(data) { this.handleLocked(data); } handleList(data) { this.credentials = data.entries; this.renderCredentials(); } handleShow(data) { const credential = data.credential; const entryName = data.entry.split('/').pop(); // Check if this is for editing if (this.editingCredential && this.editingCredential === entryName) { // Open edit modal with credential data this.openCredentialModal('edit', { name: entryName, username: credential.username || '', password: credential.password || '', url: credential.url || '', notes: credential.notes || '' }); this.editingCredential = null; // Reset return; } // Find credential card const cards = document.querySelectorAll('.credential-card'); cards.forEach(card => { if (card.dataset.entry === data.entry) { const body = card.querySelector('.credential-body'); // Clear existing content body.innerHTML = ''; // Add fields for (const [key, value] of Object.entries(credential)) { if (key === 'title') continue; // Skip title, already in header const fieldDiv = document.createElement('div'); fieldDiv.className = `credential-field ${key === 'password' ? 'password' : ''}`; const label = document.createElement('label'); label.textContent = key.charAt(0).toUpperCase() + key.slice(1) + ':'; const span = document.createElement('span'); span.textContent = value; fieldDiv.appendChild(label); fieldDiv.appendChild(span); body.appendChild(fieldDiv); } // Add action buttons const actionsDiv = document.createElement('div'); actionsDiv.className = 'credential-actions'; // Copy password button if (credential.password) { const copyBtn = document.createElement('button'); copyBtn.className = 'btn-copy'; copyBtn.textContent = 'Copy Password'; copyBtn.onclick = () => this.copyToClipboard(credential.password); actionsDiv.appendChild(copyBtn); } // Copy username button if (credential.username) { const copyUserBtn = document.createElement('button'); copyUserBtn.className = 'btn-copy'; copyUserBtn.textContent = 'Copy Username'; copyUserBtn.onclick = () => this.copyToClipboard(credential.username); actionsDiv.appendChild(copyUserBtn); } body.appendChild(actionsDiv); // Show body body.classList.add('visible'); } }); } handleTotp(data) { const entryName = data.entry.split('/').pop(); // Find credential card and show TOTP const cards = document.querySelectorAll('.credential-card'); cards.forEach(card => { if (card.dataset.entry === data.entry) { const body = card.querySelector('.credential-body'); // Add TOTP display const totpDiv = document.createElement('div'); totpDiv.innerHTML = `
${data.code}
Expires in ${data.expiresIn} seconds
`; body.innerHTML = ''; body.appendChild(totpDiv); body.classList.add('visible'); // Auto-refresh TOTP after expiry setTimeout(() => { if (body.classList.contains('visible')) { this.send({ type: 'totp', entry: data.entry }); } }, data.expiresIn * 1000); } }); } handleAdded(data) { console.log('handleAdded called:', data); this.showSuccess(data.message); this.closeCredentialModal(); // Reload credentials list this.loadCredentials(); } handleEdited(data) { console.log('handleEdited called:', data); this.showSuccess(data.message); this.closeCredentialModal(); // Reload credentials list this.loadCredentials(); } handleDeleted(data) { this.showSuccess(data.message); // Reload credentials list this.loadCredentials(); } loadCredentials() { this.send({ type: 'list', folder: `/${this.currentProject}` }); } renderCredentials() { const listEl = document.getElementById('credentials-list'); listEl.innerHTML = ''; if (this.credentials.length === 0) { listEl.innerHTML = `

No credentials found in /${this.currentProject} folder

`; return; } // Group credentials by type const groups = {}; this.credentials.forEach(entry => { const type = this.guessCredentialType(entry); if (!groups[type]) { groups[type] = []; } groups[type].push(entry); }); // Sort credentials alphabetically within each group Object.keys(groups).forEach(type => { groups[type].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); }); // Define display order for groups const groupOrder = [ 'Database', 'API Key', 'Secret', 'Auth', 'Email', 'SSH', '2FA', 'URL', 'Config', 'Other' ]; // Render groups in order groupOrder.forEach(type => { if (!groups[type] || groups[type].length === 0) return; // Create group header const groupHeader = document.createElement('div'); groupHeader.className = 'credential-group-header'; groupHeader.innerHTML = `

${this.getGroupIcon(type)} ${type} (${groups[type].length})

`; listEl.appendChild(groupHeader); // Create group container const groupContainer = document.createElement('div'); groupContainer.className = 'credential-group'; // Add credentials in this group groups[type].forEach(entry => { const card = this.createCredentialCard(entry); groupContainer.appendChild(card); }); listEl.appendChild(groupContainer); }); } getGroupIcon(type) { const icons = { 'Database': '🗄️', 'API Key': '🔑', 'Secret': '🔐', 'Auth': '👤', 'Email': '📧', 'SSH': '🔒', '2FA': '📱', 'URL': '🌐', 'Config': '⚙️', 'Other': '📦' }; return icons[type] || '📦'; } createCredentialCard(entry) { const card = document.createElement('div'); card.className = 'credential-card'; card.dataset.entry = `/${this.currentProject}/${entry}`; const header = document.createElement('div'); header.className = 'credential-header'; const title = document.createElement('h3'); title.textContent = `📦 ${entry}`; const type = document.createElement('span'); type.className = 'credential-type'; type.textContent = this.guessCredentialType(entry); // Action buttons const actions = document.createElement('div'); actions.className = 'credential-actions'; const editBtn = document.createElement('button'); editBtn.className = 'btn-icon'; editBtn.innerHTML = '✏️'; editBtn.title = 'Edit credential'; editBtn.onclick = (e) => { e.stopPropagation(); // Set editing mode and fetch credential data this.editingCredential = entry; this.send({ type: 'show', entry: `/${this.currentProject}/${entry}` }); }; const deleteBtn = document.createElement('button'); deleteBtn.className = 'btn-icon btn-danger'; deleteBtn.innerHTML = '🗑️'; deleteBtn.title = 'Delete credential'; deleteBtn.onclick = (e) => { e.stopPropagation(); this.deleteCredential(entry); }; actions.appendChild(editBtn); actions.appendChild(deleteBtn); header.appendChild(title); header.appendChild(type); header.appendChild(actions); const body = document.createElement('div'); body.className = 'credential-body'; card.appendChild(header); card.appendChild(body); // Click to expand/show credential card.onclick = () => { if (body.classList.contains('visible')) { body.classList.remove('visible'); } else { // Hide all other cards document.querySelectorAll('.credential-body').forEach(b => { b.classList.remove('visible'); }); // Load this credential this.send({ type: 'show', entry: `/${this.currentProject}/${entry}` }); } }; return card; } guessCredentialType(name) { const lower = name.toLowerCase(); // Database credentials if (lower.includes('mongodb') || lower.includes('mongo_uri')) return 'Database'; if (lower.includes('db_') || lower.includes('database')) return 'Database'; if (lower.includes('postgres') || lower.includes('mysql')) return 'Database'; // API Keys & Tokens if (lower.includes('api_key') || lower.includes('apikey')) return 'API Key'; if (lower.includes('stripe_') || lower.includes('anthropic')) return 'API Key'; if (lower.includes('token') || lower.includes('access_key')) return 'API Key'; // Secrets & Encryption if (lower.includes('jwt_secret') || lower.includes('session_secret')) return 'Secret'; if (lower.includes('encryption') || lower.includes('cipher')) return 'Secret'; if (lower.includes('_secret') && !lower.includes('api')) return 'Secret'; // Authentication if (lower.includes('password') || lower.includes('passwd')) return 'Auth'; if (lower.includes('username') || lower.includes('user_')) return 'Auth'; if (lower.includes('auth') || lower.includes('oauth')) return 'Auth'; // Email & SMTP if (lower.includes('smtp') || lower.includes('email')) return 'Email'; if (lower.includes('mail_') || lower.includes('mailgun')) return 'Email'; // SSH & Security if (lower.includes('ssh')) return 'SSH'; if (lower.includes('2fa') || lower.includes('totp')) return '2FA'; // URLs & Endpoints if (lower.includes('url') || lower.includes('endpoint')) return 'URL'; if (lower.includes('_uri') || lower.includes('base_url')) return 'URL'; // Environment/Config if (lower.includes('node_env') || lower.includes('port')) return 'Config'; if (lower.includes('host') || lower.includes('domain')) return 'Config'; if (lower.includes('log_') || lower.includes('debug')) return 'Config'; return 'Other'; } copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => { this.showSuccess('Copied to clipboard!'); // Auto-clear clipboard after 30 seconds setTimeout(() => { navigator.clipboard.writeText(''); }, 30000); }).catch(err => { this.showError('Failed to copy to clipboard'); }); } updateLockStatus(locked) { const statusEl = document.getElementById('lock-status'); if (locked) { statusEl.textContent = '🔒 Locked'; statusEl.className = 'lock-status locked'; } else { statusEl.textContent = '🔓 Unlocked'; statusEl.className = 'lock-status unlocked'; } } updateConnectionStatus(connected) { const statusEl = document.getElementById('connection-status'); if (connected) { statusEl.textContent = '✓ Connected'; statusEl.className = 'connection-status connected'; } else { statusEl.textContent = '⚠️ Disconnected'; statusEl.className = 'connection-status disconnected'; } } showError(message) { const messageEl = document.getElementById('unlock-message'); messageEl.className = 'error'; messageEl.textContent = '❌ ' + message; messageEl.style.display = 'block'; setTimeout(() => { messageEl.style.display = 'none'; }, 5000); } showSuccess(message) { const messageEl = document.getElementById('unlock-message'); messageEl.className = 'success'; messageEl.textContent = '✓ ' + message; messageEl.style.display = 'block'; setTimeout(() => { messageEl.style.display = 'none'; }, 3000); } showWarning(message) { const messageEl = document.getElementById('unlock-message'); messageEl.className = 'warning'; messageEl.textContent = '⚠️ ' + message; messageEl.style.display = 'block'; setTimeout(() => { messageEl.style.display = 'none'; }, 5000); } resetAutoLockTimer() { if (this.autoLockTimer) { clearTimeout(this.autoLockTimer); } // Auto-lock after 15 minutes of inactivity this.autoLockTimer = setTimeout(() => { if (!this.locked) { this.send({ type: 'lock' }); } }, 15 * 60 * 1000); } openCredentialModal(mode = 'add', credential = null) { const modal = document.getElementById('credential-modal'); const form = document.getElementById('credential-form'); const title = document.getElementById('modal-title'); const nameField = document.getElementById('cred-name'); // Set modal title title.textContent = mode === 'add' ? '➕ Add Credential' : '✏️ Edit Credential'; // Clear/populate form form.dataset.mode = mode; nameField.value = credential?.name || ''; document.getElementById('cred-username').value = credential?.username || ''; document.getElementById('cred-password').value = credential?.password || ''; document.getElementById('cred-url').value = credential?.url || ''; document.getElementById('cred-notes').value = credential?.notes || ''; // Store original name for edit mode // In edit mode, make name field read-only (KeePassXC can't rename entries) if (mode === 'edit' && credential) { form.dataset.originalName = credential.name; nameField.readOnly = true; nameField.style.backgroundColor = '#f5f5f5'; nameField.style.cursor = 'not-allowed'; } else { nameField.readOnly = false; nameField.style.backgroundColor = ''; nameField.style.cursor = ''; } modal.style.display = 'block'; } closeCredentialModal() { const modal = document.getElementById('credential-modal'); modal.style.display = 'none'; // Clear form document.getElementById('credential-form').reset(); } saveCredential() { const form = document.getElementById('credential-form'); const mode = form.dataset.mode; const name = document.getElementById('cred-name').value.trim(); const username = document.getElementById('cred-username').value.trim(); const password = document.getElementById('cred-password').value; const url = document.getElementById('cred-url').value.trim(); const notes = document.getElementById('cred-notes').value.trim(); console.log('saveCredential called - mode:', mode); console.log('Form data:', { name, username, password: password ? '***' : '(empty)', url, notes }); if (!name) { this.showError('Credential name is required'); return; } if (!password && mode === 'add') { this.showError('Password is required'); return; } // For edit mode, use the original name to identify the entry const entryName = (mode === 'edit' && form.dataset.originalName) ? form.dataset.originalName : name; console.log('Using entry name:', entryName, '(original:', form.dataset.originalName, ')'); const data = { type: mode, folder: `/${this.currentProject}`, name: entryName, username: username, password: password, url: url, notes: notes }; console.log('Sending WebSocket message:', data); this.send(data); } deleteCredential(name) { if (!confirm(`Are you sure you want to delete "${name}"?\n\nThis action cannot be undone.`)) { return; } this.send({ type: 'delete', folder: `/${this.currentProject}`, name: name }); } setupEventListeners() { // Unlock form document.getElementById('unlock-form').addEventListener('submit', (e) => { e.preventDefault(); const password = document.getElementById('master-password').value; if (!password) { this.showError('Please enter master password'); return; } document.getElementById('unlock-btn').disabled = true; document.getElementById('unlock-btn').textContent = 'Unlocking...'; this.send({ type: 'unlock', password: password }); setTimeout(() => { document.getElementById('unlock-btn').disabled = false; document.getElementById('unlock-btn').textContent = 'Unlock Vault'; }, 2000); }); // Lock button document.getElementById('lock-btn').addEventListener('click', () => { this.send({ type: 'lock' }); }); // Add credential button document.getElementById('add-credential-btn').addEventListener('click', () => { this.openCredentialModal('add'); }); // Project selector document.getElementById('project-selector').addEventListener('change', (e) => { this.currentProject = e.target.value; this.loadCredentials(); }); // Modal close button document.getElementById('modal-close-btn').addEventListener('click', () => { this.closeCredentialModal(); }); // Modal cancel button document.getElementById('modal-cancel-btn').addEventListener('click', () => { this.closeCredentialModal(); }); // Modal save button document.getElementById('modal-save-btn').addEventListener('click', () => { this.saveCredential(); }); // Close modal on outside click window.addEventListener('click', (e) => { const modal = document.getElementById('credential-modal'); if (e.target === modal) { this.closeCredentialModal(); } }); // Reset auto-lock timer on user activity document.addEventListener('mousemove', () => this.resetAutoLockTimer()); document.addEventListener('keypress', () => this.resetAutoLockTimer()); } } // Initialize vault client when page loads const vaultClient = new VaultClient();