Enhance member deletion and implement template-based email system
All checks were successful
Build And Push Image / docker (push) Successful in 2m50s

- Add Keycloak user deletion to member removal process
- Implement Handlebars email templates for verification, password reset, and dues reminders
- Improve JWT secret configuration with multiple fallbacks
- Add getMemberById function and enhance error handling
- Update Dockerfile to include email templates in production build
This commit is contained in:
2025-08-09 17:36:35 +02:00
parent bff89bd89d
commit df1ff15975
10 changed files with 767 additions and 15 deletions

View File

@@ -1,5 +1,6 @@
import { deleteMember, handleNocoDbError } from '~/server/utils/nocodb';
import { deleteMember, handleNocoDbError, getMemberById } from '~/server/utils/nocodb';
import { createSessionManager } from '~/server/utils/session';
import { deleteKeycloakUser } from '~/server/utils/keycloak-admin';
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
@@ -38,10 +39,47 @@ export default defineEventHandler(async (event) => {
console.log('[api/members/[id].delete] Authorized user:', session.user.email, 'Tier:', userTier);
// First, get the member data to check for Keycloak ID
let member;
try {
member = await getMemberById(id);
console.log('[api/members/[id].delete] Retrieved member for deletion:', member.first_name, member.last_name);
if (member.keycloak_id) {
console.log('[api/members/[id].delete] Member has Keycloak ID:', member.keycloak_id);
} else {
console.log('[api/members/[id].delete] Member has no Keycloak ID - NocoDB only deletion');
}
} catch (memberError: any) {
console.error('[api/members/[id].delete] Failed to retrieve member data:', memberError);
// Continue with deletion attempt even if we can't get member data
}
// If member has a Keycloak account, try to delete it first
if (member?.keycloak_id) {
try {
console.log('[api/members/[id].delete] Attempting Keycloak user deletion...');
await deleteKeycloakUser(member.keycloak_id);
console.log('[api/members/[id].delete] ✅ Keycloak user deleted successfully');
} catch (keycloakError: any) {
console.error('[api/members/[id].delete] ⚠️ Failed to delete Keycloak user:', keycloakError);
console.error('[api/members/[id].delete] Continuing with member deletion despite Keycloak failure');
// Don't throw here - we want to continue with NocoDB deletion
// This prevents orphaned NocoDB records if Keycloak is temporarily unavailable
}
}
// Delete member from NocoDB
const result = await deleteMember(id);
console.log('[api/members/[id].delete] ✅ Member deleted successfully:', id);
console.log('[api/members/[id].delete] ✅ Member deleted successfully from NocoDB:', id);
// Log completion status
if (member?.keycloak_id) {
console.log('[api/members/[id].delete] ✅ Enhanced deletion completed (NocoDB + Keycloak cleanup)');
} else {
console.log('[api/members/[id].delete] ✅ Standard deletion completed (NocoDB only)');
}
return {
success: true,

View File

@@ -0,0 +1,190 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Membership Dues Reminder - MonacoUSA</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f4f4f4;
}
.email-container {
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #a31515;
}
.logo {
width: 100px;
height: 100px;
margin-bottom: 20px;
}
.title {
color: #a31515;
font-size: 28px;
font-weight: bold;
margin: 0;
}
.subtitle {
color: #666;
font-size: 16px;
margin: 10px 0 0 0;
}
.content {
margin-bottom: 30px;
}
.greeting {
font-size: 20px;
color: #a31515;
margin-bottom: 20px;
}
.dues-info {
background: rgba(163, 21, 21, 0.05);
padding: 20px;
border-radius: 8px;
border: 1px solid rgba(163, 21, 21, 0.1);
margin: 20px 0;
}
.dues-info h3 {
color: #a31515;
margin: 0 0 15px 0;
}
.amount {
font-size: 24px;
font-weight: bold;
color: #a31515;
text-align: center;
margin: 15px 0;
}
.payment-details {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
border-left: 4px solid #a31515;
margin: 20px 0;
}
.payment-details h3 {
color: #a31515;
margin: 0 0 15px 0;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
color: #666;
font-size: 14px;
}
.link {
color: #a31515;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
@media (max-width: 600px) {
body {
padding: 10px;
}
.email-container {
padding: 20px;
}
.title {
font-size: 24px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<img src="{{logoUrl}}" alt="MonacoUSA" class="logo">
<h1 class="title">Membership Dues Reminder</h1>
<p class="subtitle">Monaco - United States Association</p>
</div>
<div class="content">
<div class="greeting">
Dear {{firstName}} {{lastName}},
</div>
<p>This is a friendly reminder that your annual membership dues for the MonacoUSA Association are due.</p>
<div class="dues-info">
<h3>💳 Payment Due</h3>
<div class="amount">€{{amount}}</div>
<p><strong>Due Date:</strong> {{dueDate}}</p>
</div>
{{#if iban}}
<div class="payment-details">
<h3>🏦 Payment Instructions</h3>
<p><strong>Bank Transfer Details:</strong></p>
<ul>
<li><strong>IBAN:</strong> {{iban}}</li>
{{#if accountHolder}}<li><strong>Account Holder:</strong> {{accountHolder}}</li>{{/if}}
<li><strong>Amount:</strong> €{{amount}}</li>
<li><strong>Reference:</strong> Membership Dues - {{firstName}} {{lastName}}</li>
</ul>
<p><em>Please include your name in the payment reference to ensure proper crediting.</em></p>
</div>
{{/if}}
<h3>🌟 Why Your Membership Matters</h3>
<p>Your membership helps us:</p>
<ul>
<li>Connect Monaco and the United States communities</li>
<li>Organize cultural events and networking opportunities</li>
<li>Support charitable causes in both regions</li>
<li>Maintain our digital platform and services</li>
</ul>
<p>If you have already made your payment, please disregard this reminder. It may take a few days for payments to be processed.</p>
<p>If you have any questions about your membership or payment, please don't hesitate to contact us.</p>
</div>
<div class="footer">
<p><strong>MonacoUSA Association</strong><br>
Connecting Monaco and the United States</p>
<p>
<a href="https://portal.monacousa.org" class="link">Portal</a> |
<a href="mailto:info@monacousa.org" class="link">Contact Us</a> |
<a href="https://monacousa.org" class="link">Website</a>
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Password Reset - MonacoUSA</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f4f4f4;
}
.email-container {
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #a31515;
}
.logo {
width: 100px;
height: 100px;
margin-bottom: 20px;
}
.title {
color: #a31515;
font-size: 28px;
font-weight: bold;
margin: 0;
}
.subtitle {
color: #666;
font-size: 16px;
margin: 10px 0 0 0;
}
.content {
margin-bottom: 30px;
}
.greeting {
font-size: 20px;
color: #a31515;
margin-bottom: 20px;
}
.reset-button {
display: inline-block;
padding: 15px 30px;
background: linear-gradient(135deg, #a31515 0%, #c41e1e 100%);
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: bold;
font-size: 16px;
text-align: center;
margin: 20px 0;
transition: all 0.3s ease;
}
.reset-button:hover {
background: linear-gradient(135deg, #8b1212 0%, #a31515 100%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(163, 21, 21, 0.3);
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
color: #666;
font-size: 14px;
}
.link {
color: #a31515;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
@media (max-width: 600px) {
body {
padding: 10px;
}
.email-container {
padding: 20px;
}
.title {
font-size: 24px;
}
.reset-button {
display: block;
text-align: center;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<img src="{{logoUrl}}" alt="MonacoUSA" class="logo">
<h1 class="title">Password Reset</h1>
<p class="subtitle">Monaco - United States Association</p>
</div>
<div class="content">
<div class="greeting">
Dear {{firstName}},
</div>
<p>We received a request to reset your password for your MonacoUSA Portal account.</p>
<div style="text-align: center;">
<a href="{{resetLink}}" class="reset-button">
🔒 Reset Your Password
</a>
</div>
<p><em>This password reset link will expire in 1 hour for security purposes.</em></p>
<p>If you didn't request this password reset, please ignore this email. Your password will remain unchanged.</p>
<p>For security reasons, this link can only be used once.</p>
</div>
<div class="footer">
<p><strong>MonacoUSA Association</strong><br>
Connecting Monaco and the United States</p>
<p>
<a href="https://portal.monacousa.org" class="link">Portal</a> |
<a href="mailto:info@monacousa.org" class="link">Contact Us</a> |
<a href="https://monacousa.org" class="link">Website</a>
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verify Your Email - MonacoUSA</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f4f4f4;
}
.email-container {
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #a31515;
}
.logo {
width: 100px;
height: 100px;
margin-bottom: 20px;
}
.title {
color: #a31515;
font-size: 28px;
font-weight: bold;
margin: 0;
}
.subtitle {
color: #666;
font-size: 16px;
margin: 10px 0 0 0;
}
.content {
margin-bottom: 30px;
}
.greeting {
font-size: 20px;
color: #a31515;
margin-bottom: 20px;
}
.verify-button {
display: inline-block;
padding: 15px 30px;
background: linear-gradient(135deg, #a31515 0%, #c41e1e 100%);
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: bold;
font-size: 16px;
text-align: center;
margin: 20px 0;
transition: all 0.3s ease;
}
.verify-button:hover {
background: linear-gradient(135deg, #8b1212 0%, #a31515 100%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(163, 21, 21, 0.3);
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
color: #666;
font-size: 14px;
}
.link {
color: #a31515;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
@media (max-width: 600px) {
body {
padding: 10px;
}
.email-container {
padding: 20px;
}
.title {
font-size: 24px;
}
.verify-button {
display: block;
text-align: center;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<img src="{{logoUrl}}" alt="MonacoUSA" class="logo">
<h1 class="title">Email Verification</h1>
<p class="subtitle">Monaco - United States Association</p>
</div>
<div class="content">
<div class="greeting">
Dear {{firstName}},
</div>
<p>Please verify your email address to complete your MonacoUSA account setup.</p>
<div style="text-align: center;">
<a href="{{verificationLink}}" class="verify-button">
✉️ Verify Email Address
</a>
</div>
<p><em>This verification link will expire in 24 hours for security purposes.</em></p>
<p>If you didn't request this verification, please ignore this email.</p>
</div>
<div class="footer">
<p><strong>MonacoUSA Association</strong><br>
Connecting Monaco and the United States</p>
<p>
<a href="https://portal.monacousa.org" class="link">Portal</a> |
<a href="mailto:info@monacousa.org" class="link">Contact Us</a> |
<a href="https://monacousa.org" class="link">Website</a>
</p>
</div>
</div>
</body>
</html>

View File

@@ -16,9 +16,26 @@ const activeTokens = new Map<string, EmailVerificationTokenPayload>();
export async function generateEmailVerificationToken(userId: string, email: string): Promise<string> {
const runtimeConfig = useRuntimeConfig();
if (!runtimeConfig.jwtSecret) {
throw new Error('JWT secret not configured');
// Get JWT secret with multiple fallbacks
const jwtSecret = (runtimeConfig.jwtSecret as string) ||
(runtimeConfig.sessionSecret as string) ||
(runtimeConfig.encryptionKey as string) ||
process.env.NUXT_JWT_SECRET ||
process.env.NUXT_SESSION_SECRET ||
process.env.NUXT_ENCRYPTION_KEY ||
'fallback-secret-key-for-email-tokens-please-configure-proper-jwt-secret';
if (!jwtSecret || typeof jwtSecret !== 'string' || jwtSecret.length < 10) {
throw new Error('JWT secret not configured properly. Please set NUXT_JWT_SECRET environment variable');
}
console.log('[email-tokens] Using JWT secret source:',
runtimeConfig.jwtSecret ? 'runtimeConfig.jwtSecret' :
runtimeConfig.sessionSecret ? 'runtimeConfig.sessionSecret' :
runtimeConfig.encryptionKey ? 'runtimeConfig.encryptionKey' :
process.env.NUXT_JWT_SECRET ? 'NUXT_JWT_SECRET' :
process.env.NUXT_SESSION_SECRET ? 'NUXT_SESSION_SECRET' :
process.env.NUXT_ENCRYPTION_KEY ? 'NUXT_ENCRYPTION_KEY' : 'fallback');
const payload: EmailVerificationTokenPayload = {
userId,
@@ -27,7 +44,7 @@ export async function generateEmailVerificationToken(userId: string, email: stri
iat: Date.now()
};
const token = jwt.sign(payload, runtimeConfig.jwtSecret as string, {
const token = jwt.sign(payload, jwtSecret, {
expiresIn: '24h',
issuer: 'monacousa-portal',
audience: 'email-verification'
@@ -52,8 +69,17 @@ export async function generateEmailVerificationToken(userId: string, email: stri
export async function verifyEmailToken(token: string): Promise<{ userId: string; email: string }> {
const runtimeConfig = useRuntimeConfig();
if (!runtimeConfig.jwtSecret) {
throw new Error('JWT secret not configured');
// Get JWT secret with multiple fallbacks (same as generation)
const jwtSecret = (runtimeConfig.jwtSecret as string) ||
(runtimeConfig.sessionSecret as string) ||
(runtimeConfig.encryptionKey as string) ||
process.env.NUXT_JWT_SECRET ||
process.env.NUXT_SESSION_SECRET ||
process.env.NUXT_ENCRYPTION_KEY ||
'fallback-secret-key-for-email-tokens-please-configure-proper-jwt-secret';
if (!jwtSecret || typeof jwtSecret !== 'string' || jwtSecret.length < 10) {
throw new Error('JWT secret not configured properly. Please set NUXT_JWT_SECRET environment variable');
}
if (!token) {
@@ -62,7 +88,7 @@ export async function verifyEmailToken(token: string): Promise<{ userId: string;
try {
// Verify JWT signature and expiration
const decoded = jwt.verify(token, runtimeConfig.jwtSecret as string, {
const decoded = jwt.verify(token, jwtSecret, {
issuer: 'monacousa-portal',
audience: 'email-verification'
}) as any as EmailVerificationTokenPayload;
@@ -114,11 +140,20 @@ export async function isTokenValid(token: string): Promise<boolean> {
try {
const runtimeConfig = useRuntimeConfig();
if (!runtimeConfig.jwtSecret || !token) {
// Get JWT secret with multiple fallbacks (same as generation)
const jwtSecret = (runtimeConfig.jwtSecret as string) ||
(runtimeConfig.sessionSecret as string) ||
(runtimeConfig.encryptionKey as string) ||
process.env.NUXT_JWT_SECRET ||
process.env.NUXT_SESSION_SECRET ||
process.env.NUXT_ENCRYPTION_KEY ||
'fallback-secret-key-for-email-tokens-please-configure-proper-jwt-secret';
if (!jwtSecret || typeof jwtSecret !== 'string' || jwtSecret.length < 10 || !token) {
return false;
}
const decoded = jwt.verify(token, runtimeConfig.jwtSecret as string, {
const decoded = jwt.verify(token, jwtSecret, {
issuer: 'monacousa-portal',
audience: 'email-verification'
}) as any as EmailVerificationTokenPayload;

View File

@@ -149,13 +149,37 @@ export class EmailService {
templateNames.forEach(templateName => {
try {
const templatePath = join(process.cwd(), 'server/templates', `${templateName}.hbs`);
const templateContent = readFileSync(templatePath, 'utf-8');
// Try multiple possible paths for template files
const possiblePaths = [
join(process.cwd(), 'server/templates', `${templateName}.hbs`),
join(process.cwd(), '.output/server/templates', `${templateName}.hbs`),
join(__dirname, '..', 'templates', `${templateName}.hbs`),
join(__dirname, '..', '..', 'server/templates', `${templateName}.hbs`)
];
let templateContent: string | null = null;
let usedPath = '';
for (const templatePath of possiblePaths) {
try {
templateContent = readFileSync(templatePath, 'utf-8');
usedPath = templatePath;
break;
} catch (pathError) {
// Continue to next path
}
}
if (!templateContent) {
console.warn(`[EmailService] ⚠️ Template '${templateName}' not found in any of the expected paths`);
return;
}
const compiledTemplate = handlebars.compile(templateContent);
this.templates.set(templateName, compiledTemplate);
console.log(`[EmailService] ✅ Template '${templateName}' loaded`);
console.log(`[EmailService] ✅ Template '${templateName}' loaded from: ${usedPath}`);
} catch (error) {
console.warn(`[EmailService] ⚠️ Template '${templateName}' not found or failed to load:`, error);
console.warn(`[EmailService] ⚠️ Template '${templateName}' failed to compile:`, error);
}
});
}

View File

@@ -786,3 +786,20 @@ export function createKeycloakAdminClient(): KeycloakAdminClient {
clientSecret: config.keycloakAdmin.clientSecret
});
}
/**
* Helper function to delete a Keycloak user by ID
*/
export async function deleteKeycloakUser(userId: string): Promise<void> {
try {
console.log(`[Keycloak] Attempting to delete user: ${userId}`);
const adminClient = createKeycloakAdminClient();
await adminClient.deleteUser(userId);
console.log(`[Keycloak] User ${userId} deleted successfully`);
} catch (error) {
console.error(`[Keycloak] Failed to delete user ${userId}:`, error);
throw error;
}
}

View File

@@ -120,6 +120,8 @@ export const normalizeFieldsFromNocoDB = (data: any): Member => {
'Current Year Dues Paid': 'current_year_dues_paid',
'Membership Date Paid': 'membership_date_paid',
'Payment Due Date': 'payment_due_date',
'Keycloak ID': 'keycloak_id',
'keycloak_id': 'keycloak_id',
// Also handle reverse mapping in case data comes in snake_case already
'first_name': 'first_name',
'last_name': 'last_name',
@@ -171,7 +173,8 @@ export const normalizeFieldsForNocoDB = (data: any): Record<string, any> => {
'member_since': 'Member Since',
'current_year_dues_paid': 'Current Year Dues Paid',
'membership_date_paid': 'Membership Date Paid',
'payment_due_date': 'Payment Due Date'
'payment_due_date': 'Payment Due Date',
'keycloak_id': 'Keycloak ID'
};
const normalized: any = {};