diff --git a/map/app/Dockerfile b/map/app/Dockerfile
index 95c6c6c..0b7361b 100644
--- a/map/app/Dockerfile
+++ b/map/app/Dockerfile
@@ -30,6 +30,7 @@ COPY config ./config
COPY controllers ./controllers
COPY middleware ./middleware
COPY utils ./utils
+COPY templates ./templates
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
diff --git a/map/app/controllers/passwordRecoveryController.js b/map/app/controllers/passwordRecoveryController.js
new file mode 100644
index 0000000..de83da1
--- /dev/null
+++ b/map/app/controllers/passwordRecoveryController.js
@@ -0,0 +1,60 @@
+const nocodbService = require('../services/nocodb');
+const { sendPasswordRecovery } = require('../services/email');
+const logger = require('../utils/logger');
+
+class PasswordRecoveryController {
+ async requestPassword(req, res) {
+ try {
+ const { email } = req.body;
+
+ if (!email) {
+ return res.status(400).json({
+ success: false,
+ error: 'Email address is required'
+ });
+ }
+
+ // Validate email format
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ return res.status(400).json({
+ success: false,
+ error: 'Invalid email format'
+ });
+ }
+
+ logger.info(`Password recovery requested for: ${email}`);
+
+ // Find user in database
+ const user = await nocodbService.getUserByEmail(email);
+
+ if (!user) {
+ // Don't reveal whether the email exists or not for security
+ logger.warn(`Password recovery attempted for non-existent email: ${email}`);
+ return res.json({
+ success: true,
+ message: 'If an account exists with this email, you will receive your password shortly.'
+ });
+ }
+
+ // Send password email
+ await sendPasswordRecovery(user);
+
+ logger.info(`Password recovery email sent to: ${email}`);
+
+ res.json({
+ success: true,
+ message: 'If an account exists with this email, you will receive your password shortly.'
+ });
+
+ } catch (error) {
+ logger.error('Password recovery error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to process password recovery request. Please try again later.'
+ });
+ }
+ }
+}
+
+module.exports = new PasswordRecoveryController();
diff --git a/map/app/package-lock.json b/map/app/package-lock.json
index 4e93318..1a18f5f 100644
--- a/map/app/package-lock.json
+++ b/map/app/package-lock.json
@@ -20,6 +20,7 @@
"helmet": "^7.1.0",
"multer": "^1.4.5-lts.1",
"node-fetch": "^2.7.0",
+ "nodemailer": "^7.0.5",
"qrcode": "^1.5.3",
"winston": "^3.11.0"
},
@@ -725,16 +726,15 @@
}
},
"node_modules/express-session": {
- "version": "1.18.1",
- "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz",
- "integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==",
- "license": "MIT",
+ "version": "1.18.2",
+ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
+ "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.7",
"debug": "2.6.9",
"depd": "~2.0.0",
- "on-headers": "~1.0.2",
+ "on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.1",
"uid-safe": "~2.1.5"
@@ -834,10 +834,9 @@
}
},
"node_modules/form-data": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
- "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
- "license": "MIT",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@@ -1339,6 +1338,14 @@
}
}
},
+ "node_modules/nodemailer": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz",
+ "integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/nodemon": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
@@ -1437,10 +1444,9 @@
}
},
"node_modules/on-headers": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
- "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
- "license": "MIT",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"engines": {
"node": ">= 0.8"
}
diff --git a/map/app/package.json b/map/app/package.json
index 6df1457..88835dc 100644
--- a/map/app/package.json
+++ b/map/app/package.json
@@ -29,6 +29,7 @@
"helmet": "^7.1.0",
"multer": "^1.4.5-lts.1",
"node-fetch": "^2.7.0",
+ "nodemailer": "^7.0.5",
"qrcode": "^1.5.3",
"winston": "^3.11.0"
},
diff --git a/map/app/public/login.html b/map/app/public/login.html
index 052fea7..d0cac79 100644
--- a/map/app/public/login.html
+++ b/map/app/public/login.html
@@ -136,6 +136,21 @@
color: var(--secondary-color);
font-size: 14px;
}
+
+ .password-recovery {
+ text-align: center;
+ margin-top: 15px;
+ }
+
+ .password-recovery a {
+ color: var(--primary-color);
+ text-decoration: none;
+ font-size: 14px;
+ }
+
+ .password-recovery a:hover {
+ text-decoration: underline;
+ }
@@ -180,6 +195,10 @@
+
+
@@ -262,6 +281,45 @@
}
})
.catch(console.error);
+
+ // Add password recovery handler
+ document.getElementById('forgot-password-link').addEventListener('click', async (e) => {
+ e.preventDefault();
+
+ const email = prompt('Please enter your email address:');
+ if (!email) return;
+
+ const errorMessage = document.getElementById('error-message');
+ const successMessage = document.getElementById('success-message');
+
+ // Clear previous messages
+ errorMessage.classList.remove('show');
+ successMessage.classList.remove('show');
+
+ try {
+ const response = await fetch('/api/auth/recover-password', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ email })
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ successMessage.textContent = data.message;
+ successMessage.classList.add('show');
+ } else {
+ errorMessage.textContent = data.error || 'Failed to process request';
+ errorMessage.classList.add('show');
+ }
+ } catch (error) {
+ console.error('Password recovery error:', error);
+ errorMessage.textContent = 'Failed to send password recovery email';
+ errorMessage.classList.add('show');
+ }
+ });
diff --git a/map/app/routes/auth.js b/map/app/routes/auth.js
index 9e6e603..f041300 100644
--- a/map/app/routes/auth.js
+++ b/map/app/routes/auth.js
@@ -1,11 +1,15 @@
const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
+const passwordRecoveryController = require('../controllers/passwordRecoveryController');
const { authLimiter } = require('../middleware/rateLimiter');
// Login route with rate limiting
router.post('/login', authLimiter, authController.login);
+// Password recovery route with rate limiting
+router.post('/recover-password', authLimiter, passwordRecoveryController.requestPassword);
+
// Logout route
router.post('/logout', authController.logout);
diff --git a/map/app/routes/debug.js b/map/app/routes/debug.js
index 3276851..01fa43f 100644
--- a/map/app/routes/debug.js
+++ b/map/app/routes/debug.js
@@ -261,4 +261,22 @@ router.get('/login-structure', async (req, res) => {
}
});
+// Reload email templates (development only)
+router.get('/reload-email-templates', (req, res) => {
+ try {
+ const emailTemplates = require('../services/emailTemplates');
+ emailTemplates.clearCache();
+ res.json({
+ success: true,
+ message: 'Email template cache cleared'
+ });
+ } catch (error) {
+ logger.error('Error clearing email template cache:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to clear email template cache'
+ });
+ }
+});
+
module.exports = router;
\ No newline at end of file
diff --git a/map/app/server.js b/map/app/server.js
index 5aad159..4dbb72b 100644
--- a/map/app/server.js
+++ b/map/app/server.js
@@ -12,6 +12,7 @@ const logger = require('./utils/logger');
const { getCookieConfig } = require('./utils/helpers');
const { apiLimiter } = require('./middleware/rateLimiter');
const { cacheBusting } = require('./utils/cacheBusting');
+const { initializeEmailService } = require('./services/email');
// Initialize Express app
const app = express();
@@ -142,6 +143,9 @@ app.get('/api/docs-search', async (req, res) => {
}
});
+// Initialize email service
+initializeEmailService();
+
// Import and setup routes
require('./routes')(app);
diff --git a/map/app/services/email.js b/map/app/services/email.js
new file mode 100644
index 0000000..7fcc455
--- /dev/null
+++ b/map/app/services/email.js
@@ -0,0 +1,99 @@
+const nodemailer = require('nodemailer');
+const logger = require('../utils/logger');
+const emailTemplates = require('./emailTemplates');
+const config = require('../config');
+
+// Create reusable transporter
+let transporter;
+
+const initializeEmailService = () => {
+ const emailConfig = {
+ host: process.env.SMTP_HOST,
+ port: parseInt(process.env.SMTP_PORT),
+ secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports
+ auth: {
+ user: process.env.SMTP_USER,
+ pass: process.env.SMTP_PASS
+ }
+ };
+
+ // Additional settings for specific providers
+ if (process.env.SMTP_SERVICE) {
+ emailConfig.service = process.env.SMTP_SERVICE; // 'gmail' for example
+ }
+
+ // Handle TLS for port 587
+ if (emailConfig.port === 587) {
+ emailConfig.secure = false;
+ emailConfig.requireTLS = true;
+ }
+
+ transporter = nodemailer.createTransport(emailConfig);
+
+ // Verify connection
+ transporter.verify((error, success) => {
+ if (error) {
+ logger.error('SMTP connection failed:', error);
+ } else {
+ logger.info('SMTP server ready to send emails');
+ }
+ });
+};
+
+const sendEmail = async ({ to, subject, text, html }) => {
+ if (!transporter) {
+ throw new Error('Email service not initialized');
+ }
+
+ const mailOptions = {
+ from: `${process.env.EMAIL_FROM_NAME} <${process.env.EMAIL_FROM_ADDRESS}>`,
+ to,
+ subject,
+ text,
+ html
+ };
+
+ try {
+ const info = await transporter.sendMail(mailOptions);
+ logger.info(`Email sent: ${info.messageId}`);
+ return info;
+ } catch (error) {
+ logger.error('Failed to send email:', error);
+ throw error;
+ }
+};
+
+const sendPasswordRecovery = async (user) => {
+ try {
+ const baseUrl = config.isProduction ?
+ `https://map.${config.domain}` :
+ `http://localhost:${config.port}`;
+
+ const variables = {
+ APP_NAME: process.env.APP_NAME || 'CMlite Map',
+ USER_NAME: user.Name || user.name || user.Email || user.email,
+ USER_EMAIL: user.Email || user.email,
+ PASSWORD: user.Password || user.password,
+ LOGIN_URL: `${baseUrl}/login.html`,
+ TIMESTAMP: new Date().toLocaleString()
+ };
+
+ const { html, text } = await emailTemplates.render('password-recovery', variables);
+
+ return await sendEmail({
+ to: user.Email || user.email,
+ subject: `Password Recovery - ${variables.APP_NAME}`,
+ text,
+ html
+ });
+ } catch (error) {
+ logger.error('Failed to send password recovery email:', error);
+ throw error;
+ }
+};
+
+module.exports = {
+ initializeEmailService,
+ sendEmail,
+ sendPasswordRecovery
+};
\ No newline at end of file
diff --git a/map/app/services/emailTemplates.js b/map/app/services/emailTemplates.js
new file mode 100644
index 0000000..cd140f0
--- /dev/null
+++ b/map/app/services/emailTemplates.js
@@ -0,0 +1,69 @@
+const fs = require('fs').promises;
+const path = require('path');
+const logger = require('../utils/logger');
+
+class EmailTemplateService {
+ constructor() {
+ this.templatesDir = path.join(__dirname, '../templates/email');
+ this.cache = new Map();
+ }
+
+ async loadTemplate(templateName, type = 'html') {
+ const cacheKey = `${templateName}.${type}`;
+
+ // Check cache first
+ if (this.cache.has(cacheKey)) {
+ return this.cache.get(cacheKey);
+ }
+
+ try {
+ const templatePath = path.join(this.templatesDir, `${templateName}.${type}`);
+ const template = await fs.readFile(templatePath, 'utf-8');
+
+ // Cache the template
+ this.cache.set(cacheKey, template);
+
+ return template;
+ } catch (error) {
+ logger.error(`Failed to load email template ${templateName}.${type}:`, error);
+ throw new Error(`Email template not found: ${templateName}.${type}`);
+ }
+ }
+
+ renderTemplate(template, variables) {
+ let rendered = template;
+
+ // Replace all {{VARIABLE}} with actual values
+ Object.entries(variables).forEach(([key, value]) => {
+ const regex = new RegExp(`{{${key}}}`, 'g');
+ rendered = rendered.replace(regex, value || '');
+ });
+
+ return rendered;
+ }
+
+ async render(templateName, variables) {
+ try {
+ // Load both HTML and text versions
+ const [htmlTemplate, textTemplate] = await Promise.all([
+ this.loadTemplate(templateName, 'html'),
+ this.loadTemplate(templateName, 'txt')
+ ]);
+
+ return {
+ html: this.renderTemplate(htmlTemplate, variables),
+ text: this.renderTemplate(textTemplate, variables)
+ };
+ } catch (error) {
+ logger.error('Failed to render email template:', error);
+ throw error;
+ }
+ }
+
+ // Clear template cache (useful for development)
+ clearCache() {
+ this.cache.clear();
+ }
+}
+
+module.exports = new EmailTemplateService();
diff --git a/map/app/templates/email/password-recovery.html b/map/app/templates/email/password-recovery.html
new file mode 100644
index 0000000..81cbb68
--- /dev/null
+++ b/map/app/templates/email/password-recovery.html
@@ -0,0 +1,79 @@
+
+
+
+
+ Your Password
+
+
+
+
+
+
+
Password Recovery
+
Hello {{USER_NAME}},
+
You requested your password for the account associated with {{USER_EMAIL}}.
+
Your password is:
+
{{PASSWORD}}
+
You can use this password to log in at: {{LOGIN_URL}}
+
⚠️ For security reasons, we recommend changing your password after logging in.
+
+
+
+
+
diff --git a/map/app/templates/email/password-recovery.txt b/map/app/templates/email/password-recovery.txt
new file mode 100644
index 0000000..f34701e
--- /dev/null
+++ b/map/app/templates/email/password-recovery.txt
@@ -0,0 +1,15 @@
+Password Recovery - {{APP_NAME}}
+
+Hello {{USER_NAME}},
+
+You requested your password for the account associated with {{USER_EMAIL}}.
+
+Your password is: {{PASSWORD}}
+
+You can use this password to log in at: {{LOGIN_URL}}
+
+WARNING: For security reasons, we recommend changing your password after logging in.
+
+---
+This email was sent from {{APP_NAME}} at {{TIMESTAMP}}
+If you didn't request this password, please contact your administrator immediately.