tractatus/.credential-vault/vault-ui.js
TheFlow ac2db33732 fix(submissions): restructure Economist package and fix article display
- Create Economist SubmissionTracking package correctly:
  * mainArticle = full blog post content
  * coverLetter = 216-word SIR— letter
  * Links to blog post via blogPostId
- Archive 'Letter to The Economist' from blog posts (it's the cover letter)
- Fix date display on article cards (use published_at)
- Target publication already displaying via blue badge

Database changes:
- Make blogPostId optional in SubmissionTracking model
- Economist package ID: 68fa85ae49d4900e7f2ecd83
- Le Monde package ID: 68fa2abd2e6acd5691932150

Next: Enhanced modal with tabs, validation, export

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 08:47:42 +13:00

736 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = `
<div class="totp-code">${data.code}</div>
<div class="totp-timer">Expires in ${data.expiresIn} seconds</div>
`;
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 = `<p class="warning">No credentials found in /${this.currentProject} folder</p>`;
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 = `
<h2>${this.getGroupIcon(type)} ${type} <span class="count">(${groups[type].length})</span></h2>
`;
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();