#### __1. Role-Based Security Architecture__
All checks were successful
Build And Push Image / docker (push) Successful in 2m58s
All checks were successful
Build And Push Image / docker (push) Successful in 2m58s
- Replaces group-based tiers with proper Keycloak realm roles - `monaco-user`, `monaco-board`, `monaco-admin` roles - Backward compatibility with existing group system #### __2. Advanced User Management__ - Comprehensive user profile synchronization - Membership data stored in Keycloak user attributes - Bidirectional sync between NocoDB and Keycloak #### __3. Session Security & Monitoring__ - Real-time session tracking and management - Administrative session control capabilities - Enhanced security analytics foundation #### __4. Email Workflow System__ - Multiple email types: DUES_REMINDER, MEMBERSHIP_RENEWAL, WELCOME, VERIFICATION - Customizable email parameters and lifespans - Advanced email template support #### __5. Seamless Migration Path__ - All existing functionality continues to work - New users automatically get realm roles - Gradual migration from groups to roles - Zero breaking changes ### 🔧 __What You Can Do Now__ #### __For New Users:__ - Public registrations automatically assign `monaco-user` role - Portal account creation syncs member data to Keycloak attributes - Enhanced email verification and welcome workflows #### __For Administrators:__ - Session management and monitoring capabilities - Advanced user profile management with member data sync - Comprehensive role assignment and management - Enhanced email communication workflows #### __For Developers:__ - Use `hasRole('monaco-admin')` for role-based checks - Access `getAllRoles()` for debugging and analytics - Enhanced `useAuth()` composable with backward compatibility - Comprehensive TypeScript support throughout ### 🛡️ __Security & Reliability__ - __Backward Compatibility__: Existing users continue to work seamlessly - __Enhanced Security__: Proper realm role-based authorization - __Error Handling__: Comprehensive error handling and fallbacks - __Type Safety__: Full TypeScript support throughout the system
This commit is contained in:
163
server/api/admin/cleanup-accounts.post.ts
Normal file
163
server/api/admin/cleanup-accounts.post.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { Member } from '~/utils/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/admin/cleanup-accounts.post] =========================');
|
||||
console.log('[api/admin/cleanup-accounts.post] POST /api/admin/cleanup-accounts - Account cleanup for expired members');
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
// Require admin privileges for account cleanup
|
||||
if (session.user.tier !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Admin privileges required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/admin/cleanup-accounts.post] Authorized admin:', session.user.email);
|
||||
|
||||
// Get cleanup options from request body (optional)
|
||||
const body = await readBody(event).catch(() => ({}));
|
||||
const dryRun = body?.dryRun === true;
|
||||
const monthsOverdue = body?.monthsOverdue || 3;
|
||||
|
||||
console.log('[api/admin/cleanup-accounts.post] Cleanup options:', { dryRun, monthsOverdue });
|
||||
|
||||
// Calculate cutoff date (default: 3 months ago)
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setMonth(cutoffDate.getMonth() - monthsOverdue);
|
||||
|
||||
console.log('[api/admin/cleanup-accounts.post] Cutoff date:', cutoffDate.toISOString());
|
||||
|
||||
// Find members registered before cutoff date with unpaid dues
|
||||
const { getMembers } = await import('~/server/utils/nocodb');
|
||||
const membersResult = await getMembers();
|
||||
const allMembers = membersResult.list || [];
|
||||
|
||||
const expiredMembers = allMembers.filter((member: Member) => {
|
||||
// Must have a registration date
|
||||
if (!member.registration_date) return false;
|
||||
|
||||
// Must be registered before cutoff date
|
||||
const registrationDate = new Date(member.registration_date);
|
||||
if (registrationDate >= cutoffDate) return false;
|
||||
|
||||
// Must have unpaid dues
|
||||
if (member.current_year_dues_paid === 'true') return false;
|
||||
|
||||
// Must have a Keycloak ID (portal account)
|
||||
if (!member.keycloak_id) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log('[api/admin/cleanup-accounts.post] Found', expiredMembers.length, 'expired members for cleanup');
|
||||
|
||||
const deletedAccounts = [];
|
||||
const failedDeletions = [];
|
||||
|
||||
if (!dryRun && expiredMembers.length > 0) {
|
||||
const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin');
|
||||
const { deleteMember } = await import('~/server/utils/nocodb');
|
||||
const keycloakAdmin = createKeycloakAdminClient();
|
||||
|
||||
for (const member of expiredMembers) {
|
||||
try {
|
||||
console.log('[api/admin/cleanup-accounts.post] Processing cleanup for:', member.email);
|
||||
|
||||
// Delete from Keycloak first
|
||||
if (member.keycloak_id) {
|
||||
try {
|
||||
await keycloakAdmin.deleteUser(member.keycloak_id);
|
||||
console.log('[api/admin/cleanup-accounts.post] Deleted Keycloak user:', member.keycloak_id);
|
||||
} catch (keycloakError: any) {
|
||||
console.warn('[api/admin/cleanup-accounts.post] Failed to delete Keycloak user:', keycloakError.message);
|
||||
// Continue with member deletion even if Keycloak deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete member record
|
||||
await deleteMember(member.Id);
|
||||
console.log('[api/admin/cleanup-accounts.post] Deleted member record:', member.Id);
|
||||
|
||||
deletedAccounts.push({
|
||||
id: member.Id,
|
||||
email: member.email,
|
||||
name: `${member.first_name} ${member.last_name}`,
|
||||
registrationDate: member.registration_date,
|
||||
keycloakId: member.keycloak_id
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/admin/cleanup-accounts.post] Failed to delete account for', member.email, ':', error);
|
||||
failedDeletions.push({
|
||||
id: member.Id,
|
||||
email: member.email,
|
||||
name: `${member.first_name} ${member.last_name}`,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
dryRun,
|
||||
monthsOverdue,
|
||||
cutoffDate: cutoffDate.toISOString(),
|
||||
totalExpiredMembers: expiredMembers.length,
|
||||
deletedCount: deletedAccounts.length,
|
||||
failedCount: failedDeletions.length,
|
||||
message: dryRun
|
||||
? `Found ${expiredMembers.length} expired accounts that would be deleted (dry run)`
|
||||
: `Cleaned up ${deletedAccounts.length} expired accounts${failedDeletions.length > 0 ? ` (${failedDeletions.length} failed)` : ''}`,
|
||||
data: {
|
||||
expiredMembers: expiredMembers.map(m => ({
|
||||
id: m.Id,
|
||||
email: m.email,
|
||||
name: `${m.first_name} ${m.last_name}`,
|
||||
registrationDate: m.registration_date,
|
||||
daysSinceRegistration: Math.floor((Date.now() - new Date(m.registration_date || '').getTime()) / (1000 * 60 * 60 * 24)),
|
||||
hasKeycloakAccount: !!m.keycloak_id
|
||||
})),
|
||||
deleted: deletedAccounts,
|
||||
failed: failedDeletions
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[api/admin/cleanup-accounts.post] ✅ Account cleanup completed');
|
||||
console.log('[api/admin/cleanup-accounts.post] Summary:', {
|
||||
found: expiredMembers.length,
|
||||
deleted: deletedAccounts.length,
|
||||
failed: failedDeletions.length,
|
||||
dryRun
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/admin/cleanup-accounts.post] ❌ Account cleanup failed:', error);
|
||||
|
||||
// If it's already an HTTP error, re-throw it
|
||||
if (error.statusCode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Otherwise, wrap it in a generic error
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Account cleanup failed'
|
||||
});
|
||||
}
|
||||
});
|
||||
43
server/api/admin/recaptcha-config.get.ts
Normal file
43
server/api/admin/recaptcha-config.get.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/admin/recaptcha-config.get] =========================');
|
||||
console.log('[api/admin/recaptcha-config.get] GET /api/admin/recaptcha-config - Get reCAPTCHA 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/recaptcha-config.get] Authorized admin:', session.user.email);
|
||||
|
||||
// Get reCAPTCHA configuration
|
||||
const { getRecaptchaConfig } = await import('~/server/utils/admin-config');
|
||||
const config = getRecaptchaConfig();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
siteKey: config.siteKey,
|
||||
secretKey: config.secretKey ? '••••••••••••••••' : ''
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/admin/recaptcha-config.get] ❌ Error getting reCAPTCHA config:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
64
server/api/admin/recaptcha-config.post.ts
Normal file
64
server/api/admin/recaptcha-config.post.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/admin/recaptcha-config.post] =========================');
|
||||
console.log('[api/admin/recaptcha-config.post] POST /api/admin/recaptcha-config - Save reCAPTCHA 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/recaptcha-config.post] Authorized admin:', session.user.email);
|
||||
|
||||
// Get and validate request body
|
||||
const body = await readBody(event);
|
||||
console.log('[api/admin/recaptcha-config.post] Request body fields:', Object.keys(body));
|
||||
|
||||
// Validate required fields
|
||||
if (!body.siteKey || typeof body.siteKey !== 'string') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Site Key is required'
|
||||
});
|
||||
}
|
||||
|
||||
if (!body.secretKey || typeof body.secretKey !== 'string') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Secret Key is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Save reCAPTCHA configuration
|
||||
const { saveRecaptchaConfig } = await import('~/server/utils/admin-config');
|
||||
await saveRecaptchaConfig({
|
||||
siteKey: body.siteKey.trim(),
|
||||
secretKey: body.secretKey.trim()
|
||||
}, session.user.email);
|
||||
|
||||
console.log('[api/admin/recaptcha-config.post] ✅ reCAPTCHA configuration saved successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'reCAPTCHA configuration saved successfully'
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/admin/recaptcha-config.post] ❌ Error saving reCAPTCHA config:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
40
server/api/admin/registration-config.get.ts
Normal file
40
server/api/admin/registration-config.get.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/admin/registration-config.get] =========================');
|
||||
console.log('[api/admin/registration-config.get] GET /api/admin/registration-config - Get registration 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/registration-config.get] Authorized admin:', session.user.email);
|
||||
|
||||
// Get registration configuration
|
||||
const { getRegistrationConfig } = await import('~/server/utils/admin-config');
|
||||
const config = getRegistrationConfig();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: config
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/admin/registration-config.get] ❌ Error getting registration config:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
72
server/api/admin/registration-config.post.ts
Normal file
72
server/api/admin/registration-config.post.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/admin/registration-config.post] =========================');
|
||||
console.log('[api/admin/registration-config.post] POST /api/admin/registration-config - Save registration 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/registration-config.post] Authorized admin:', session.user.email);
|
||||
|
||||
// Get and validate request body
|
||||
const body = await readBody(event);
|
||||
console.log('[api/admin/registration-config.post] Request body fields:', Object.keys(body));
|
||||
|
||||
// Validate required fields
|
||||
if (!body.membershipFee || typeof body.membershipFee !== 'number' || body.membershipFee <= 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Valid membership fee is required'
|
||||
});
|
||||
}
|
||||
|
||||
if (!body.iban || typeof body.iban !== 'string') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'IBAN is required'
|
||||
});
|
||||
}
|
||||
|
||||
if (!body.accountHolder || typeof body.accountHolder !== 'string') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Account holder name is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Save registration configuration
|
||||
const { saveRegistrationConfig } = await import('~/server/utils/admin-config');
|
||||
await saveRegistrationConfig({
|
||||
membershipFee: body.membershipFee,
|
||||
iban: body.iban.trim(),
|
||||
accountHolder: body.accountHolder.trim()
|
||||
}, session.user.email);
|
||||
|
||||
console.log('[api/admin/registration-config.post] ✅ Registration configuration saved successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Registration configuration saved successfully'
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/admin/registration-config.post] ❌ Error saving registration config:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
177
server/api/members/[id]/create-portal-account.post.ts
Normal file
177
server/api/members/[id]/create-portal-account.post.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/members/[id]/create-portal-account.post] =========================');
|
||||
console.log('[api/members/[id]/create-portal-account.post] POST /api/members/:id/create-portal-account - Create portal account for member');
|
||||
|
||||
try {
|
||||
// Validate session and require board/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'
|
||||
});
|
||||
}
|
||||
|
||||
// Require board or admin privileges
|
||||
if (session.user.tier !== 'admin' && session.user.tier !== 'board') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Board or Admin privileges required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/members/[id]/create-portal-account.post] Authorized user:', session.user.email, 'tier:', session.user.tier);
|
||||
|
||||
// Get member ID from route parameter
|
||||
const memberId = getRouterParam(event, 'id');
|
||||
if (!memberId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Member ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/members/[id]/create-portal-account.post] Processing member ID:', memberId);
|
||||
|
||||
// 1. Get member data
|
||||
const { getMemberById } = await import('~/server/utils/nocodb');
|
||||
const member = await getMemberById(memberId);
|
||||
|
||||
if (!member) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Member not found'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/members/[id]/create-portal-account.post] Found member:', member.email);
|
||||
|
||||
// 2. Check if member already has portal account
|
||||
if (member.keycloak_id) {
|
||||
console.log('[api/members/[id]/create-portal-account.post] Member already has portal account:', member.keycloak_id);
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'Member already has a portal account'
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Validate member data
|
||||
if (!member.email || !member.first_name || !member.last_name) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Member must have email, first name, and last name to create portal account'
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Check if user already exists in Keycloak (by email)
|
||||
const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin');
|
||||
const keycloakAdmin = createKeycloakAdminClient();
|
||||
|
||||
console.log('[api/members/[id]/create-portal-account.post] Checking for existing Keycloak user...');
|
||||
const existingUsers = await keycloakAdmin.findUserByEmail(member.email);
|
||||
|
||||
if (existingUsers.length > 0) {
|
||||
console.log('[api/members/[id]/create-portal-account.post] User already exists in Keycloak');
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'A user with this email already exists in the system'
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Determine membership tier based on member data
|
||||
const membershipTier = determineMembershipTier(member);
|
||||
console.log('[api/members/[id]/create-portal-account.post] Determined membership tier:', membershipTier);
|
||||
|
||||
// 6. Prepare membership data for Keycloak sync
|
||||
const membershipData = {
|
||||
membershipStatus: member.membership_status || 'Active',
|
||||
duesStatus: member.current_year_dues_paid === 'true' ? 'paid' as const : 'unpaid' as const,
|
||||
memberSince: member.member_since || new Date().getFullYear().toString(),
|
||||
nationality: member.nationality || '',
|
||||
phone: member.phone || '',
|
||||
address: member.address || '',
|
||||
registrationDate: member.registration_date || new Date().toISOString(),
|
||||
paymentDueDate: member.payment_due_date || '',
|
||||
membershipTier,
|
||||
nocodbMemberId: memberId
|
||||
};
|
||||
|
||||
// 7. Create Keycloak user with role-based registration
|
||||
console.log('[api/members/[id]/create-portal-account.post] Creating Keycloak user with role-based system...');
|
||||
const keycloakId = await keycloakAdmin.createUserWithRoleRegistration({
|
||||
email: member.email,
|
||||
firstName: member.first_name,
|
||||
lastName: member.last_name,
|
||||
membershipTier,
|
||||
membershipData
|
||||
});
|
||||
|
||||
console.log('[api/members/[id]/create-portal-account.post] Created Keycloak user with ID:', keycloakId);
|
||||
|
||||
// 6. 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 });
|
||||
|
||||
console.log('[api/members/[id]/create-portal-account.post] ✅ Portal account creation successful');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Portal account created successfully. The member will receive an email to verify their account and set their password.',
|
||||
data: {
|
||||
keycloak_id: keycloakId,
|
||||
member_id: memberId,
|
||||
email: member.email,
|
||||
name: `${member.first_name} ${member.last_name}`
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/members/[id]/create-portal-account.post] ❌ Portal account creation failed:', error);
|
||||
|
||||
// If it's already an HTTP error, re-throw it
|
||||
if (error.statusCode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Otherwise, wrap it in a generic error
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Failed to create portal account'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Determine membership tier based on member data
|
||||
* This function analyzes member information to assign appropriate portal roles
|
||||
*/
|
||||
function determineMembershipTier(member: any): 'user' | 'board' | 'admin' {
|
||||
// Check for explicit tier indicators in member data
|
||||
// This could be based on membership type, special flags, or other criteria
|
||||
|
||||
// For now, default all members to 'user' tier
|
||||
// In the future, you might want to check specific fields like:
|
||||
// - member.membership_type
|
||||
// - member.is_board_member
|
||||
// - member.is_admin
|
||||
// - specific email domains for admins
|
||||
// - etc.
|
||||
|
||||
// Example logic (uncomment and modify as needed):
|
||||
/*
|
||||
if (member.email && member.email.includes('@admin.monacousa.org')) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
if (member.membership_type === 'Board' || member.is_board_member === 'true') {
|
||||
return 'board';
|
||||
}
|
||||
*/
|
||||
|
||||
// Default to user tier for all members
|
||||
return 'user';
|
||||
}
|
||||
262
server/api/registration.post.ts
Normal file
262
server/api/registration.post.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import type { RegistrationFormData } from '~/utils/types';
|
||||
import { createKeycloakAdminClient } from '~/server/utils/keycloak-admin';
|
||||
import { createMember } from '~/server/utils/nocodb';
|
||||
|
||||
// Simple NocoDB client wrapper for consistency with existing pattern
|
||||
const createNocoDBClient = () => ({
|
||||
async findAll(table: string, options: any) {
|
||||
// For registration, we'll use a direct search via the existing members API
|
||||
try {
|
||||
const response = await $fetch(`/api/members`, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
}) as any;
|
||||
|
||||
// Filter by email from the response
|
||||
const members = response?.data || response?.list || [];
|
||||
if (options.where.email) {
|
||||
return members.filter((member: any) => member.email === options.where.email);
|
||||
}
|
||||
return members;
|
||||
} catch (error) {
|
||||
console.warn('[createNocoDBClient.findAll] Error fetching members:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async create(table: string, data: any) {
|
||||
return await createMember(data);
|
||||
},
|
||||
|
||||
async delete(table: string, id: string) {
|
||||
const { deleteMember } = await import('~/server/utils/nocodb');
|
||||
return await deleteMember(id);
|
||||
}
|
||||
});
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/registration.post] =========================');
|
||||
console.log('[api/registration.post] POST /api/registration - Public member registration');
|
||||
|
||||
let createdKeycloakId: string | null = null;
|
||||
let createdMemberId: string | null = null;
|
||||
|
||||
try {
|
||||
const body = await readBody(event) as RegistrationFormData;
|
||||
console.log('[api/registration.post] Registration attempt for:', body.email);
|
||||
|
||||
// 1. Validate reCAPTCHA
|
||||
if (!body.recaptcha_token) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'reCAPTCHA verification is required'
|
||||
});
|
||||
}
|
||||
|
||||
const recaptchaValid = await validateRecaptcha(body.recaptcha_token);
|
||||
if (!recaptchaValid) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'reCAPTCHA verification failed'
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Validate form data
|
||||
const validationErrors = validateRegistrationForm(body);
|
||||
if (validationErrors.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: validationErrors.join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Check for existing user in Keycloak
|
||||
const keycloakAdmin = createKeycloakAdminClient();
|
||||
const existingUsers = await keycloakAdmin.findUserByEmail(body.email);
|
||||
if (existingUsers.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'An account with this email already exists'
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Check for existing member record
|
||||
const nocodb = createNocoDBClient();
|
||||
const existingMembers = await nocodb.findAll('members', {
|
||||
where: { email: body.email }
|
||||
});
|
||||
if (existingMembers.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'A member with this email already exists'
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Create Keycloak user with role-based registration
|
||||
console.log('[api/registration.post] Creating Keycloak user with role-based system...');
|
||||
const membershipData = {
|
||||
membershipStatus: 'Active',
|
||||
duesStatus: 'unpaid' as const,
|
||||
nationality: body.nationality,
|
||||
phone: body.phone,
|
||||
address: body.address,
|
||||
registrationDate: new Date().toISOString(),
|
||||
paymentDueDate: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
membershipTier: 'user' as const
|
||||
};
|
||||
|
||||
createdKeycloakId = await keycloakAdmin.createUserWithRoleRegistration({
|
||||
email: body.email,
|
||||
firstName: body.first_name,
|
||||
lastName: body.last_name,
|
||||
membershipTier: 'user', // All public registrations default to 'user' role
|
||||
membershipData
|
||||
});
|
||||
|
||||
// 6. Create member record
|
||||
console.log('[api/registration.post] Creating member record...');
|
||||
const memberData = {
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
date_of_birth: body.date_of_birth,
|
||||
address: body.address,
|
||||
nationality: body.nationality,
|
||||
keycloak_id: createdKeycloakId,
|
||||
current_year_dues_paid: 'false',
|
||||
membership_status: 'Active',
|
||||
registration_date: new Date().toISOString(),
|
||||
member_since: new Date().getFullYear().toString(),
|
||||
membership_date_paid: '',
|
||||
payment_due_date: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString() // 3 months from now
|
||||
};
|
||||
|
||||
const member = await nocodb.create('members', memberData);
|
||||
createdMemberId = member.Id;
|
||||
|
||||
console.log(`[api/registration.post] ✅ Registration successful - Member ID: ${createdMemberId}, Keycloak ID: ${createdKeycloakId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Registration successful! Please check your email to verify your account and set your password.',
|
||||
data: {
|
||||
memberId: createdMemberId,
|
||||
email: body.email
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/registration.post] ❌ Registration failed:', error);
|
||||
|
||||
// Rollback operations if needed
|
||||
if (createdKeycloakId || createdMemberId) {
|
||||
console.log('[api/registration.post] Rolling back partial registration...');
|
||||
|
||||
// Delete Keycloak user if created
|
||||
if (createdKeycloakId) {
|
||||
try {
|
||||
const keycloakAdmin = createKeycloakAdminClient();
|
||||
await keycloakAdmin.deleteUser(createdKeycloakId);
|
||||
console.log('[api/registration.post] Keycloak user rolled back');
|
||||
} catch (rollbackError) {
|
||||
console.error('[api/registration.post] Failed to rollback Keycloak user:', rollbackError);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete member record if created
|
||||
if (createdMemberId) {
|
||||
try {
|
||||
const nocodb = createNocoDBClient();
|
||||
await nocodb.delete('members', createdMemberId);
|
||||
console.log('[api/registration.post] Member record rolled back');
|
||||
} catch (rollbackError) {
|
||||
console.error('[api/registration.post] Failed to rollback member record:', rollbackError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate reCAPTCHA token
|
||||
*/
|
||||
async function validateRecaptcha(token: string): Promise<boolean> {
|
||||
try {
|
||||
const { getRecaptchaConfig } = await import('~/server/utils/admin-config');
|
||||
const recaptchaConfig = getRecaptchaConfig();
|
||||
|
||||
if (!recaptchaConfig.secretKey) {
|
||||
console.warn('[api/registration.post] reCAPTCHA secret key not configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
secret: recaptchaConfig.secretKey,
|
||||
response: token
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
return result.success === true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[api/registration.post] reCAPTCHA validation error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate registration form data
|
||||
*/
|
||||
function validateRegistrationForm(data: RegistrationFormData): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Required fields
|
||||
if (!data.first_name?.trim()) errors.push('First name is required');
|
||||
if (!data.last_name?.trim()) errors.push('Last name is required');
|
||||
if (!data.email?.trim()) errors.push('Email is required');
|
||||
if (!data.phone?.trim()) errors.push('Phone number is required');
|
||||
if (!data.date_of_birth?.trim()) errors.push('Date of birth is required');
|
||||
if (!data.address?.trim()) errors.push('Address is required');
|
||||
if (!data.nationality?.trim()) errors.push('Nationality is required');
|
||||
|
||||
// Email format validation
|
||||
if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||
errors.push('Invalid email format');
|
||||
}
|
||||
|
||||
// Date of birth validation (must be at least 18 years old)
|
||||
if (data.date_of_birth) {
|
||||
const birthDate = new Date(data.date_of_birth);
|
||||
const today = new Date();
|
||||
const age = today.getFullYear() - birthDate.getFullYear();
|
||||
const monthDiff = today.getMonth() - birthDate.getMonth();
|
||||
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
||||
// Haven't had birthday this year yet
|
||||
if (age - 1 < 18) {
|
||||
errors.push('You must be at least 18 years old to register');
|
||||
}
|
||||
} else {
|
||||
if (age < 18) {
|
||||
errors.push('You must be at least 18 years old to register');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Name length validation
|
||||
if (data.first_name && data.first_name.trim().length < 2) {
|
||||
errors.push('First name must be at least 2 characters');
|
||||
}
|
||||
if (data.last_name && data.last_name.trim().length < 2) {
|
||||
errors.push('Last name must be at least 2 characters');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -6,6 +6,15 @@ import type { NocoDBSettings } from '~/utils/types';
|
||||
|
||||
interface AdminConfiguration {
|
||||
nocodb: NocoDBSettings;
|
||||
recaptcha?: {
|
||||
siteKey: string;
|
||||
secretKey: string; // Will be encrypted
|
||||
};
|
||||
registration?: {
|
||||
membershipFee: number;
|
||||
iban: string;
|
||||
accountHolder: string;
|
||||
};
|
||||
lastUpdated: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
@@ -177,6 +186,9 @@ export async function loadAdminConfig(): Promise<AdminConfiguration | null> {
|
||||
if (config.nocodb.apiKey) {
|
||||
config.nocodb.apiKey = decryptSensitiveData(config.nocodb.apiKey);
|
||||
}
|
||||
if (config.recaptcha?.secretKey) {
|
||||
config.recaptcha.secretKey = decryptSensitiveData(config.recaptcha.secretKey);
|
||||
}
|
||||
|
||||
console.log('[admin-config] Configuration loaded from file');
|
||||
configCache = config;
|
||||
@@ -295,6 +307,108 @@ export async function getCurrentConfig(): Promise<NocoDBSettings> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save reCAPTCHA configuration
|
||||
*/
|
||||
export async function saveRecaptchaConfig(config: { siteKey: string; secretKey: string }, updatedBy: string): Promise<void> {
|
||||
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,
|
||||
recaptcha: {
|
||||
siteKey: config.siteKey,
|
||||
secretKey: encryptSensitiveData(config.secretKey)
|
||||
},
|
||||
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,
|
||||
recaptcha: {
|
||||
siteKey: config.siteKey,
|
||||
secretKey: config.secretKey
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[admin-config] reCAPTCHA configuration saved');
|
||||
await logConfigChange('RECAPTCHA_CONFIG_SAVED', updatedBy, { siteKey: config.siteKey });
|
||||
|
||||
} catch (error) {
|
||||
console.error('[admin-config] Failed to save reCAPTCHA configuration:', error);
|
||||
await logConfigChange('RECAPTCHA_CONFIG_SAVE_FAILED', updatedBy, { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save registration configuration
|
||||
*/
|
||||
export async function saveRegistrationConfig(config: { membershipFee: number; iban: string; accountHolder: string }, updatedBy: string): Promise<void> {
|
||||
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,
|
||||
registration: config,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
updatedBy
|
||||
};
|
||||
|
||||
const configJson = JSON.stringify(updatedConfig, null, 2);
|
||||
await writeFile(CONFIG_FILE, configJson, 'utf-8');
|
||||
|
||||
configCache = updatedConfig;
|
||||
|
||||
console.log('[admin-config] Registration configuration saved');
|
||||
await logConfigChange('REGISTRATION_CONFIG_SAVED', updatedBy, { membershipFee: config.membershipFee });
|
||||
|
||||
} catch (error) {
|
||||
console.error('[admin-config] Failed to save registration configuration:', error);
|
||||
await logConfigChange('REGISTRATION_CONFIG_SAVE_FAILED', updatedBy, { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reCAPTCHA configuration
|
||||
*/
|
||||
export function getRecaptchaConfig(): { siteKey: string; secretKey: string } {
|
||||
const config = configCache?.recaptcha || { siteKey: '', secretKey: '' };
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registration configuration
|
||||
*/
|
||||
export function getRegistrationConfig(): { membershipFee: number; iban: string; accountHolder: string } {
|
||||
const config = configCache?.registration || {
|
||||
membershipFee: 50,
|
||||
iban: '',
|
||||
accountHolder: ''
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize configuration system on server startup
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { KeycloakAdminConfig } from '~/utils/types';
|
||||
import type {
|
||||
KeycloakAdminConfig,
|
||||
KeycloakUserRepresentation,
|
||||
KeycloakRoleRepresentation,
|
||||
KeycloakGroupRepresentation,
|
||||
UserSessionRepresentation,
|
||||
EmailWorkflowData,
|
||||
MembershipProfileData
|
||||
} from '~/utils/types';
|
||||
|
||||
export class KeycloakAdminClient {
|
||||
private config: KeycloakAdminConfig;
|
||||
@@ -36,12 +44,13 @@ export class KeycloakAdminClient {
|
||||
/**
|
||||
* Find a user by email address
|
||||
*/
|
||||
async findUserByEmail(email: string, adminToken: string): Promise<any[]> {
|
||||
async findUserByEmail(email: string, adminToken?: string): Promise<any[]> {
|
||||
const token = adminToken || await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/users?email=${encodeURIComponent(email)}&exact=true`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
}
|
||||
});
|
||||
@@ -54,6 +63,675 @@ export class KeycloakAdminClient {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user with temporary password and email verification
|
||||
*/
|
||||
async createUserWithRegistration(userData: {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
username?: string;
|
||||
}): Promise<string> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
// Check if user already exists
|
||||
const existingUsers = await this.findUserByEmail(userData.email, adminToken);
|
||||
if (existingUsers.length > 0) {
|
||||
throw new Error('User with this email already exists');
|
||||
}
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/users`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: userData.email,
|
||||
username: userData.username || userData.email,
|
||||
firstName: userData.firstName,
|
||||
lastName: userData.lastName,
|
||||
enabled: true,
|
||||
emailVerified: false,
|
||||
groups: ['/users'], // Default to 'user' tier group
|
||||
attributes: {
|
||||
tier: ['user']
|
||||
},
|
||||
requiredActions: ['VERIFY_EMAIL', 'UPDATE_PASSWORD']
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to create user: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
// Extract user ID from Location header
|
||||
const locationHeader = response.headers.get('location');
|
||||
if (!locationHeader) {
|
||||
throw new Error('User created but failed to get user ID');
|
||||
}
|
||||
|
||||
const userId = locationHeader.split('/').pop();
|
||||
if (!userId) {
|
||||
throw new Error('Failed to extract user ID from response');
|
||||
}
|
||||
|
||||
console.log(`[keycloak-admin] Created user ${userData.email} with ID: ${userId}`);
|
||||
return userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user by ID
|
||||
*/
|
||||
async deleteUser(userId: string): Promise<void> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to delete user: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
console.log(`[keycloak-admin] Deleted user with ID: ${userId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ROLE MANAGEMENT METHODS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a new realm role
|
||||
*/
|
||||
async createRealmRole(roleName: string, description: string): Promise<void> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/roles`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: roleName,
|
||||
description: description,
|
||||
composite: false,
|
||||
clientRole: false
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to create realm role: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
console.log(`[keycloak-admin] Created realm role: ${roleName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a realm role by name
|
||||
*/
|
||||
async getRealmRole(roleName: string, adminToken?: string): Promise<KeycloakRoleRepresentation> {
|
||||
const token = adminToken || await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/roles/${roleName}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to get realm role: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all realm roles
|
||||
*/
|
||||
async getAllRealmRoles(adminToken?: string): Promise<KeycloakRoleRepresentation[]> {
|
||||
const token = adminToken || await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/roles`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to get realm roles: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a realm role to a user
|
||||
*/
|
||||
async assignRealmRoleToUser(userId: string, roleName: string): Promise<void> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
// First get the role
|
||||
const role = await this.getRealmRole(roleName, adminToken);
|
||||
|
||||
// Then assign it to user
|
||||
const response = await fetch(`${adminBaseUrl}/users/${userId}/role-mappings/realm`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
},
|
||||
body: JSON.stringify([role])
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to assign role to user: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
console.log(`[keycloak-admin] Assigned role ${roleName} to user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a realm role from a user
|
||||
*/
|
||||
async removeRealmRoleFromUser(userId: string, roleName: string): Promise<void> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
// First get the role
|
||||
const role = await this.getRealmRole(roleName, adminToken);
|
||||
|
||||
// Then remove it from user
|
||||
const response = await fetch(`${adminBaseUrl}/users/${userId}/role-mappings/realm`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
},
|
||||
body: JSON.stringify([role])
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to remove role from user: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
console.log(`[keycloak-admin] Removed role ${roleName} from user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's realm role mappings
|
||||
*/
|
||||
async getUserRealmRoles(userId: string, adminToken?: string): Promise<KeycloakRoleRepresentation[]> {
|
||||
const token = adminToken || await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/users/${userId}/role-mappings/realm`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to get user roles: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USER PROFILE MANAGEMENT METHODS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get user by ID with full profile information
|
||||
*/
|
||||
async getUserById(userId: string, adminToken?: string): Promise<KeycloakUserRepresentation> {
|
||||
const token = adminToken || await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/users/${userId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to get user: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile with membership data synchronization
|
||||
*/
|
||||
async updateUserProfile(userId: string, profileData: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
enabled?: boolean;
|
||||
emailVerified?: boolean;
|
||||
attributes?: {
|
||||
membershipStatus?: string;
|
||||
duesStatus?: string;
|
||||
memberSince?: string;
|
||||
nationality?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
registrationDate?: string;
|
||||
paymentDueDate?: string;
|
||||
lastLoginDate?: string;
|
||||
membershipTier?: string;
|
||||
nocodbMemberId?: string;
|
||||
};
|
||||
}): Promise<void> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
// Build user representation
|
||||
const userUpdate: any = {};
|
||||
if (profileData.firstName !== undefined) userUpdate.firstName = profileData.firstName;
|
||||
if (profileData.lastName !== undefined) userUpdate.lastName = profileData.lastName;
|
||||
if (profileData.email !== undefined) userUpdate.email = profileData.email;
|
||||
if (profileData.enabled !== undefined) userUpdate.enabled = profileData.enabled;
|
||||
if (profileData.emailVerified !== undefined) userUpdate.emailVerified = profileData.emailVerified;
|
||||
|
||||
// Handle custom attributes
|
||||
if (profileData.attributes) {
|
||||
userUpdate.attributes = {};
|
||||
Object.entries(profileData.attributes).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
userUpdate.attributes[key] = [value]; // Keycloak expects arrays for attributes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
},
|
||||
body: JSON.stringify(userUpdate)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to update user profile: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
console.log(`[keycloak-admin] Updated profile for user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user with role-based registration (enhanced version)
|
||||
*/
|
||||
async createUserWithRoleRegistration(userData: {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
username?: string;
|
||||
membershipTier?: 'user' | 'board' | 'admin';
|
||||
membershipData?: MembershipProfileData;
|
||||
}): Promise<string> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
// Check if user already exists
|
||||
const existingUsers = await this.findUserByEmail(userData.email, adminToken);
|
||||
if (existingUsers.length > 0) {
|
||||
throw new Error('User with this email already exists');
|
||||
}
|
||||
|
||||
// Build user attributes
|
||||
const attributes: Record<string, string[]> = {
|
||||
membershipTier: [userData.membershipTier || 'user'],
|
||||
registrationDate: [new Date().toISOString()]
|
||||
};
|
||||
|
||||
if (userData.membershipData) {
|
||||
Object.entries(userData.membershipData).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
attributes[key] = [String(value)];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/users`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: userData.email,
|
||||
username: userData.username || userData.email,
|
||||
firstName: userData.firstName,
|
||||
lastName: userData.lastName,
|
||||
enabled: true,
|
||||
emailVerified: false,
|
||||
attributes,
|
||||
requiredActions: ['VERIFY_EMAIL', 'UPDATE_PASSWORD']
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to create user: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
// Extract user ID from Location header
|
||||
const locationHeader = response.headers.get('location');
|
||||
if (!locationHeader) {
|
||||
throw new Error('User created but failed to get user ID');
|
||||
}
|
||||
|
||||
const userId = locationHeader.split('/').pop();
|
||||
if (!userId) {
|
||||
throw new Error('Failed to extract user ID from response');
|
||||
}
|
||||
|
||||
// Assign appropriate realm role
|
||||
const roleName = `monaco-${userData.membershipTier || 'user'}`;
|
||||
try {
|
||||
await this.assignRealmRoleToUser(userId, roleName);
|
||||
} catch (error) {
|
||||
console.warn(`[keycloak-admin] Failed to assign role ${roleName} to user ${userId}:`, error);
|
||||
// Don't fail the entire operation if role assignment fails
|
||||
}
|
||||
|
||||
console.log(`[keycloak-admin] Created user ${userData.email} with ID: ${userId} and role: ${roleName}`);
|
||||
return userId;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SESSION MANAGEMENT METHODS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all active sessions for a user
|
||||
*/
|
||||
async getUserSessions(userId: string): Promise<UserSessionRepresentation[]> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/users/${userId}/sessions`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to get user sessions: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout a specific user session
|
||||
*/
|
||||
async logoutUserSession(sessionId: string): Promise<void> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to logout session: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
console.log(`[keycloak-admin] Logged out session: ${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout all sessions for a user
|
||||
*/
|
||||
async logoutAllUserSessions(userId: string): Promise<void> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/users/${userId}/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to logout all user sessions: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
console.log(`[keycloak-admin] Logged out all sessions for user: ${userId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GROUP MANAGEMENT METHODS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a new group
|
||||
*/
|
||||
async createGroup(name: string, path: string, parentPath?: string): Promise<string> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const groupData = {
|
||||
name: name,
|
||||
path: path
|
||||
};
|
||||
|
||||
let url = `${adminBaseUrl}/groups`;
|
||||
if (parentPath) {
|
||||
// Find parent group ID first
|
||||
const parentId = await this.getGroupByPath(parentPath);
|
||||
url = `${adminBaseUrl}/groups/${parentId}/children`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
},
|
||||
body: JSON.stringify(groupData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to create group: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const locationHeader = response.headers.get('location');
|
||||
const groupId = locationHeader?.split('/').pop() || '';
|
||||
|
||||
console.log(`[keycloak-admin] Created group: ${name} with ID: ${groupId}`);
|
||||
return groupId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group by path
|
||||
*/
|
||||
async getGroupByPath(path: string): Promise<string> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/groups?search=${encodeURIComponent(path)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to find group: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const groups: KeycloakGroupRepresentation[] = await response.json();
|
||||
const group = groups.find(g => g.path === path);
|
||||
|
||||
if (!group?.id) {
|
||||
throw new Error(`Group not found: ${path}`);
|
||||
}
|
||||
|
||||
return group.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign user to group
|
||||
*/
|
||||
async assignUserToGroup(userId: string, groupId: string): Promise<void> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const response = await fetch(`${adminBaseUrl}/users/${userId}/groups/${groupId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to assign user to group: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
console.log(`[keycloak-admin] Assigned user ${userId} to group ${groupId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ADVANCED EMAIL WORKFLOWS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Send custom email workflows
|
||||
*/
|
||||
async sendCustomEmail(userId: string, emailData: EmailWorkflowData): Promise<void> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const emailUrl = new URL(`${adminBaseUrl}/users/${userId}/execute-actions-email`);
|
||||
|
||||
// Configure email based on type
|
||||
switch (emailData.emailType) {
|
||||
case 'DUES_REMINDER':
|
||||
emailUrl.searchParams.set('lifespan', emailData.lifespan?.toString() || '259200'); // 3 days
|
||||
if (emailData.customData?.dueAmount) {
|
||||
emailUrl.searchParams.set('dueAmount', emailData.customData.dueAmount);
|
||||
}
|
||||
break;
|
||||
case 'MEMBERSHIP_RENEWAL':
|
||||
emailUrl.searchParams.set('lifespan', emailData.lifespan?.toString() || '604800'); // 1 week
|
||||
if (emailData.customData?.renewalDate) {
|
||||
emailUrl.searchParams.set('renewalDate', emailData.customData.renewalDate);
|
||||
}
|
||||
break;
|
||||
case 'WELCOME':
|
||||
emailUrl.searchParams.set('lifespan', emailData.lifespan?.toString() || '43200'); // 12 hours
|
||||
break;
|
||||
case 'VERIFICATION':
|
||||
emailUrl.searchParams.set('lifespan', emailData.lifespan?.toString() || '86400'); // 24 hours
|
||||
break;
|
||||
}
|
||||
|
||||
if (emailData.redirectUri) {
|
||||
emailUrl.searchParams.set('redirect_uri', emailData.redirectUri);
|
||||
}
|
||||
|
||||
emailUrl.searchParams.set('client_id', this.config.clientId);
|
||||
|
||||
const response = await fetch(emailUrl.toString(), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
},
|
||||
body: JSON.stringify([emailData.emailType])
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to send ${emailData.emailType} email: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
console.log(`[keycloak-admin] Sent ${emailData.emailType} email to user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send enhanced verification email
|
||||
*/
|
||||
async sendVerificationEmail(userId: string, redirectUri?: string): Promise<void> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
const emailUrl = new URL(`${adminBaseUrl}/users/${userId}/send-verify-email`);
|
||||
if (redirectUri) {
|
||||
emailUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
}
|
||||
emailUrl.searchParams.set('client_id', this.config.clientId);
|
||||
|
||||
const response = await fetch(emailUrl.toString(), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'User-Agent': 'MonacoUSA-Portal/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to send verification email: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
console.log(`[keycloak-admin] Sent verification email to user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email to a user
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user