From 4ec05e29dc054b1e8bfe086b66d9ad7623e194c7 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 Aug 2025 22:51:14 +0200 Subject: [PATCH] Add email verification system for user registration - Add SMTP configuration UI in admin panel with test functionality - Implement email verification workflow with tokens and templates - Add verification success/expired pages for user feedback - Include nodemailer, handlebars, and JWT dependencies - Create API endpoints for email config, testing, and verification --- components/AdminConfigurationDialog.vue | 261 ++++++++++++- components/PhoneInputWrapper.vue | 26 +- package-lock.json | 356 ++++++++++-------- package.json | 6 + pages/auth/verify-expired.vue | 278 ++++++++++++++ pages/auth/verify-success.vue | 161 ++++++++ pages/signup.vue | 123 +++--- server/api/admin/smtp-config.get.ts | 46 +++ server/api/admin/smtp-config.post.ts | 91 +++++ server/api/admin/test-email.post.ts | 92 +++++ .../api/auth/send-verification-email.post.ts | 137 +++++++ server/api/auth/verify-email.get.ts | 59 +++ .../[id]/create-portal-account.post.ts | 26 +- server/api/registration.post.ts | 24 ++ server/templates/test.hbs | 216 +++++++++++ server/templates/welcome.hbs | 219 +++++++++++ server/utils/admin-config.ts | 73 +++- server/utils/email-tokens.ts | 168 +++++++++ server/utils/email.ts | 356 ++++++++++++++++++ utils/types.ts | 10 + 20 files changed, 2501 insertions(+), 227 deletions(-) create mode 100644 pages/auth/verify-expired.vue create mode 100644 pages/auth/verify-success.vue create mode 100644 server/api/admin/smtp-config.get.ts create mode 100644 server/api/admin/smtp-config.post.ts create mode 100644 server/api/admin/test-email.post.ts create mode 100644 server/api/auth/send-verification-email.post.ts create mode 100644 server/api/auth/verify-email.get.ts create mode 100644 server/templates/test.hbs create mode 100644 server/templates/welcome.hbs create mode 100644 server/utils/email-tokens.ts create mode 100644 server/utils/email.ts diff --git a/components/AdminConfigurationDialog.vue b/components/AdminConfigurationDialog.vue index 300657e..99585b6 100644 --- a/components/AdminConfigurationDialog.vue +++ b/components/AdminConfigurationDialog.vue @@ -35,6 +35,10 @@ mdi-account-plus Registration + + mdi-email + Email + @@ -281,6 +285,177 @@ + + + + + + Configure SMTP settings for sending emails from the portal (registration confirmations, password resets, dues reminders). + + + + + + + + + + + + + + +
+ Enable for secure connection (recommended for production) +
+
+ + + + + + + + + + + + + + + + + + + + + + + + mdi-email-send + Send Test Email + + + + +
+ + + {{ emailTestStatus.success ? 'mdi-check-circle' : 'mdi-alert-circle' }} + + {{ emailTestStatus.message }} + +
+
+ + + +
+ Common SMTP Providers: +
    +
  • Gmail: smtp.gmail.com:587 (use App Password, not regular password)
  • +
  • SendGrid: smtp.sendgrid.net:587
  • +
  • Amazon SES: email-smtp.region.amazonaws.com:587
  • +
  • Mailgun: smtp.mailgun.org:587
  • +
+
+
+
+
+
+
@@ -332,7 +507,7 @@ + + diff --git a/pages/auth/verify-success.vue b/pages/auth/verify-success.vue new file mode 100644 index 0000000..6eea738 --- /dev/null +++ b/pages/auth/verify-success.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/pages/signup.vue b/pages/signup.vue index 29d95a2..974737a 100644 --- a/pages/signup.vue +++ b/pages/signup.vue @@ -422,33 +422,59 @@ function loadRecaptchaScript(siteKey: string) { document.head.appendChild(script); } +// Add page initialization state +const pageReady = ref(false); + // Load configurations on mount onMounted(async () => { + console.log('🚀 Initializing signup page...'); + + // Set a timeout to ensure page shows even if API calls fail + const initTimeout = setTimeout(() => { + if (!pageReady.value) { + console.warn('⚠️ API calls taking too long, showing page with defaults'); + pageReady.value = true; + } + }, 3000); + try { - // Load reCAPTCHA config (public endpoint - no authentication required) - const recaptchaResponse = await $fetch('/api/recaptcha-config') as any; - if (recaptchaResponse?.success && recaptchaResponse?.data?.siteKey) { - recaptchaConfig.value.siteKey = recaptchaResponse.data.siteKey; + // Load reCAPTCHA config with timeout + try { + const recaptchaResponse = await Promise.race([ + $fetch('/api/recaptcha-config'), + new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)) + ]) as any; - // Load reCAPTCHA script dynamically - loadRecaptchaScript(recaptchaConfig.value.siteKey); - - console.log('✅ reCAPTCHA site key loaded successfully'); - } else { - console.warn('❌ reCAPTCHA not configured or failed to load'); + if (recaptchaResponse?.success && recaptchaResponse?.data?.siteKey) { + recaptchaConfig.value.siteKey = recaptchaResponse.data.siteKey; + loadRecaptchaScript(recaptchaConfig.value.siteKey); + console.log('✅ reCAPTCHA site key loaded successfully'); + } + } catch (error) { + console.warn('❌ reCAPTCHA config failed to load:', error); } - // Load registration config (public endpoint - no authentication required) - const registrationResponse = await $fetch('/api/registration-config') as any; - if (registrationResponse?.success) { - registrationConfig.value = registrationResponse.data; - console.log('✅ Registration config loaded successfully'); - } else { - console.warn('❌ Registration config failed to load'); + // Load registration config with timeout + try { + const registrationResponse = await Promise.race([ + $fetch('/api/registration-config'), + new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)) + ]) as any; + + if (registrationResponse?.success) { + registrationConfig.value = registrationResponse.data; + console.log('✅ Registration config loaded successfully'); + } + } catch (error) { + console.warn('❌ Registration config failed to load:', error); } + } catch (error) { console.error('Failed to load configuration:', error); - // Page will still work with default values + } finally { + clearTimeout(initTimeout); + pageReady.value = true; + console.log('✅ Signup page ready'); } }); @@ -456,56 +482,33 @@ onMounted(async () => { + + + + + diff --git a/server/templates/welcome.hbs b/server/templates/welcome.hbs new file mode 100644 index 0000000..a7c6c7a --- /dev/null +++ b/server/templates/welcome.hbs @@ -0,0 +1,219 @@ + + + + + + Welcome to MonacoUSA + + + + + + diff --git a/server/utils/admin-config.ts b/server/utils/admin-config.ts index bf40c8a..b445e12 100644 --- a/server/utils/admin-config.ts +++ b/server/utils/admin-config.ts @@ -2,7 +2,7 @@ import { readFile, writeFile, mkdir, access, constants } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto'; -import type { NocoDBSettings } from '~/utils/types'; +import type { NocoDBSettings, SMTPConfig } from '~/utils/types'; interface AdminConfiguration { nocodb: NocoDBSettings; @@ -15,6 +15,15 @@ interface AdminConfiguration { iban: string; accountHolder: string; }; + smtp?: { + host: string; + port: number; + secure: boolean; + username: string; + password: string; // Will be encrypted + fromAddress: string; + fromName: string; + }; lastUpdated: string; updatedBy: string; } @@ -189,6 +198,9 @@ export async function loadAdminConfig(): Promise { if (config.recaptcha?.secretKey) { config.recaptcha.secretKey = decryptSensitiveData(config.recaptcha.secretKey); } + if (config.smtp?.password) { + config.smtp.password = decryptSensitiveData(config.smtp.password); + } console.log('[admin-config] Configuration loaded from file'); configCache = config; @@ -409,6 +421,65 @@ export function getRegistrationConfig(): { membershipFee: number; iban: string; return config; } +/** + * Save SMTP configuration + */ +export async function saveSMTPConfig(config: SMTPConfig, updatedBy: string): Promise { + try { + await ensureConfigDir(); + await createBackup(); + + const currentConfig = configCache || await loadAdminConfig() || { + nocodb: { url: '', apiKey: '', baseId: '', tables: {} }, + lastUpdated: new Date().toISOString(), + updatedBy: 'system' + }; + + const updatedConfig: AdminConfiguration = { + ...currentConfig, + smtp: { + ...config, + password: encryptSensitiveData(config.password) + }, + lastUpdated: new Date().toISOString(), + updatedBy + }; + + const configJson = JSON.stringify(updatedConfig, null, 2); + await writeFile(CONFIG_FILE, configJson, 'utf-8'); + + // Update cache with unencrypted data + configCache = { + ...updatedConfig, + smtp: { ...config } // Keep original unencrypted data in cache + }; + + console.log('[admin-config] SMTP configuration saved'); + await logConfigChange('SMTP_CONFIG_SAVED', updatedBy, { host: config.host, fromAddress: config.fromAddress }); + + } catch (error) { + console.error('[admin-config] Failed to save SMTP configuration:', error); + await logConfigChange('SMTP_CONFIG_SAVE_FAILED', updatedBy, { error: error instanceof Error ? error.message : String(error) }); + throw error; + } +} + +/** + * Get SMTP configuration + */ +export function getSMTPConfig(): SMTPConfig { + const config = configCache?.smtp || { + host: '', + port: 587, + secure: false, + username: '', + password: '', + fromAddress: '', + fromName: 'MonacoUSA Portal' + }; + return config; +} + /** * Initialize configuration system on server startup */ diff --git a/server/utils/email-tokens.ts b/server/utils/email-tokens.ts new file mode 100644 index 0000000..71fedd7 --- /dev/null +++ b/server/utils/email-tokens.ts @@ -0,0 +1,168 @@ +import { sign, verify } from 'jsonwebtoken'; + +export interface EmailVerificationTokenPayload { + userId: string; + email: string; + purpose: 'email-verification'; + iat: number; +} + +// In-memory token storage for validation (in production, consider Redis) +const activeTokens = new Map(); + +/** + * Generate a secure JWT token for email verification + */ +export async function generateEmailVerificationToken(userId: string, email: string): Promise { + const runtimeConfig = useRuntimeConfig(); + + if (!runtimeConfig.jwtSecret) { + throw new Error('JWT secret not configured'); + } + + const payload: EmailVerificationTokenPayload = { + userId, + email: email.toLowerCase().trim(), + purpose: 'email-verification', + iat: Date.now() + }; + + const token = sign(payload, runtimeConfig.jwtSecret, { + expiresIn: '24h', + issuer: 'monacousa-portal', + audience: 'email-verification' + }); + + // Store token metadata for additional validation + activeTokens.set(token, payload); + + // Clean up expired tokens periodically + setTimeout(() => { + activeTokens.delete(token); + }, 24 * 60 * 60 * 1000); // 24 hours + + console.log('[email-tokens] Generated verification token for user:', userId, 'email:', email); + + return token; +} + +/** + * Verify and decode an email verification token + */ +export async function verifyEmailToken(token: string): Promise<{ userId: string; email: string }> { + const runtimeConfig = useRuntimeConfig(); + + if (!runtimeConfig.jwtSecret) { + throw new Error('JWT secret not configured'); + } + + if (!token) { + throw new Error('Token is required'); + } + + try { + // Verify JWT signature and expiration + const decoded = verify(token, runtimeConfig.jwtSecret, { + issuer: 'monacousa-portal', + audience: 'email-verification' + }) as EmailVerificationTokenPayload; + + // Validate token purpose + if (decoded.purpose !== 'email-verification') { + throw new Error('Invalid token purpose'); + } + + // Check if token exists in our active tokens (prevents replay attacks) + const storedPayload = activeTokens.get(token); + if (!storedPayload) { + throw new Error('Token not found or already used'); + } + + // Validate payload consistency + if (storedPayload.userId !== decoded.userId || storedPayload.email !== decoded.email) { + throw new Error('Token payload mismatch'); + } + + // Remove token after successful verification (single use) + activeTokens.delete(token); + + console.log('[email-tokens] Successfully verified token for user:', decoded.userId, 'email:', decoded.email); + + return { + userId: decoded.userId, + email: decoded.email + }; + + } catch (error: any) { + console.error('[email-tokens] Token verification failed:', error.message); + + // Provide user-friendly error messages + if (error.name === 'TokenExpiredError') { + throw new Error('Verification link has expired. Please request a new one.'); + } else if (error.name === 'JsonWebTokenError') { + throw new Error('Invalid verification link.'); + } else { + throw new Error(error.message || 'Token verification failed'); + } + } +} + +/** + * Check if a token is still valid without consuming it + */ +export async function isTokenValid(token: string): Promise { + try { + const runtimeConfig = useRuntimeConfig(); + + if (!runtimeConfig.jwtSecret || !token) { + return false; + } + + const decoded = verify(token, runtimeConfig.jwtSecret, { + issuer: 'monacousa-portal', + audience: 'email-verification' + }) as EmailVerificationTokenPayload; + + return decoded.purpose === 'email-verification' && activeTokens.has(token); + } catch (error) { + return false; + } +} + +/** + * Clean up expired tokens from memory + */ +export function cleanupExpiredTokens(): void { + const now = Date.now(); + const expirationTime = 24 * 60 * 60 * 1000; // 24 hours + + for (const [token, payload] of activeTokens.entries()) { + if (now - payload.iat > expirationTime) { + activeTokens.delete(token); + } + } + + console.log('[email-tokens] Cleaned up expired tokens. Active tokens:', activeTokens.size); +} + +/** + * Get statistics about active tokens + */ +export function getTokenStats(): { activeTokens: number; oldestToken: number | null } { + const now = Date.now(); + let oldestToken: number | null = null; + + for (const payload of activeTokens.values()) { + if (oldestToken === null || payload.iat < oldestToken) { + oldestToken = payload.iat; + } + } + + return { + activeTokens: activeTokens.size, + oldestToken: oldestToken ? Math.floor((now - oldestToken) / 1000 / 60) : null // minutes ago + }; +} + +// Periodic cleanup of expired tokens (every hour) +setInterval(cleanupExpiredTokens, 60 * 60 * 1000); diff --git a/server/utils/email.ts b/server/utils/email.ts new file mode 100644 index 0000000..55a56a1 --- /dev/null +++ b/server/utils/email.ts @@ -0,0 +1,356 @@ +import nodemailer from 'nodemailer'; +import handlebars from 'handlebars'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import type { SMTPConfig } from '~/utils/types'; + +export interface EmailData { + to: string; + subject: string; + html: string; + text?: string; +} + +export interface WelcomeEmailData { + firstName: string; + lastName: string; + verificationLink: string; + memberId: string; + logoUrl?: string; +} + +export interface VerificationEmailData { + firstName: string; + verificationLink: string; + logoUrl?: string; +} + +export interface PasswordResetEmailData { + firstName: string; + resetLink: string; + logoUrl?: string; +} + +export interface DuesReminderEmailData { + firstName: string; + lastName: string; + amount: number; + dueDate: string; + iban?: string; + accountHolder?: string; + logoUrl?: string; +} + +export class EmailService { + private transporter: nodemailer.Transporter | null = null; + private templates: Map = new Map(); + + constructor(private config: SMTPConfig) { + this.initializeTransporter(); + this.preloadTemplates(); + } + + /** + * Initialize the nodemailer transporter + */ + private initializeTransporter(): void { + if (!this.config.host || !this.config.port) { + console.warn('[EmailService] SMTP configuration incomplete, emails will not be sent'); + return; + } + + try { + this.transporter = nodemailer.createTransport({ + host: this.config.host, + port: this.config.port, + secure: this.config.secure, // true for 465, false for other ports + auth: this.config.username && this.config.password ? { + user: this.config.username, + pass: this.config.password + } : undefined, + tls: { + rejectUnauthorized: false // Accept self-signed certificates in development + } + }); + + console.log('[EmailService] ✅ SMTP transporter initialized'); + } catch (error) { + console.error('[EmailService] ❌ Failed to initialize SMTP transporter:', error); + } + } + + /** + * Preload and compile email templates + */ + private preloadTemplates(): void { + const templateNames = ['welcome', 'verification', 'password-reset', 'dues-reminder', 'test']; + + templateNames.forEach(templateName => { + try { + const templatePath = join(process.cwd(), 'server/templates', `${templateName}.hbs`); + const templateContent = readFileSync(templatePath, 'utf-8'); + const compiledTemplate = handlebars.compile(templateContent); + this.templates.set(templateName, compiledTemplate); + console.log(`[EmailService] ✅ Template '${templateName}' loaded`); + } catch (error) { + console.warn(`[EmailService] ⚠️ Template '${templateName}' not found or failed to load:`, error); + } + }); + } + + /** + * Get a compiled template by name + */ + private getTemplate(templateName: string): handlebars.TemplateDelegate | null { + return this.templates.get(templateName) || null; + } + + /** + * Send a generic email + */ + private async sendEmail(emailData: EmailData): Promise { + if (!this.transporter) { + throw new Error('SMTP transporter not initialized'); + } + + const mailOptions = { + from: `${this.config.fromName} <${this.config.fromAddress}>`, + to: emailData.to, + subject: emailData.subject, + html: emailData.html, + text: emailData.text || undefined + }; + + try { + const info = await this.transporter.sendMail(mailOptions); + console.log(`[EmailService] ✅ Email sent successfully to ${emailData.to}:`, info.messageId); + } catch (error) { + console.error(`[EmailService] ❌ Failed to send email to ${emailData.to}:`, error); + throw error; + } + } + + /** + * Send welcome/verification email to new members + */ + async sendWelcomeEmail(to: string, data: WelcomeEmailData): Promise { + const template = this.getTemplate('welcome'); + if (!template) { + throw new Error('Welcome email template not found'); + } + + const templateData = { + ...data, + logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png` + }; + + const html = template(templateData); + + await this.sendEmail({ + to, + subject: 'Welcome to MonacoUSA - Please Verify Your Email', + html + }); + + console.log(`[EmailService] ✅ Welcome email sent to ${to}`); + } + + /** + * Send email verification email + */ + async sendVerificationEmail(to: string, data: VerificationEmailData): Promise { + const template = this.getTemplate('verification'); + if (!template) { + throw new Error('Verification email template not found'); + } + + const templateData = { + ...data, + logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png` + }; + + const html = template(templateData); + + await this.sendEmail({ + to, + subject: 'Verify Your Email - MonacoUSA Portal', + html + }); + + console.log(`[EmailService] ✅ Verification email sent to ${to}`); + } + + /** + * Send password reset email + */ + async sendPasswordResetEmail(to: string, data: PasswordResetEmailData): Promise { + const template = this.getTemplate('password-reset'); + if (!template) { + throw new Error('Password reset email template not found'); + } + + const templateData = { + ...data, + logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png` + }; + + const html = template(templateData); + + await this.sendEmail({ + to, + subject: 'Reset Your Password - MonacoUSA Portal', + html + }); + + console.log(`[EmailService] ✅ Password reset email sent to ${to}`); + } + + /** + * Send membership dues reminder email + */ + async sendDuesReminderEmail(to: string, data: DuesReminderEmailData): Promise { + const template = this.getTemplate('dues-reminder'); + if (!template) { + throw new Error('Dues reminder email template not found'); + } + + const templateData = { + ...data, + logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png` + }; + + const html = template(templateData); + + await this.sendEmail({ + to, + subject: `MonacoUSA Membership Dues Reminder - €${data.amount} Due`, + html + }); + + console.log(`[EmailService] ✅ Dues reminder email sent to ${to}`); + } + + /** + * Send test email to verify SMTP configuration + */ + async sendTestEmail(to: string): Promise { + const template = this.getTemplate('test'); + + const templateData = { + testTime: new Date().toISOString(), + logoUrl: `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`, + smtpHost: this.config.host, + fromAddress: this.config.fromAddress + }; + + let html: string; + if (template) { + html = template(templateData); + } else { + // Fallback HTML if template is not available + html = ` + + + SMTP Test Email + +
+ MonacoUSA +

SMTP Test Email

+
+ +

This is a test email from the MonacoUSA Portal email system.

+ +
+

Test Details:

+
    +
  • Sent at: ${templateData.testTime}
  • +
  • SMTP Host: ${templateData.smtpHost}
  • +
  • From Address: ${templateData.fromAddress}
  • +
+
+ +

If you received this email, your SMTP configuration is working correctly!

+ +
+

+ MonacoUSA Portal Email System +

+ + + `; + } + + await this.sendEmail({ + to, + subject: 'SMTP Configuration Test - MonacoUSA Portal', + html, + text: `This is a test email from MonacoUSA Portal. Sent at: ${templateData.testTime}` + }); + + console.log(`[EmailService] ✅ Test email sent to ${to}`); + } + + /** + * Verify SMTP connection + */ + async verifyConnection(): Promise { + if (!this.transporter) { + throw new Error('SMTP transporter not initialized'); + } + + try { + await this.transporter.verify(); + console.log('[EmailService] ✅ SMTP connection verified'); + return true; + } catch (error) { + console.error('[EmailService] ❌ SMTP connection verification failed:', error); + return false; + } + } + + /** + * Update SMTP configuration and reinitialize transporter + */ + updateConfig(newConfig: SMTPConfig): void { + this.config = newConfig; + this.initializeTransporter(); + } + + /** + * Close the transporter connection + */ + close(): void { + if (this.transporter) { + this.transporter.close(); + this.transporter = null; + console.log('[EmailService] SMTP connection closed'); + } + } +} + +// Singleton instance for reuse across the application +let emailServiceInstance: EmailService | null = null; + +/** + * Get or create EmailService instance with current SMTP config + */ +export function getEmailService(): EmailService { + const { getSMTPConfig } = require('./admin-config'); + const config = getSMTPConfig(); + + if (!emailServiceInstance) { + emailServiceInstance = new EmailService(config); + } else { + // Update config in case it changed + emailServiceInstance.updateConfig(config); + } + + return emailServiceInstance; +} + +/** + * Create a new EmailService instance with custom config + */ +export function createEmailService(config: SMTPConfig): EmailService { + return new EmailService(config); +} diff --git a/utils/types.ts b/utils/types.ts index c2bbc51..9c3bb3b 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -189,6 +189,16 @@ export interface RegistrationConfig { accountHolder: string; } +export interface SMTPConfig { + host: string; + port: number; + secure: boolean; + username: string; + password: string; + fromAddress: string; + fromName: string; +} + // Enhanced Keycloak Admin API Types export interface KeycloakUserRepresentation { id?: string;