Add email verification system for user registration
All checks were successful
Build And Push Image / docker (push) Successful in 3m1s
All checks were successful
Build And Push Image / docker (push) Successful in 3m1s
- 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
This commit is contained in:
46
server/api/admin/smtp-config.get.ts
Normal file
46
server/api/admin/smtp-config.get.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/admin/smtp-config.get] =========================');
|
||||
console.log('[api/admin/smtp-config.get] GET /api/admin/smtp-config - Get SMTP configuration');
|
||||
|
||||
try {
|
||||
// Validate session and require admin privileges
|
||||
const sessionManager = createSessionManager();
|
||||
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
|
||||
const session = sessionManager.getSession(cookieHeader);
|
||||
|
||||
if (!session?.user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
if (session.user.tier !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Admin privileges required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/admin/smtp-config.get] Authorized admin:', session.user.email);
|
||||
|
||||
// Get SMTP configuration
|
||||
const { getSMTPConfig } = await import('~/server/utils/admin-config');
|
||||
const config = getSMTPConfig();
|
||||
|
||||
// Hide password for security
|
||||
const safeConfig = {
|
||||
...config,
|
||||
password: config.password ? '••••••••••••••••' : ''
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: safeConfig
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/admin/smtp-config.get] ❌ Error getting SMTP config:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
91
server/api/admin/smtp-config.post.ts
Normal file
91
server/api/admin/smtp-config.post.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/admin/smtp-config.post] =========================');
|
||||
console.log('[api/admin/smtp-config.post] POST /api/admin/smtp-config - Save SMTP configuration');
|
||||
|
||||
try {
|
||||
// Validate session and require admin privileges
|
||||
const sessionManager = createSessionManager();
|
||||
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
|
||||
const session = sessionManager.getSession(cookieHeader);
|
||||
|
||||
if (!session?.user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
if (session.user.tier !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Admin privileges required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/admin/smtp-config.post] Authorized admin:', session.user.email);
|
||||
|
||||
// Parse request body
|
||||
const body = await readBody(event);
|
||||
console.log('[api/admin/smtp-config.post] Request body:', {
|
||||
...body,
|
||||
password: body.password ? '••••••••••••••••' : ''
|
||||
});
|
||||
|
||||
// Validate required fields
|
||||
if (!body.host || !body.port || !body.fromAddress || !body.fromName) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Missing required SMTP configuration fields'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(body.fromAddress)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid from address email format'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate port is a number
|
||||
const port = parseInt(body.port, 10);
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Port must be a valid number between 1 and 65535'
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare configuration object
|
||||
const smtpConfig = {
|
||||
host: body.host.trim(),
|
||||
port: port,
|
||||
secure: Boolean(body.secure),
|
||||
username: body.username?.trim() || '',
|
||||
password: body.password?.trim() || '',
|
||||
fromAddress: body.fromAddress.trim(),
|
||||
fromName: body.fromName.trim()
|
||||
};
|
||||
|
||||
console.log('[api/admin/smtp-config.post] Saving SMTP config:', {
|
||||
...smtpConfig,
|
||||
password: smtpConfig.password ? '••••••••••••••••' : ''
|
||||
});
|
||||
|
||||
// Save SMTP configuration
|
||||
const { saveSMTPConfig } = await import('~/server/utils/admin-config');
|
||||
await saveSMTPConfig(smtpConfig, session.user.email);
|
||||
|
||||
console.log('[api/admin/smtp-config.post] ✅ SMTP configuration saved successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'SMTP configuration saved successfully'
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/admin/smtp-config.post] ❌ Error saving SMTP config:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
92
server/api/admin/test-email.post.ts
Normal file
92
server/api/admin/test-email.post.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/admin/test-email.post] =========================');
|
||||
console.log('[api/admin/test-email.post] POST /api/admin/test-email - Send test email');
|
||||
|
||||
try {
|
||||
// Validate session and require admin privileges
|
||||
const sessionManager = createSessionManager();
|
||||
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
|
||||
const session = sessionManager.getSession(cookieHeader);
|
||||
|
||||
if (!session?.user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
if (session.user.tier !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Admin privileges required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/admin/test-email.post] Authorized admin:', session.user.email);
|
||||
|
||||
// Parse request body
|
||||
const body = await readBody(event);
|
||||
console.log('[api/admin/test-email.post] Request body:', body);
|
||||
|
||||
// Validate required fields
|
||||
if (!body.testEmail) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Test email address is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(body.testEmail)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid email address format'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/admin/test-email.post] Sending test email to:', body.testEmail);
|
||||
|
||||
// Get email service and send test email
|
||||
const { getEmailService } = await import('~/server/utils/email');
|
||||
const emailService = getEmailService();
|
||||
|
||||
// Verify connection first
|
||||
const connectionOk = await emailService.verifyConnection();
|
||||
if (!connectionOk) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'SMTP connection verification failed. Please check your SMTP configuration.'
|
||||
});
|
||||
}
|
||||
|
||||
// Send test email
|
||||
await emailService.sendTestEmail(body.testEmail);
|
||||
|
||||
console.log('[api/admin/test-email.post] ✅ Test email sent successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Test email sent successfully to ${body.testEmail}`
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/admin/test-email.post] ❌ Error sending test email:', error);
|
||||
|
||||
// Provide more specific error messages for common SMTP issues
|
||||
let errorMessage = error.message || 'Failed to send test email';
|
||||
|
||||
if (error.code === 'EAUTH') {
|
||||
errorMessage = 'SMTP authentication failed. Please check your username and password.';
|
||||
} else if (error.code === 'ECONNECTION' || error.code === 'ETIMEDOUT') {
|
||||
errorMessage = 'Could not connect to SMTP server. Please check your host and port settings.';
|
||||
} else if (error.code === 'ESOCKET') {
|
||||
errorMessage = 'Socket error. Please check your network connection and SMTP settings.';
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
statusMessage: errorMessage
|
||||
});
|
||||
}
|
||||
});
|
||||
137
server/api/auth/send-verification-email.post.ts
Normal file
137
server/api/auth/send-verification-email.post.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const body = await readBody(event);
|
||||
const { email } = body;
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Email is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid email format'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[send-verification-email] Processing request for email:', email);
|
||||
|
||||
// Check if user exists in Keycloak
|
||||
const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin');
|
||||
const keycloak = createKeycloakAdminClient();
|
||||
|
||||
let existingUsers;
|
||||
try {
|
||||
existingUsers = await keycloak.findUserByEmail(email.toLowerCase().trim());
|
||||
} catch (error: any) {
|
||||
console.error('[send-verification-email] Failed to search users:', error.message);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to verify account status'
|
||||
});
|
||||
}
|
||||
|
||||
if (!existingUsers || existingUsers.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'No account found with this email address'
|
||||
});
|
||||
}
|
||||
|
||||
const user = existingUsers[0];
|
||||
|
||||
// Check if user is already verified
|
||||
if (user.emailVerified) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'This email address is already verified'
|
||||
});
|
||||
}
|
||||
|
||||
// Rate limiting: check if we recently sent an email to this address
|
||||
const rateLimitKey = `verification_email_${email.toLowerCase()}`;
|
||||
|
||||
// Simple in-memory rate limiting (in production, use Redis)
|
||||
const globalCache = globalThis as any;
|
||||
if (!globalCache.verificationEmailCache) {
|
||||
globalCache.verificationEmailCache = new Map();
|
||||
}
|
||||
|
||||
const lastSent = globalCache.verificationEmailCache.get(rateLimitKey);
|
||||
const cooldownPeriod = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
if (lastSent && Date.now() - lastSent < cooldownPeriod) {
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
statusMessage: 'Please wait a few minutes before requesting another verification email'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate verification token
|
||||
const { generateEmailVerificationToken } = await import('~/server/utils/email-tokens');
|
||||
const verificationToken = await generateEmailVerificationToken(user.id, email);
|
||||
|
||||
// Get configuration
|
||||
const config = useRuntimeConfig();
|
||||
const verificationLink = `${config.public.domain}/api/auth/verify-email?token=${verificationToken}`;
|
||||
|
||||
// Send verification email
|
||||
const { getEmailService } = await import('~/server/utils/email');
|
||||
const emailService = getEmailService();
|
||||
|
||||
try {
|
||||
await emailService.sendWelcomeEmail(email, {
|
||||
firstName: user.firstName || '',
|
||||
lastName: user.lastName || '',
|
||||
verificationLink,
|
||||
memberId: user.id
|
||||
});
|
||||
|
||||
console.log('[send-verification-email] Successfully sent verification email to:', email);
|
||||
|
||||
// Update rate limiting cache
|
||||
globalCache.verificationEmailCache.set(rateLimitKey, Date.now());
|
||||
|
||||
// Clean up old rate limit entries periodically
|
||||
if (Math.random() < 0.1) { // 10% chance
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of globalCache.verificationEmailCache.entries()) {
|
||||
if (now - timestamp > cooldownPeriod * 2) {
|
||||
globalCache.verificationEmailCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Verification email sent successfully'
|
||||
};
|
||||
|
||||
} catch (emailError: any) {
|
||||
console.error('[send-verification-email] Failed to send email:', emailError.message);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to send verification email. Please try again later.'
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[send-verification-email] Request failed:', error.message);
|
||||
|
||||
// Re-throw HTTP errors
|
||||
if (error.statusCode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle unexpected errors
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'An unexpected error occurred. Please try again.'
|
||||
});
|
||||
}
|
||||
});
|
||||
59
server/api/auth/verify-email.get.ts
Normal file
59
server/api/auth/verify-email.get.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const { token } = getQuery(event);
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Verification token is required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[verify-email] Processing verification token...');
|
||||
|
||||
// Verify the token
|
||||
const { verifyEmailToken } = await import('~/server/utils/email-tokens');
|
||||
const { userId, email } = await verifyEmailToken(token);
|
||||
|
||||
// Update user verification status in Keycloak
|
||||
const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin');
|
||||
const keycloak = createKeycloakAdminClient();
|
||||
|
||||
try {
|
||||
await keycloak.updateUserProfile(userId, {
|
||||
emailVerified: true,
|
||||
attributes: {
|
||||
lastLoginDate: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[verify-email] Successfully verified user:', userId, 'email:', email);
|
||||
|
||||
// Redirect to success page
|
||||
return sendRedirect(event, '/auth/verify-success?email=' + encodeURIComponent(email), 302);
|
||||
|
||||
} catch (keycloakError: any) {
|
||||
console.error('[verify-email] Keycloak update failed:', keycloakError.message);
|
||||
|
||||
// Even if Keycloak update fails, consider verification successful if token was valid
|
||||
// This prevents user frustration due to backend issues
|
||||
return sendRedirect(event, '/auth/verify-success?email=' + encodeURIComponent(email) + '&warning=partial', 302);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[verify-email] Verification failed:', error.message);
|
||||
|
||||
// Handle different error types with appropriate redirects
|
||||
if (error.message?.includes('expired')) {
|
||||
return sendRedirect(event, '/auth/verify-expired', 302);
|
||||
} else if (error.message?.includes('already used') || error.message?.includes('not found')) {
|
||||
return sendRedirect(event, '/auth/verify-expired?reason=used', 302);
|
||||
} else {
|
||||
// For other errors, show a generic error page
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error.message || 'Invalid verification link'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -111,11 +111,35 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
console.log('[api/members/[id]/create-portal-account.post] Created Keycloak user with ID:', keycloakId);
|
||||
|
||||
// 6. Update member record with keycloak_id
|
||||
// 8. Update member record with keycloak_id
|
||||
console.log('[api/members/[id]/create-portal-account.post] Updating member record with keycloak_id...');
|
||||
const { updateMember } = await import('~/server/utils/nocodb');
|
||||
await updateMember(memberId, { keycloak_id: keycloakId });
|
||||
|
||||
// 9. Send welcome/verification email using our custom email system
|
||||
console.log('[api/members/[id]/create-portal-account.post] Sending welcome/verification email...');
|
||||
try {
|
||||
const { getEmailService } = await import('~/server/utils/email');
|
||||
const { generateEmailVerificationToken } = await import('~/server/utils/email-tokens');
|
||||
|
||||
const emailService = getEmailService();
|
||||
const verificationToken = await generateEmailVerificationToken(keycloakId, member.email);
|
||||
const config = useRuntimeConfig();
|
||||
const verificationLink = `${config.public.domain}/api/auth/verify-email?token=${verificationToken}`;
|
||||
|
||||
await emailService.sendWelcomeEmail(member.email, {
|
||||
firstName: member.first_name,
|
||||
lastName: member.last_name,
|
||||
verificationLink,
|
||||
memberId: memberId
|
||||
});
|
||||
|
||||
console.log('[api/members/[id]/create-portal-account.post] Welcome email sent successfully');
|
||||
} catch (emailError: any) {
|
||||
console.error('[api/members/[id]/create-portal-account.post] Failed to send welcome email:', emailError.message);
|
||||
// Don't fail the account creation if email fails - user can resend verification email later
|
||||
}
|
||||
|
||||
console.log('[api/members/[id]/create-portal-account.post] ✅ Portal account creation successful');
|
||||
|
||||
return {
|
||||
|
||||
@@ -135,6 +135,30 @@ export default defineEventHandler(async (event) => {
|
||||
const member = await nocodb.create('members', memberData);
|
||||
createdMemberId = member.Id;
|
||||
|
||||
// 7. Send welcome/verification email using our custom email system
|
||||
console.log('[api/registration.post] Sending welcome/verification email...');
|
||||
try {
|
||||
const { getEmailService } = await import('~/server/utils/email');
|
||||
const { generateEmailVerificationToken } = await import('~/server/utils/email-tokens');
|
||||
|
||||
const emailService = getEmailService();
|
||||
const verificationToken = await generateEmailVerificationToken(createdKeycloakId, body.email);
|
||||
const config = useRuntimeConfig();
|
||||
const verificationLink = `${config.public.domain}/api/auth/verify-email?token=${verificationToken}`;
|
||||
|
||||
await emailService.sendWelcomeEmail(body.email, {
|
||||
firstName: body.first_name,
|
||||
lastName: body.last_name,
|
||||
verificationLink,
|
||||
memberId: createdMemberId
|
||||
});
|
||||
|
||||
console.log('[api/registration.post] Welcome email sent successfully');
|
||||
} catch (emailError: any) {
|
||||
console.error('[api/registration.post] Failed to send welcome email:', emailError.message);
|
||||
// Don't fail the registration if email fails - user can resend verification email later
|
||||
}
|
||||
|
||||
console.log(`[api/registration.post] ✅ Registration successful - Member ID: ${createdMemberId}, Keycloak ID: ${createdKeycloakId}`);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user