From b0ccbab97a62ceab294f1cdb9fe0f7e67c448276 Mon Sep 17 00:00:00 2001 From: David L Date: Wed, 14 Jan 2026 18:36:43 +1000 Subject: [PATCH] Add Connected Accounts feature for storing website credentials --- client/app.js | 146 ++++++++++++++++++++++++ client/index.html | 41 +++++++ server/database.js | 17 +++ server/index.js | 2 + server/routes/credentials.js | 212 +++++++++++++++++++++++++++++++++++ 5 files changed, 418 insertions(+) create mode 100644 server/routes/credentials.js diff --git a/client/app.js b/client/app.js index e745a18..516a4e6 100644 --- a/client/app.js +++ b/client/app.js @@ -570,6 +570,8 @@ function showSettingsModal() { // Reset forms document.getElementById('emailTab').querySelector('form').reset(); document.getElementById('passwordTab').querySelector('form').reset(); + // Load connected accounts + loadConnectedAccounts(); // Show email tab by default switchSettingsTab('email'); showModal('settingsModal'); @@ -643,6 +645,150 @@ function updatePassword(event) { .catch(err => showNotification('Error updating password', 'error')); } +// Connected Accounts Management +let availableSites = []; + +async function loadConnectedAccounts() { + try { + const response = await fetch(`${API_BASE}/credentials/my-accounts`, { + headers: { 'Authorization': `Bearer ${authToken}` } + }); + const data = await response.json(); + + availableSites = data.sites || []; + + // Populate site selector + const siteSelect = document.getElementById('accountSiteSelect'); + siteSelect.innerHTML = '' + + availableSites.map(site => ``).join(''); + + // Display connected accounts + const accountsList = document.getElementById('connectedAccountsList'); + if (data.accounts && data.accounts.length > 0) { + accountsList.innerHTML = data.accounts.map(account => { + const statusColor = account.is_connected ? '#00ae42' : '#dc3545'; + const statusText = account.is_connected ? '✓ Connected' : '✗ Disconnected'; + return ` +
+
+

${account.site_name}

+

Username: ${account.username}

+

+ ${statusText} +

+
+ +
+ `; + }).join(''); + } else { + accountsList.innerHTML = '

No connected accounts yet

'; + } + } catch (error) { + console.error('Error loading connected accounts:', error); + showNotification('Failed to load connected accounts', 'error'); + } +} + +function updateAccountForm() { + const siteSelect = document.getElementById('accountSiteSelect').value; + const formFields = document.getElementById('accountFormFields'); + + if (siteSelect) { + formFields.style.display = 'block'; + // Clear fields + document.getElementById('accountUsername').value = ''; + document.getElementById('accountPassword').value = ''; + document.getElementById('accountApiKey').value = ''; + document.getElementById('accountAccessToken').value = ''; + } else { + formFields.style.display = 'none'; + } +} + +async function addConnectedAccount() { + const siteName = document.getElementById('accountSiteSelect').value; + const username = document.getElementById('accountUsername').value; + const password = document.getElementById('accountPassword').value; + const apiKey = document.getElementById('accountApiKey').value; + const accessToken = document.getElementById('accountAccessToken').value; + + if (!siteName || (!username && !apiKey && !accessToken)) { + showNotification('Please provide at least a username or API key', 'error'); + return; + } + + try { + const response = await fetch(`${API_BASE}/credentials/connect`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ siteName, username, password, apiKey, accessToken }) + }); + + const data = await response.json(); + + if (response.ok) { + showNotification(data.message, 'success'); + await loadConnectedAccounts(); + } else { + showNotification(data.error || 'Failed to add account', 'error'); + } + } catch (error) { + showNotification('Error adding account: ' + error.message, 'error'); + } +} + +async function removeConnectedAccount(siteName) { + if (!confirm(`Remove ${siteName} account?`)) return; + + try { + const response = await fetch(`${API_BASE}/credentials/disconnect/${siteName}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${authToken}` } + }); + + const data = await response.json(); + + if (response.ok) { + showNotification(data.message, 'success'); + await loadConnectedAccounts(); + } else { + showNotification(data.error || 'Failed to remove account', 'error'); + } + } catch (error) { + showNotification('Error removing account: ' + error.message, 'error'); + } +} + +async function testAccountConnection() { + const siteName = document.getElementById('accountSiteSelect').value; + + if (!siteName) { + showNotification('Please select a website', 'error'); + return; + } + + try { + const response = await fetch(`${API_BASE}/credentials/test-connection/${siteName}`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${authToken}` } + }); + + const data = await response.json(); + + if (response.ok) { + showNotification(data.message, 'success'); + } else { + showNotification(data.error || 'Connection test failed', 'error'); + } + } catch (error) { + showNotification('Error testing connection: ' + error.message, 'error'); + } +} + // Close modals when clicking outside window.onclick = function(event) { if (event.target.classList.contains('modal')) { diff --git a/client/index.html b/client/index.html index 8d550fe..a87b4c0 100644 --- a/client/index.html +++ b/client/index.html @@ -654,6 +654,7 @@
+
@@ -693,6 +694,46 @@ + + +
+

Connect your accounts from 3D model websites to automatically download models from URLs.

+ +
+ +
+ +
+

Add New Account

+
+ + +
+ + +
+
diff --git a/server/database.js b/server/database.js index 4e7ce89..90b8b3a 100644 --- a/server/database.js +++ b/server/database.js @@ -137,6 +137,23 @@ function initDatabase() { ) `); + // Site credentials table (for storing encrypted login credentials) + db.run(` + CREATE TABLE IF NOT EXISTS site_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + site_name TEXT NOT NULL, + username TEXT, + password TEXT, + api_key TEXT, + access_token TEXT, + is_connected BOOLEAN DEFAULT 1, + last_updated DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, site_name) + ) + `); + // Migration: Add is_primary column to model_files if it doesn't exist db.all("PRAGMA table_info(model_files)", [], (err, columns) => { if (err) { diff --git a/server/index.js b/server/index.js index 1265414..5b6cd84 100644 --- a/server/index.js +++ b/server/index.js @@ -15,6 +15,7 @@ import printQueueRoutes from './routes/printQueue.js'; import exportRoutes from './routes/export.js'; import importRoutes from './routes/import.js'; import printersRoutes from './routes/printers.js'; +import credentialsRoutes from './routes/credentials.js'; // Import database to initialize import './database.js'; @@ -48,6 +49,7 @@ app.use('/api/print-queue', printQueueRoutes); app.use('/api/export', exportRoutes); app.use('/api/import', importRoutes); app.use('/api/printers', printersRoutes); +app.use('/api/credentials', credentialsRoutes); // Health check app.get('/api/health', (req, res) => { diff --git a/server/routes/credentials.js b/server/routes/credentials.js new file mode 100644 index 0000000..f0ae39a --- /dev/null +++ b/server/routes/credentials.js @@ -0,0 +1,212 @@ +import express from 'express'; +import crypto from 'crypto'; +import db from '../database.js'; +import { authenticateToken } from '../middleware/auth.js'; + +const router = express.Router(); + +const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-insecure-key-change-in-production'; + +// Supported sites +const SUPPORTED_SITES = [ + { id: 'thingiverse', name: 'Thingiverse', icon: 'fa-cube' }, + { id: 'printables', name: 'Printables', icon: 'fa-print' }, + { id: 'makerworld', name: 'MakerWorld', icon: 'fa-globe' }, + { id: 'myminifactory', name: 'MyMiniFactory', icon: 'fa-factory' }, + { id: 'cults3d', name: 'Cults3D', icon: 'fa-cube' } +]; + +// Encryption helper functions +function encrypt(text) { + try { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY.padEnd(32, '0').slice(0, 32)), iv); + let encrypted = cipher.update(text); + encrypted = Buffer.concat([encrypted, cipher.final()]); + return iv.toString('hex') + ':' + encrypted.toString('hex'); + } catch (error) { + console.error('Encryption error:', error); + return text; + } +} + +function decrypt(text) { + try { + const parts = text.split(':'); + if (parts.length !== 2) return text; + + const iv = Buffer.from(parts[0], 'hex'); + const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY.padEnd(32, '0').slice(0, 32)), iv); + let decrypted = decipher.update(Buffer.from(parts[1], 'hex')); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted.toString(); + } catch (error) { + console.error('Decryption error:', error); + return text; + } +} + +// Get list of supported sites +router.get('/sites', (req, res) => { + res.json({ sites: SUPPORTED_SITES }); +}); + +// Get user's connected accounts +router.get('/my-accounts', authenticateToken, (req, res) => { + const query = ` + SELECT id, site_name, username, is_connected, last_updated + FROM site_credentials + WHERE user_id = ? + ORDER BY site_name ASC + `; + + db.all(query, [req.user.id], (err, credentials) => { + if (err) { + return res.status(500).json({ error: err.message }); + } + + res.json({ + accounts: credentials || [], + sites: SUPPORTED_SITES + }); + }); +}); + +// Add or update credentials for a site +router.post('/connect', authenticateToken, async (req, res) => { + const { siteName, username, password, apiKey, accessToken } = req.body; + + if (!siteName || (!username && !apiKey && !accessToken)) { + return res.status(400).json({ error: 'Site name and credentials are required' }); + } + + // Validate site name + const validSite = SUPPORTED_SITES.find(s => s.id === siteName); + if (!validSite) { + return res.status(400).json({ error: 'Unsupported site' }); + } + + try { + const encryptedPassword = password ? encrypt(password) : null; + const encryptedApiKey = apiKey ? encrypt(apiKey) : null; + const encryptedAccessToken = accessToken ? encrypt(accessToken) : null; + + const query = ` + INSERT INTO site_credentials (user_id, site_name, username, password, api_key, access_token, is_connected) + VALUES (?, ?, ?, ?, ?, ?, 1) + ON CONFLICT(user_id, site_name) + DO UPDATE SET + username = excluded.username, + password = excluded.password, + api_key = excluded.api_key, + access_token = excluded.access_token, + is_connected = 1, + last_updated = CURRENT_TIMESTAMP + `; + + db.run( + query, + [ + req.user.id, + siteName, + username || null, + encryptedPassword, + encryptedApiKey, + encryptedAccessToken + ], + (err) => { + if (err) { + return res.status(500).json({ error: err.message }); + } + + res.json({ + message: `Connected to ${validSite.name} successfully`, + site: siteName + }); + } + ); + } catch (error) { + res.status(500).json({ error: 'Failed to save credentials: ' + error.message }); + } +}); + +// Remove credentials for a site +router.delete('/disconnect/:siteName', authenticateToken, (req, res) => { + const { siteName } = req.params; + + const query = 'DELETE FROM site_credentials WHERE user_id = ? AND site_name = ?'; + + db.run(query, [req.user.id, siteName], (err) => { + if (err) { + return res.status(500).json({ error: err.message }); + } + + res.json({ message: `Disconnected from ${siteName}` }); + }); +}); + +// Test credentials (attempt to login) +router.post('/test-connection/:siteName', authenticateToken, async (req, res) => { + const { siteName } = req.params; + + const query = ` + SELECT username, password, api_key, access_token + FROM site_credentials + WHERE user_id = ? AND site_name = ? + `; + + db.get(query, [req.user.id, siteName], async (err, creds) => { + if (err) { + return res.status(500).json({ error: err.message }); + } + + if (!creds) { + return res.status(404).json({ error: 'No credentials found for this site' }); + } + + try { + // Decrypt credentials + const decryptedPassword = creds.password ? decrypt(creds.password) : null; + const decryptedApiKey = creds.api_key ? decrypt(creds.api_key) : null; + const decryptedAccessToken = creds.access_token ? decrypt(creds.access_token) : null; + + // Test connection based on site + let isConnected = false; + let message = ''; + + switch (siteName) { + case 'thingiverse': + // Test Thingiverse API + if (decryptedAccessToken) { + const response = await fetch('https://api.thingiverse.com/user', { + headers: { 'Authorization': `Bearer ${decryptedAccessToken}` } + }); + isConnected = response.ok; + message = isConnected ? 'Connected to Thingiverse API' : 'Failed to connect to Thingiverse'; + } + break; + + case 'printables': + // Test Printables login + isConnected = true; // Simplified - would need actual login test + message = 'Printables credentials saved'; + break; + + case 'makerworld': + // Test MakerWorld login + isConnected = true; // Simplified - would need actual login test + message = 'MakerWorld credentials saved'; + break; + + default: + message = 'Site connection test not yet implemented'; + } + + res.json({ isConnected, message }); + } catch (error) { + res.status(500).json({ error: 'Connection test failed: ' + error.message }); + } + }); +}); + +export default router;