diff --git a/components/EditMemberDialog.vue b/components/EditMemberDialog.vue index 00d4d05..540029d 100644 --- a/components/EditMemberDialog.vue +++ b/components/EditMemberDialog.vue @@ -172,6 +172,52 @@ :error-messages="getFieldError('payment_due_date')" /> + + + @@ -233,7 +279,8 @@ const form = ref({ member_since: '', current_year_dues_paid: 'false', membership_date_paid: '', - payment_due_date: '' + payment_due_date: '', + portal_group: 'user' }); // Additional form state @@ -243,6 +290,71 @@ const phoneData = ref(null); // Error handling const fieldErrors = ref>({}); +// Auth state +const { user, isAdmin } = useAuth(); + +// Portal group management +const groupLoading = ref(false); +const groupSyncStatus = ref<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null); +const originalPortalGroup = ref('user'); + +const portalGroupOptions = [ + { title: 'User - Basic Access', value: 'user' }, + { title: 'Board Member - Extended Access', value: 'board' }, + { title: 'Administrator - Full Access', value: 'admin' } +]; + +// Watch for portal group changes and sync with Keycloak +watch(() => form.value.portal_group, async (newGroup, oldGroup) => { + if (!props.member?.keycloak_id || !isAdmin || newGroup === oldGroup || newGroup === originalPortalGroup.value) { + return; + } + + console.log('[EditMemberDialog] Portal group changed:', oldGroup, '->', newGroup); + + groupLoading.value = true; + groupSyncStatus.value = null; + + try { + console.log('[EditMemberDialog] Updating Keycloak groups for member:', props.member.Id); + + const response = await $fetch(`/api/members/${props.member.Id}/keycloak-groups`, { + method: 'PUT', + body: { newGroup } + }); + + if (response.success) { + groupSyncStatus.value = { + type: 'success', + message: `Successfully changed access level to ${newGroup}` + }; + originalPortalGroup.value = newGroup; // Update original to prevent re-trigger + console.log('[EditMemberDialog] Group change successful:', response.data); + } else { + throw new Error(response.message || 'Failed to update access level'); + } + + } catch (error: any) { + console.error('[EditMemberDialog] Failed to update Keycloak groups:', error); + + groupSyncStatus.value = { + type: 'error', + message: error.data?.message || error.message || 'Failed to update access level' + }; + + // Revert the form value on error + form.value.portal_group = oldGroup || 'user'; + + } finally { + groupLoading.value = false; + + // Clear status after 5 seconds + setTimeout(() => { + groupSyncStatus.value = null; + }, 5000); + } +}); + // Watch dues paid switch watch(duesPaid, (newValue) => { form.value.current_year_dues_paid = newValue ? 'true' : 'false'; @@ -334,7 +446,8 @@ const populateForm = () => { member_since: formatDateForInput(member.member_since || ''), current_year_dues_paid: member.current_year_dues_paid || 'false', membership_date_paid: formatDateForInput(member.membership_date_paid || ''), - payment_due_date: formatDateForInput(member.payment_due_date || '') + payment_due_date: formatDateForInput(member.payment_due_date || ''), + portal_group: member.portal_group || 'user' }; // Set dues paid switch based on the string value diff --git a/server/api/admin/nocodb-config.get.ts b/server/api/admin/nocodb-config.get.ts index fe06c2a..1c8250d 100644 --- a/server/api/admin/nocodb-config.get.ts +++ b/server/api/admin/nocodb-config.get.ts @@ -28,8 +28,16 @@ export default defineEventHandler(async (event) => { console.log('[api/admin/nocodb-config.get] Admin access confirmed for:', session.user.email); - // Get current configuration using the new admin config system - const { getCurrentConfig } = await import('~/server/utils/admin-config'); + // Force reload and get current configuration using the new admin config system + const { getCurrentConfig, reloadAdminConfig } = await import('~/server/utils/admin-config'); + + // Force reload configuration to ensure we have the latest settings + try { + await reloadAdminConfig(); + } catch (error) { + console.warn('[api/admin/nocodb-config.get] Failed to reload config, using cached version:', error); + } + const settings = await getCurrentConfig(); console.log('[api/admin/nocodb-config.get] ✅ Settings retrieved successfully'); diff --git a/server/api/admin/recaptcha-config.get.ts b/server/api/admin/recaptcha-config.get.ts index 3f431f3..7155615 100644 --- a/server/api/admin/recaptcha-config.get.ts +++ b/server/api/admin/recaptcha-config.get.ts @@ -24,8 +24,16 @@ export default defineEventHandler(async (event) => { console.log('[api/admin/recaptcha-config.get] Authorized admin:', session.user.email); - // Get reCAPTCHA configuration - const { getRecaptchaConfig } = await import('~/server/utils/admin-config'); + // Force reload and get reCAPTCHA configuration + const { getRecaptchaConfig, reloadAdminConfig } = await import('~/server/utils/admin-config'); + + // Force reload configuration to ensure we have the latest settings + try { + await reloadAdminConfig(); + } catch (error) { + console.warn('[api/admin/recaptcha-config.get] Failed to reload config, using cached version:', error); + } + const config = getRecaptchaConfig(); return { diff --git a/server/api/admin/registration-config.get.ts b/server/api/admin/registration-config.get.ts index 533bcf6..a49a180 100644 --- a/server/api/admin/registration-config.get.ts +++ b/server/api/admin/registration-config.get.ts @@ -24,8 +24,16 @@ export default defineEventHandler(async (event) => { console.log('[api/admin/registration-config.get] Authorized admin:', session.user.email); - // Get registration configuration - const { getRegistrationConfig } = await import('~/server/utils/admin-config'); + // Force reload and get registration configuration + const { getRegistrationConfig, reloadAdminConfig } = await import('~/server/utils/admin-config'); + + // Force reload configuration to ensure we have the latest settings + try { + await reloadAdminConfig(); + } catch (error) { + console.warn('[api/admin/registration-config.get] Failed to reload config, using cached version:', error); + } + const config = getRegistrationConfig(); return { diff --git a/server/api/admin/smtp-config.get.ts b/server/api/admin/smtp-config.get.ts index 59ff39d..35c1ed7 100644 --- a/server/api/admin/smtp-config.get.ts +++ b/server/api/admin/smtp-config.get.ts @@ -24,9 +24,24 @@ export default defineEventHandler(async (event) => { console.log('[api/admin/smtp-config.get] Authorized admin:', session.user.email); - // Get SMTP configuration - const { getSMTPConfig } = await import('~/server/utils/admin-config'); + // Force reload and get SMTP configuration + const { getSMTPConfig, reloadAdminConfig } = await import('~/server/utils/admin-config'); + + // Force reload configuration to ensure we have the latest settings + try { + await reloadAdminConfig(); + } catch (error) { + console.warn('[api/admin/smtp-config.get] Failed to reload config, using cached version:', error); + } + const config = getSMTPConfig(); + console.log('[api/admin/smtp-config.get] Current SMTP config status:', { + host: config.host || 'not set', + port: config.port || 'not set', + hasUsername: !!config.username, + hasPassword: !!config.password, + fromAddress: config.fromAddress || 'not set' + }); // Hide password for security const safeConfig = { diff --git a/server/api/members/[id]/create-portal-account.post.ts b/server/api/members/[id]/create-portal-account.post.ts index 70a8cf5..ad8093a 100644 --- a/server/api/members/[id]/create-portal-account.post.ts +++ b/server/api/members/[id]/create-portal-account.post.ts @@ -99,9 +99,9 @@ export default defineEventHandler(async (event) => { 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({ + // 7. Create Keycloak user with group-based registration + console.log('[api/members/[id]/create-portal-account.post] Creating Keycloak user with group-based system...'); + const keycloakId = await keycloakAdmin.createUserWithGroupAssignment({ email: member.email, firstName: member.first_name, lastName: member.last_name, @@ -192,16 +192,14 @@ export default defineEventHandler(async (event) => { * 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 + // Use stored portal_group value if available and valid + if (member.portal_group && ['user', 'board', 'admin'].includes(member.portal_group)) { + console.log(`[determineMembershipTier] Using stored portal_group: ${member.portal_group}`); + return member.portal_group as 'user' | 'board' | 'admin'; + } - // 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. + // Fallback logic for legacy data or when portal_group is not set + console.log('[determineMembershipTier] No valid portal_group found, using fallback logic'); // Example logic (uncomment and modify as needed): /* @@ -215,5 +213,6 @@ function determineMembershipTier(member: any): 'user' | 'board' | 'admin' { */ // Default to user tier for all members + console.log('[determineMembershipTier] Defaulting to user tier'); return 'user'; } diff --git a/server/api/members/[id]/keycloak-groups.get.ts b/server/api/members/[id]/keycloak-groups.get.ts new file mode 100644 index 0000000..0e4602e --- /dev/null +++ b/server/api/members/[id]/keycloak-groups.get.ts @@ -0,0 +1,124 @@ +export default defineEventHandler(async (event) => { + console.log('[api/members/[id]/keycloak-groups.get] ========================='); + console.log('[api/members/[id]/keycloak-groups.get] GET /api/members/:id/keycloak-groups - Get member Keycloak groups'); + + 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 group management + if (session.user.tier !== 'admin') { + throw createError({ + statusCode: 403, + statusMessage: 'Admin privileges required' + }); + } + + console.log('[api/members/[id]/keycloak-groups.get] Authorized admin user:', session.user.email); + + // 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]/keycloak-groups.get] 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' + }); + } + + // 2. Check if member has portal account + if (!member.keycloak_id) { + console.log('[api/members/[id]/keycloak-groups.get] Member has no portal account'); + throw createError({ + statusCode: 404, + statusMessage: 'Member has no portal account' + }); + } + + console.log('[api/members/[id]/keycloak-groups.get] Found member with Keycloak ID:', member.keycloak_id); + + // 3. Get user's current groups from Keycloak + const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin'); + const keycloakAdmin = createKeycloakAdminClient(); + + console.log('[api/members/[id]/keycloak-groups.get] Fetching user groups from Keycloak...'); + const userGroups = await keycloakAdmin.getUserGroups(member.keycloak_id); + + // 4. Determine primary group (user/board/admin) + const primaryGroups = userGroups.filter(g => ['user', 'board', 'admin'].includes(g.name || '')); + const primaryGroup = primaryGroups.length > 0 ? primaryGroups[0].name : 'user'; + + console.log('[api/members/[id]/keycloak-groups.get] Primary group determined:', primaryGroup); + console.log('[api/members/[id]/keycloak-groups.get] Total groups:', userGroups.length); + + // 5. Compare with database portal_group field + const databaseGroup = member.portal_group || 'user'; + const groupsInSync = primaryGroup === databaseGroup; + + console.log('[api/members/[id]/keycloak-groups.get] ✅ Successfully retrieved group information'); + + return { + success: true, + data: { + member_id: memberId, + keycloak_id: member.keycloak_id, + primary_group: primaryGroup, + database_group: databaseGroup, + groups_in_sync: groupsInSync, + all_groups: userGroups.map(g => ({ + id: g.id, + name: g.name, + path: g.path + })), + primary_groups: primaryGroups.map(g => ({ + id: g.id, + name: g.name, + path: g.path + })) + } + }; + + } catch (error: any) { + console.error('[api/members/[id]/keycloak-groups.get] ❌ Failed to get member groups:', error); + + // If it's already an HTTP error, re-throw it + if (error.statusCode) { + throw error; + } + + // Handle specific Keycloak errors + if (error.message?.includes('Failed to get user groups')) { + throw createError({ + statusCode: 500, + statusMessage: 'Failed to retrieve user groups from Keycloak. Check service account permissions.' + }); + } + + // Otherwise, wrap it in a generic error + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to get member Keycloak groups' + }); + } +}); diff --git a/server/api/members/[id]/keycloak-groups.put.ts b/server/api/members/[id]/keycloak-groups.put.ts new file mode 100644 index 0000000..8b1a053 --- /dev/null +++ b/server/api/members/[id]/keycloak-groups.put.ts @@ -0,0 +1,178 @@ +export default defineEventHandler(async (event) => { + console.log('[api/members/[id]/keycloak-groups.put] ========================='); + console.log('[api/members/[id]/keycloak-groups.put] PUT /api/members/:id/keycloak-groups - Update member Keycloak groups'); + + 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 group management + if (session.user.tier !== 'admin') { + throw createError({ + statusCode: 403, + statusMessage: 'Admin privileges required' + }); + } + + console.log('[api/members/[id]/keycloak-groups.put] Authorized admin user:', session.user.email); + + // Get member ID from route parameter + const memberId = getRouterParam(event, 'id'); + if (!memberId) { + throw createError({ + statusCode: 400, + statusMessage: 'Member ID is required' + }); + } + + // Parse request body + const body = await readBody(event); + const { newGroup } = body; + + console.log('[api/members/[id]/keycloak-groups.put] Processing member ID:', memberId, 'new group:', newGroup); + + // Validate new group + if (!['user', 'board', 'admin'].includes(newGroup)) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid group. Must be one of: user, board, admin' + }); + } + + // 1. Get member data + const { getMemberById, updateMember } = await import('~/server/utils/nocodb'); + const member = await getMemberById(memberId); + + if (!member) { + throw createError({ + statusCode: 404, + statusMessage: 'Member not found' + }); + } + + // 2. Check if member has portal account + if (!member.keycloak_id) { + console.log('[api/members/[id]/keycloak-groups.put] Member has no portal account'); + throw createError({ + statusCode: 404, + statusMessage: 'Member has no portal account' + }); + } + + console.log('[api/members/[id]/keycloak-groups.put] Found member with Keycloak ID:', member.keycloak_id); + + // 3. Get current group status + const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin'); + const keycloakAdmin = createKeycloakAdminClient(); + + console.log('[api/members/[id]/keycloak-groups.put] Getting current groups...'); + const currentGroups = await keycloakAdmin.getUserGroups(member.keycloak_id); + const currentPrimaryGroups = currentGroups.filter(g => ['user', 'board', 'admin'].includes(g.name || '')); + const currentPrimaryGroup = currentPrimaryGroups.length > 0 ? currentPrimaryGroups[0].name : 'user'; + + console.log('[api/members/[id]/keycloak-groups.put] Current primary group:', currentPrimaryGroup); + + // 4. Check if change is needed + if (currentPrimaryGroup === newGroup) { + console.log('[api/members/[id]/keycloak-groups.put] User already in target group, syncing database...'); + + // Still sync database to ensure consistency + await updateMember(memberId, { portal_group: newGroup }); + + return { + success: true, + message: `User was already in ${newGroup} group. Database synced.`, + data: { + member_id: memberId, + keycloak_id: member.keycloak_id, + old_group: currentPrimaryGroup, + new_group: newGroup, + changed: false + } + }; + } + + // 5. Change user's primary group in Keycloak + console.log('[api/members/[id]/keycloak-groups.put] Changing user primary group in Keycloak...'); + await keycloakAdmin.changeUserPrimaryGroup(member.keycloak_id, newGroup); + + // 6. Update database portal_group field + console.log('[api/members/[id]/keycloak-groups.put] Updating database portal_group field...'); + await updateMember(memberId, { portal_group: newGroup }); + + // 7. Verify the change + console.log('[api/members/[id]/keycloak-groups.put] Verifying group change...'); + const updatedGroups = await keycloakAdmin.getUserGroups(member.keycloak_id); + const newPrimaryGroups = updatedGroups.filter(g => ['user', 'board', 'admin'].includes(g.name || '')); + const verifiedNewGroup = newPrimaryGroups.length > 0 ? newPrimaryGroups[0].name : 'user'; + + const changeSuccessful = verifiedNewGroup === newGroup; + + if (changeSuccessful) { + console.log('[api/members/[id]/keycloak-groups.put] ✅ Group change successful and verified'); + } else { + console.error('[api/members/[id]/keycloak-groups.put] ❌ Group change verification failed'); + } + + return { + success: changeSuccessful, + message: changeSuccessful + ? `Successfully changed user from ${currentPrimaryGroup} to ${newGroup} group` + : `Group change attempted but verification failed. Current group: ${verifiedNewGroup}`, + data: { + member_id: memberId, + keycloak_id: member.keycloak_id, + old_group: currentPrimaryGroup, + new_group: newGroup, + verified_group: verifiedNewGroup, + changed: changeSuccessful, + database_updated: true + } + }; + + } catch (error: any) { + console.error('[api/members/[id]/keycloak-groups.put] ❌ Failed to update member groups:', error); + + // If it's already an HTTP error, re-throw it + if (error.statusCode) { + throw error; + } + + // Handle specific Keycloak errors + if (error.message?.includes('Failed to get user groups')) { + throw createError({ + statusCode: 500, + statusMessage: 'Failed to retrieve user groups from Keycloak. Check service account permissions.' + }); + } + + if (error.message?.includes('Invalid group name')) { + throw createError({ + statusCode: 400, + statusMessage: error.message + }); + } + + if (error.message?.includes('Group not found')) { + throw createError({ + statusCode: 500, + statusMessage: `Keycloak group not found. Ensure the groups 'user', 'board', and 'admin' exist in Keycloak.` + }); + } + + // Otherwise, wrap it in a generic error + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to update member Keycloak groups' + }); + } +}); diff --git a/server/utils/admin-config.ts b/server/utils/admin-config.ts index a1a5769..29ca773 100644 --- a/server/utils/admin-config.ts +++ b/server/utils/admin-config.ts @@ -566,6 +566,35 @@ export function getSMTPConfig(): SMTPConfig { return config; } +/** + * Force reload configuration from file and update cache + */ +export async function reloadAdminConfig(): Promise { + console.log('[admin-config] Force reloading configuration from file...'); + + try { + // Clear cache to force reload + configCache = null; + + // Load fresh from file + await loadAdminConfig(); + + // Update global nocodb configuration + try { + const { setGlobalNocoDBConfig } = await import('./nocodb'); + setGlobalNocoDBConfig(getEffectiveNocoDBConfig()); + console.log('[admin-config] Global configuration updated after reload'); + } catch (error) { + console.error('[admin-config] Failed to update global configuration after reload:', error); + } + + console.log('[admin-config] ✅ Configuration reloaded successfully'); + } catch (error) { + console.error('[admin-config] ❌ Failed to reload configuration:', error); + throw error; + } +} + /** * Initialize configuration system on server startup */ @@ -574,9 +603,59 @@ export async function initAdminConfig(): Promise { try { await ensureConfigDir(); - await loadAdminConfig(); + + // Load configuration and initialize cache + const config = await loadAdminConfig(); + + if (config) { + console.log('[admin-config] ✅ Configuration loaded from existing file'); + console.log('[admin-config] Configuration summary:', { + nocodb: { + url: config.nocodb.url || 'not set', + hasApiKey: !!config.nocodb.apiKey, + baseId: config.nocodb.baseId || 'not set' + }, + smtp: { + host: config.smtp?.host || 'not set', + port: config.smtp?.port || 'not set', + hasAuth: !!(config.smtp?.username && config.smtp?.password) + }, + recaptcha: { + hasSiteKey: !!config.recaptcha?.siteKey, + hasSecretKey: !!config.recaptcha?.secretKey + }, + registration: { + membershipFee: config.registration?.membershipFee || 'not set', + hasIban: !!config.registration?.iban + } + }); + } else { + console.log('[admin-config] ⚠️ No existing configuration file found, using defaults'); + } + console.log('[admin-config] ✅ Admin configuration system initialized'); } catch (error) { console.error('[admin-config] ❌ Failed to initialize admin configuration:', error); } } + +/** + * Get configuration status for health checks + */ +export function getConfigStatus(): { + loaded: boolean; + nocodb: boolean; + smtp: boolean; + recaptcha: boolean; + registration: boolean; +} { + const config = configCache; + + return { + loaded: !!config, + nocodb: !!(config?.nocodb?.url && config?.nocodb?.apiKey && config?.nocodb?.baseId), + smtp: !!(config?.smtp?.host && config?.smtp?.port), + recaptcha: !!(config?.recaptcha?.siteKey && config?.recaptcha?.secretKey), + registration: !!(config?.registration?.membershipFee && config?.registration?.iban) + }; +} diff --git a/server/utils/email.ts b/server/utils/email.ts index 2a6ed74..0957325 100644 --- a/server/utils/email.ts +++ b/server/utils/email.ts @@ -443,13 +443,31 @@ let emailServiceInstance: EmailService | null = null; * Get or create EmailService instance with current SMTP config */ export async function getEmailService(): Promise { - const { getSMTPConfig } = await import('./admin-config'); + const { getSMTPConfig, reloadAdminConfig } = await import('./admin-config'); + + // Force reload configuration to ensure we have the latest settings + try { + await reloadAdminConfig(); + } catch (error) { + console.warn('[EmailService] Failed to reload admin config, using cached version:', error); + } + const config = getSMTPConfig(); + console.log('[EmailService] Current SMTP config:', { + host: config.host || 'not set', + port: config.port || 'not set', + secure: config.secure, + hasUsername: !!config.username, + hasPassword: !!config.password, + fromAddress: config.fromAddress || 'not set', + fromName: config.fromName || 'not set' + }); + if (!emailServiceInstance) { emailServiceInstance = new EmailService(config); } else { - // Update config in case it changed + // Always update config in case it changed emailServiceInstance.updateConfig(config); } diff --git a/server/utils/keycloak-admin.ts b/server/utils/keycloak-admin.ts index a411bbb..3874910 100644 --- a/server/utils/keycloak-admin.ts +++ b/server/utils/keycloak-admin.ts @@ -225,32 +225,60 @@ export class KeycloakAdminClient { } /** - * Assign a realm role to a user + * Assign a realm role to a user (requires proper Keycloak admin permissions) */ async assignRealmRoleToUser(userId: string, roleName: string): Promise { 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); + console.log(`[keycloak-admin] Assigning realm role ${roleName} to user ${userId}`); - // 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]) - }); + try { + // First try to get the role to ensure it exists + 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}`); + 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] ✅ Successfully assigned role ${roleName} to user ${userId}`); + } catch (error: any) { + // Provide detailed error information for troubleshooting + if (error.message?.includes('403')) { + console.error(`[keycloak-admin] ❌ Permission denied when assigning role ${roleName} to user ${userId}:`); + console.error(`[keycloak-admin] The Keycloak service account needs the following permissions:`); + console.error(`[keycloak-admin] 1. 'view-realm' permission to read realm roles`); + console.error(`[keycloak-admin] 2. 'manage-users' permission to assign roles to users`); + console.error(`[keycloak-admin] 3. Access to the '${roleName}' realm role`); + console.warn(`[keycloak-admin] User ${userId} created successfully but role ${roleName} could not be assigned due to insufficient permissions.`); + console.warn(`[keycloak-admin] Please assign the role manually in Keycloak admin console.`); + // Don't throw - allow user creation to complete + return; + } else if (error.message?.includes('404')) { + console.error(`[keycloak-admin] ❌ Role ${roleName} does not exist in Keycloak realm.`); + console.error(`[keycloak-admin] Please create the '${roleName}' role in Keycloak admin console.`); + console.warn(`[keycloak-admin] User ${userId} created successfully but role ${roleName} does not exist.`); + // Don't throw - allow user creation to complete + return; + } else { + console.error(`[keycloak-admin] ❌ Unexpected error assigning role ${roleName} to user ${userId}:`, error); + console.warn(`[keycloak-admin] User ${userId} created successfully but role assignment failed.`); + // Don't throw - allow user creation to complete + return; + } } - - console.log(`[keycloak-admin] Assigned role ${roleName} to user ${userId}`); } /** @@ -393,9 +421,9 @@ export class KeycloakAdminClient { } /** - * Create user with role-based registration (enhanced version) + * Create user with group-based registration (replaces role-based method) */ - async createUserWithRoleRegistration(userData: { + async createUserWithGroupAssignment(userData: { email: string; firstName: string; lastName: string; @@ -406,6 +434,8 @@ export class KeycloakAdminClient { const adminToken = await this.getAdminToken(); const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/'); + console.log(`[keycloak-admin] Creating user with group assignment: ${userData.email}, tier: ${userData.membershipTier || 'user'}`); + // Check if user already exists const existingUsers = await this.findUserByEmail(userData.email, adminToken); if (existingUsers.length > 0) { @@ -461,19 +491,42 @@ export class KeycloakAdminClient { throw new Error('Failed to extract user ID from response'); } - // Assign appropriate realm role - const roleName = `monaco-${userData.membershipTier || 'user'}`; + console.log(`[keycloak-admin] Created user ${userData.email} with ID: ${userId}`); + + // Assign appropriate group instead of role + const groupName = userData.membershipTier || 'user'; + console.log(`[keycloak-admin] Assigning user to group: ${groupName}`); + 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 + const groupId = await this.getGroupByPath(groupName); + await this.assignUserToGroup(userId, groupId); + console.log(`[keycloak-admin] ✅ Successfully assigned user ${userId} to group: ${groupName}`); + } catch (error: any) { + console.warn(`[keycloak-admin] ⚠️ Failed to assign user ${userId} to group ${groupName}:`, error); + console.warn(`[keycloak-admin] User will receive default group assignment from Keycloak realm settings`); + // Don't fail the entire operation - user gets default group automatically } - console.log(`[keycloak-admin] Created user ${userData.email} with ID: ${userId} and role: ${roleName}`); + console.log(`[keycloak-admin] ✅ User creation with group assignment completed: ${userData.email}`); return userId; } + /** + * @deprecated Use createUserWithGroupAssignment() instead + * This method has permission issues with role assignment + */ + async createUserWithRoleRegistration(userData: { + email: string; + firstName: string; + lastName: string; + username?: string; + membershipTier?: 'user' | 'board' | 'admin'; + membershipData?: MembershipProfileData; + }): Promise { + console.warn('[keycloak-admin] createUserWithRoleRegistration is deprecated. Use createUserWithGroupAssignment instead.'); + throw new Error('Method deprecated. Use createUserWithGroupAssignment() instead - it resolves permission issues.'); + } + // ============================================================================ // SESSION MANAGEMENT METHODS // ============================================================================ @@ -644,6 +697,99 @@ export class KeycloakAdminClient { console.log(`[keycloak-admin] Assigned user ${userId} to group ${groupId}`); } + /** + * Get all groups for a user + */ + async getUserGroups(userId: string): Promise { + const adminToken = await this.getAdminToken(); + const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/'); + + console.log(`[keycloak-admin] Getting groups for user: ${userId}`); + + const response = await fetch(`${adminBaseUrl}/users/${userId}/groups`, { + 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 get user groups: ${response.status} - ${errorText}`); + } + + const groups = await response.json(); + console.log(`[keycloak-admin] ✅ Retrieved ${groups.length} groups for user ${userId}`); + return groups; + } + + /** + * Remove user from a group + */ + async removeUserFromGroup(userId: string, groupId: string): Promise { + const adminToken = await this.getAdminToken(); + const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/'); + + console.log(`[keycloak-admin] Removing user ${userId} from group ${groupId}`); + + const response = await fetch(`${adminBaseUrl}/users/${userId}/groups/${groupId}`, { + 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 remove user from group: ${response.status} - ${errorText}`); + } + + console.log(`[keycloak-admin] ✅ Removed user ${userId} from group ${groupId}`); + } + + /** + * Change user's primary group (remove from old primary groups, add to new) + */ + async changeUserPrimaryGroup(userId: string, newGroupName: string): Promise { + console.log(`[keycloak-admin] Changing user ${userId} primary group to: ${newGroupName}`); + + if (!['user', 'board', 'admin'].includes(newGroupName)) { + throw new Error(`Invalid group name: ${newGroupName}. Must be one of: user, board, admin`); + } + + try { + // Get current user groups + const currentGroups = await this.getUserGroups(userId); + const primaryGroups = currentGroups.filter(g => ['user', 'board', 'admin'].includes(g.name || '')); + + console.log(`[keycloak-admin] User currently in ${primaryGroups.length} primary groups: ${primaryGroups.map(g => g.name).join(', ')}`); + + // Remove from old primary groups + for (const group of primaryGroups) { + if (group.id && group.name !== newGroupName) { + console.log(`[keycloak-admin] Removing user from old group: ${group.name}`); + await this.removeUserFromGroup(userId, group.id); + } + } + + // Add to new group (if not already in it) + const alreadyInNewGroup = primaryGroups.some(g => g.name === newGroupName); + if (!alreadyInNewGroup) { + console.log(`[keycloak-admin] Adding user to new group: ${newGroupName}`); + const newGroupId = await this.getGroupByPath(newGroupName); + await this.assignUserToGroup(userId, newGroupId); + } else { + console.log(`[keycloak-admin] User already in target group: ${newGroupName}`); + } + + console.log(`[keycloak-admin] ✅ Successfully changed user ${userId} primary group to: ${newGroupName}`); + } catch (error: any) { + console.error(`[keycloak-admin] ❌ Error changing user primary group:`, error); + throw error; + } + } + // ============================================================================ // ADVANCED EMAIL WORKFLOWS // ============================================================================ diff --git a/utils/types.ts b/utils/types.ts index 2898da8..0834b79 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -133,6 +133,7 @@ export interface Member { member_since: string; keycloak_id?: string; // New field for linking to Keycloak user registration_date?: string; // New field for tracking registration date + portal_group?: 'user' | 'board' | 'admin'; // Portal access level group // Computed fields (added by processing) FullName?: string;