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

@@ -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 = {};