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 () => { + + +
+
+ +

SMTP Test Email

+

Email Configuration Test

+
+ +
+
+ ✅ SMTP Configuration Working! +
+
+ +

Congratulations! This test email confirms that your SMTP email configuration is working correctly. The MonacoUSA Portal email system is now ready to send emails.

+ +
+

📊 Test Configuration Details

+ +
+
Test Time:
+
{{testTime}}
+
+ +
+
SMTP Host:
+
{{smtpHost}}
+
+ +
+
From Address:
+
{{fromAddress}}
+
+ +
+
Email Type:
+
SMTP Configuration Test
+
+
+ +

🎯 What This Means

+ + +

📧 Available Email Types

+

Your portal can now send the following types of emails:

+ + +
+

+ ✨ Success! Your email system is fully configured and operational. + All automated emails from the MonacoUSA Portal will now be delivered successfully. +

+
+ + +
+ + 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 + + + +
+
+ +

Welcome to MonacoUSA

+

Monaco - United States Association

+
+ +
+
+ Dear {{firstName}} {{lastName}}, +
+ +

Thank you for registering to become a member of the MonacoUSA Association! We're excited to welcome you to our community that bridges Monaco and the United States.

+ +
+

🎉 Registration Successful

+

Member ID: {{memberId}}

+

Registration Date: {{registrationDate}}

+

Your membership application has been received and is being processed.

+
+ +

📧 Next Step: Verify Your Email

+

To complete your registration and activate your account, please verify your email address by clicking the button below:

+ +
+ + ✉️ Verify Email Address + +
+ +

This verification link will expire in 24 hours for security purposes.

+ +
+

💳 Membership Dues Payment

+

Once your email is verified, you'll be able to log in to the portal. To activate your membership, please transfer your annual membership dues:

+ +
    +
  • Amount: €50/year
  • +
  • Payment method: Bank transfer (details in your portal)
  • +
  • Status: Your account will be activated once payment is verified
  • +
+ +

You can find complete payment instructions in your member portal after verification.

+
+ +

🌟 What's Next?

+
    +
  1. Verify your email using the button above
  2. +
  3. Log in to your portal at portal.monacousa.org
  4. +
  5. Complete your payment to activate your membership
  6. +
  7. Enjoy member benefits and connect with our community
  8. +
+ +

If you have any questions, please don't hesitate to contact us. We're here to help!

+
+ + +
+ + 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;