Add Connected Accounts feature for storing website credentials
This commit is contained in:
parent
2177e79566
commit
b0ccbab97a
5 changed files with 418 additions and 0 deletions
146
client/app.js
146
client/app.js
|
|
@ -570,6 +570,8 @@ function showSettingsModal() {
|
||||||
// Reset forms
|
// Reset forms
|
||||||
document.getElementById('emailTab').querySelector('form').reset();
|
document.getElementById('emailTab').querySelector('form').reset();
|
||||||
document.getElementById('passwordTab').querySelector('form').reset();
|
document.getElementById('passwordTab').querySelector('form').reset();
|
||||||
|
// Load connected accounts
|
||||||
|
loadConnectedAccounts();
|
||||||
// Show email tab by default
|
// Show email tab by default
|
||||||
switchSettingsTab('email');
|
switchSettingsTab('email');
|
||||||
showModal('settingsModal');
|
showModal('settingsModal');
|
||||||
|
|
@ -643,6 +645,150 @@ function updatePassword(event) {
|
||||||
.catch(err => showNotification('Error updating password', 'error'));
|
.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 = '<option value="">Select a website...</option>' +
|
||||||
|
availableSites.map(site => `<option value="${site.id}">${site.name}</option>`).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 `
|
||||||
|
<div style="border: 1px solid var(--border-color); padding: 1rem; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<h4 style="margin: 0 0 0.5rem 0;">${account.site_name}</h4>
|
||||||
|
<p style="margin: 0; font-size: 0.9rem; color: var(--text-secondary);">Username: ${account.username}</p>
|
||||||
|
<p style="margin: 0.25rem 0 0 0; font-size: 0.85rem; color: ${statusColor};">
|
||||||
|
${statusText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="removeConnectedAccount('${account.site_name}')">Remove</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} else {
|
||||||
|
accountsList.innerHTML = '<p style="color: var(--text-secondary); text-align: center;">No connected accounts yet</p>';
|
||||||
|
}
|
||||||
|
} 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
|
// Close modals when clicking outside
|
||||||
window.onclick = function(event) {
|
window.onclick = function(event) {
|
||||||
if (event.target.classList.contains('modal')) {
|
if (event.target.classList.contains('modal')) {
|
||||||
|
|
|
||||||
|
|
@ -654,6 +654,7 @@
|
||||||
<div class="settings-tabs">
|
<div class="settings-tabs">
|
||||||
<button class="tab-button active" onclick="switchSettingsTab('email')">Change Email</button>
|
<button class="tab-button active" onclick="switchSettingsTab('email')">Change Email</button>
|
||||||
<button class="tab-button" onclick="switchSettingsTab('password')">Change Password</button>
|
<button class="tab-button" onclick="switchSettingsTab('password')">Change Password</button>
|
||||||
|
<button class="tab-button" onclick="switchSettingsTab('accounts')">Connected Accounts</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Change Email Tab -->
|
<!-- Change Email Tab -->
|
||||||
|
|
@ -693,6 +694,46 @@
|
||||||
<button type="submit" class="btn btn-primary">Update Password</button>
|
<button type="submit" class="btn btn-primary">Update Password</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Connected Accounts Tab -->
|
||||||
|
<div id="accountsTab" class="tab-content">
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 1rem;">Connect your accounts from 3D model websites to automatically download models from URLs.</p>
|
||||||
|
|
||||||
|
<div id="connectedAccountsList" style="display: grid; gap: 1rem; margin-bottom: 2rem;">
|
||||||
|
<!-- Connected accounts will be loaded here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-top: 1px solid var(--border-color); padding-top: 1rem;">
|
||||||
|
<h3 style="margin-bottom: 1rem;">Add New Account</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Website</label>
|
||||||
|
<select id="accountSiteSelect" onchange="updateAccountForm()">
|
||||||
|
<option value="">Select a website...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="accountFormFields" style="display: none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username</label>
|
||||||
|
<input type="text" id="accountUsername" placeholder="Your username">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" id="accountPassword" placeholder="Your password (encrypted)">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API Key <span style="font-size: 0.8rem; color: var(--text-secondary);">(optional)</span></label>
|
||||||
|
<input type="text" id="accountApiKey" placeholder="API key if available">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Access Token <span style="font-size: 0.8rem; color: var(--text-secondary);">(optional)</span></label>
|
||||||
|
<input type="text" id="accountAccessToken" placeholder="Access token if available">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="addConnectedAccount()">Connect Account</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="testAccountConnection()" style="margin-left: 0.5rem;">Test Connection</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Migration: Add is_primary column to model_files if it doesn't exist
|
||||||
db.all("PRAGMA table_info(model_files)", [], (err, columns) => {
|
db.all("PRAGMA table_info(model_files)", [], (err, columns) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import printQueueRoutes from './routes/printQueue.js';
|
||||||
import exportRoutes from './routes/export.js';
|
import exportRoutes from './routes/export.js';
|
||||||
import importRoutes from './routes/import.js';
|
import importRoutes from './routes/import.js';
|
||||||
import printersRoutes from './routes/printers.js';
|
import printersRoutes from './routes/printers.js';
|
||||||
|
import credentialsRoutes from './routes/credentials.js';
|
||||||
|
|
||||||
// Import database to initialize
|
// Import database to initialize
|
||||||
import './database.js';
|
import './database.js';
|
||||||
|
|
@ -48,6 +49,7 @@ app.use('/api/print-queue', printQueueRoutes);
|
||||||
app.use('/api/export', exportRoutes);
|
app.use('/api/export', exportRoutes);
|
||||||
app.use('/api/import', importRoutes);
|
app.use('/api/import', importRoutes);
|
||||||
app.use('/api/printers', printersRoutes);
|
app.use('/api/printers', printersRoutes);
|
||||||
|
app.use('/api/credentials', credentialsRoutes);
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
|
|
|
||||||
212
server/routes/credentials.js
Normal file
212
server/routes/credentials.js
Normal file
|
|
@ -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;
|
||||||
Loading…
Reference in a new issue