/**
* 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();