diff --git a/components/AdminConfigurationDialog.vue b/components/AdminConfigurationDialog.vue new file mode 100644 index 0000000..300657e --- /dev/null +++ b/components/AdminConfigurationDialog.vue @@ -0,0 +1,669 @@ + + + + + diff --git a/components/DuesPaymentBanner.vue b/components/DuesPaymentBanner.vue new file mode 100644 index 0000000..4c5a68e --- /dev/null +++ b/components/DuesPaymentBanner.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/components/MemberCard.vue b/components/MemberCard.vue index a92f478..4b384cf 100644 --- a/components/MemberCard.vue +++ b/components/MemberCard.vue @@ -146,6 +146,45 @@ {{ isOverdue ? 'Overdue' : `Due ${formatDate(member.payment_due_date)}` }} + + +
+ + mdi-account-check + Portal Account Active + + + + mdi-account-off + No Portal Account + + + + + mdi-account-plus + Create Portal Account + +
@@ -161,17 +200,22 @@ interface Props { member: Member; canEdit?: boolean; canDelete?: boolean; + canCreatePortalAccount?: boolean; + creatingPortalAccount?: boolean; } interface Emits { (e: 'view', member: Member): void; (e: 'edit', member: Member): void; (e: 'delete', member: Member): void; + (e: 'create-portal-account', member: Member): void; } const props = withDefaults(defineProps(), { canEdit: false, - canDelete: false + canDelete: false, + canCreatePortalAccount: false, + creatingPortalAccount: false }); defineEmits(); diff --git a/composables/useAuth.ts b/composables/useAuth.ts index 74b78be..551e883 100644 --- a/composables/useAuth.ts +++ b/composables/useAuth.ts @@ -7,29 +7,131 @@ export const useAuth = () => { const loading = ref(false); const error = ref(null); - // Tier-based computed properties - const userTier = computed(() => user.value?.tier || 'user'); - const isUser = computed(() => user.value?.tier === 'user'); - const isBoard = computed(() => user.value?.tier === 'board'); - const isAdmin = computed(() => user.value?.tier === 'admin'); + // Enhanced role checking method - supports both realm roles and legacy groups + const hasRole = (roleName: string): boolean => { + if (!user.value) return false; + + // Get roles from user token (Keycloak format) + const userToken = user.value as any; // Cast for accessing token properties + + // Check realm roles first (new system) + const realmRoles = userToken.realm_access?.roles || []; + if (realmRoles.includes(roleName)) { + return true; + } + + // Check client roles (new system) + const clientRoles = userToken.resource_access || {}; + for (const clientId in clientRoles) { + const roles = clientRoles[clientId]?.roles || []; + if (roles.includes(roleName)) { + return true; + } + } + + // Fallback to legacy group system + const groups = user.value.groups || []; + return groups.includes(roleName) || groups.includes(`/${roleName}`); + }; + + // Enhanced tier-based computed properties with role support + const isUser = computed(() => { + // Check new realm roles first + if (hasRole('monaco-user')) return true; + // Fallback to legacy tier system + return user.value?.tier === 'user'; + }); + + const isBoard = computed(() => { + // Check new realm roles first + if (hasRole('monaco-board')) return true; + // Fallback to legacy tier system + return user.value?.tier === 'board'; + }); + + const isAdmin = computed(() => { + // Check new realm roles first + if (hasRole('monaco-admin')) return true; + // Fallback to legacy tier system + return user.value?.tier === 'admin'; + }); + + // Enhanced tier computation with role priority + const userTier = computed(() => { + if (hasRole('monaco-admin')) return 'admin'; + if (hasRole('monaco-board')) return 'board'; + if (hasRole('monaco-user')) return 'user'; + // Fallback to legacy tier system + return user.value?.tier || 'user'; + }); + const firstName = computed(() => { if (user.value?.firstName) return user.value.firstName; if (user.value?.name) return user.value.name.split(' ')[0]; return 'User'; }); - // Helper methods + // Enhanced helper methods const hasTier = (requiredTier: 'user' | 'board' | 'admin') => { - return user.value?.tier === requiredTier; + // Use computed userTier which handles both new and legacy systems + return userTier.value === requiredTier; }; const hasGroup = (groupName: string) => { return user.value?.groups?.includes(groupName) || false; }; - // Legacy compatibility - const hasRole = (role: string) => { - return hasGroup(role); + // New helper methods for realm roles + const hasRealmRole = (roleName: string): boolean => { + if (!user.value) return false; + const userToken = user.value as any; + const realmRoles = userToken.realm_access?.roles || []; + return realmRoles.includes(roleName); + }; + + const hasClientRole = (roleName: string, clientId?: string): boolean => { + if (!user.value) return false; + const userToken = user.value as any; + const clientRoles = userToken.resource_access || {}; + + if (clientId) { + // Check specific client + const roles = clientRoles[clientId]?.roles || []; + return roles.includes(roleName); + } else { + // Check all clients + for (const cId in clientRoles) { + const roles = clientRoles[cId]?.roles || []; + if (roles.includes(roleName)) { + return true; + } + } + return false; + } + }; + + // Get all user roles (combines realm and client roles) + const getAllRoles = (): string[] => { + if (!user.value) return []; + const userToken = user.value as any; + const roles: string[] = []; + + // Add realm roles + const realmRoles = userToken.realm_access?.roles || []; + roles.push(...realmRoles); + + // Add client roles + const clientRoles = userToken.resource_access || {}; + for (const clientId in clientRoles) { + const clientRolesList = clientRoles[clientId]?.roles || []; + roles.push(...clientRolesList); + } + + // Add legacy groups for compatibility + const groups = user.value.groups || []; + roles.push(...groups); + + return [...new Set(roles)]; // Remove duplicates }; // Direct login method @@ -205,7 +307,10 @@ export const useAuth = () => { // Helper methods hasTier, hasGroup, - hasRole, // Legacy compatibility + hasRole, // Enhanced with realm role support + hasRealmRole, + hasClientRole, + getAllRoles, // Actions login, diff --git a/layouts/dashboard.vue b/layouts/dashboard.vue index 4f4cdcc..1ca029c 100644 --- a/layouts/dashboard.vue +++ b/layouts/dashboard.vue @@ -159,6 +159,9 @@ + + + diff --git a/pages/signup.vue b/pages/signup.vue new file mode 100644 index 0000000..18eb0c0 --- /dev/null +++ b/pages/signup.vue @@ -0,0 +1,374 @@ + + + + + diff --git a/server/api/admin/cleanup-accounts.post.ts b/server/api/admin/cleanup-accounts.post.ts new file mode 100644 index 0000000..fc62431 --- /dev/null +++ b/server/api/admin/cleanup-accounts.post.ts @@ -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' + }); + } +}); diff --git a/server/api/admin/recaptcha-config.get.ts b/server/api/admin/recaptcha-config.get.ts new file mode 100644 index 0000000..3f431f3 --- /dev/null +++ b/server/api/admin/recaptcha-config.get.ts @@ -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; + } +}); diff --git a/server/api/admin/recaptcha-config.post.ts b/server/api/admin/recaptcha-config.post.ts new file mode 100644 index 0000000..19225b0 --- /dev/null +++ b/server/api/admin/recaptcha-config.post.ts @@ -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; + } +}); diff --git a/server/api/admin/registration-config.get.ts b/server/api/admin/registration-config.get.ts new file mode 100644 index 0000000..533bcf6 --- /dev/null +++ b/server/api/admin/registration-config.get.ts @@ -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; + } +}); diff --git a/server/api/admin/registration-config.post.ts b/server/api/admin/registration-config.post.ts new file mode 100644 index 0000000..3096e0f --- /dev/null +++ b/server/api/admin/registration-config.post.ts @@ -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; + } +}); diff --git a/server/api/members/[id]/create-portal-account.post.ts b/server/api/members/[id]/create-portal-account.post.ts new file mode 100644 index 0000000..a664b8b --- /dev/null +++ b/server/api/members/[id]/create-portal-account.post.ts @@ -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'; +} diff --git a/server/api/registration.post.ts b/server/api/registration.post.ts new file mode 100644 index 0000000..d141f76 --- /dev/null +++ b/server/api/registration.post.ts @@ -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 { + 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; +} diff --git a/server/utils/admin-config.ts b/server/utils/admin-config.ts index 14363e9..bf40c8a 100644 --- a/server/utils/admin-config.ts +++ b/server/utils/admin-config.ts @@ -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 { 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 { }; } +/** + * Save reCAPTCHA configuration + */ +export async function saveRecaptchaConfig(config: { siteKey: string; secretKey: string }, updatedBy: string): Promise { + 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 { + 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 */ diff --git a/server/utils/keycloak-admin.ts b/server/utils/keycloak-admin.ts index 022b311..aa8ef13 100644 --- a/server/utils/keycloak-admin.ts +++ b/server/utils/keycloak-admin.ts @@ -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 { + async findUserByEmail(email: string, adminToken?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 */ diff --git a/utils/types.ts b/utils/types.ts index 0a6a894..c2bbc51 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -130,6 +130,8 @@ export interface Member { membership_status: string; address: string; member_since: string; + keycloak_id?: string; // New field for linking to Keycloak user + registration_date?: string; // New field for tracking registration date // Computed fields (added by processing) FullName?: string; @@ -163,3 +165,216 @@ export interface MemberFilters { duesPaid?: boolean; memberSince?: string; } + +// Registration System Types +export interface RegistrationFormData { + first_name: string; + last_name: string; + email: string; + phone: string; + date_of_birth: string; + address: string; + nationality: string; + recaptcha_token: string; +} + +export interface RecaptchaConfig { + siteKey: string; + secretKey: string; +} + +export interface RegistrationConfig { + membershipFee: number; + iban: string; + accountHolder: string; +} + +// Enhanced Keycloak Admin API Types +export interface KeycloakUserRepresentation { + id?: string; + username?: string; + enabled?: boolean; + firstName?: string; + lastName?: string; + email?: string; + emailVerified?: boolean; + attributes?: Record; + groups?: string[]; + realmRoles?: string[]; + clientRoles?: Record; + createdTimestamp?: number; + requiredActions?: string[]; +} + +export interface KeycloakRoleRepresentation { + id?: string; + name?: string; + description?: string; + composite?: boolean; + clientRole?: boolean; + containerId?: string; + attributes?: Record; +} + +export interface KeycloakGroupRepresentation { + id?: string; + name?: string; + path?: string; + attributes?: Record; + realmRoles?: string[]; + clientRoles?: Record; + subGroups?: KeycloakGroupRepresentation[]; +} + +export interface UserSessionRepresentation { + id?: string; + username?: string; + userId?: string; + ipAddress?: string; + start?: number; + lastAccess?: number; + clients?: Record; +} + +export interface EmailWorkflowData { + emailType: 'DUES_REMINDER' | 'MEMBERSHIP_RENEWAL' | 'WELCOME' | 'ADMIN_NOTIFICATION' | 'VERIFICATION'; + customData?: { + dueAmount?: string; + dueDate?: string; + memberSince?: string; + renewalDate?: string; + welcomeMessage?: string; + adminNote?: string; + }; + lifespan?: number; // Email validity in seconds + redirectUri?: string; +} + +export interface MembershipProfileData { + membershipStatus?: string; + duesStatus?: 'paid' | 'unpaid' | 'overdue'; + memberSince?: string; + nationality?: string; + phone?: string; + address?: string; + registrationDate?: string; + paymentDueDate?: string; + lastLoginDate?: string; + membershipTier?: 'user' | 'board' | 'admin'; + nocodbMemberId?: string; +} + +// Enhanced User interface with role support +export interface EnhancedUser extends User { + realmRoles?: string[]; + clientRoles?: Record; + attributes?: Record; + sessions?: UserSessionRepresentation[]; + memberProfile?: MembershipProfileData; +} + +// Role management types +export interface RoleAssignmentRequest { + userId: string; + roleName: string; + roleType: 'realm' | 'client'; + clientId?: string; +} + +export interface RoleManagementResponse { + success: boolean; + assignedRoles?: string[]; + removedRoles?: string[]; + message?: string; +} + +// Group management types +export interface GroupCreationRequest { + name: string; + path: string; + parentPath?: string; + attributes?: Record; +} + +export interface GroupAssignmentRequest { + userId: string; + groupId: string; + groupPath: string; +} + +// Session management types +export interface SessionManagementRequest { + userId: string; + sessionId?: string; + action: 'get' | 'logout' | 'logoutAll'; +} + +export interface SessionAnalytics { + totalSessions: number; + activeSessions: number; + uniqueUsers: number; + sessionsToday: number; + averageSessionDuration: number; + topClientApplications: Array<{ + clientId: string; + sessionCount: number; + }>; +} + +// Enhanced authentication state with role support +export interface EnhancedAuthState extends AuthState { + realmRoles: string[]; + clientRoles: Record; + hasRole: (roleName: string) => boolean; + isUser: boolean; + isBoard: boolean; + isAdmin: boolean; +} + +// Member synchronization types +export interface MemberKeycloakSync { + memberId: string; + keycloakUserId: string; + syncDirection: 'nocodb-to-keycloak' | 'keycloak-to-nocodb' | 'bidirectional'; + syncFields: string[]; + lastSyncTimestamp: string; +} + +export interface SyncResult { + success: boolean; + syncedFields: string[]; + conflictFields?: string[]; + errors?: string[]; + timestamp: string; +} + +// Admin dashboard types +export interface AdminUserManagement { + userId: string; + email: string; + firstName?: string; + lastName?: string; + enabled: boolean; + emailVerified: boolean; + realmRoles: string[]; + groups: string[]; + activeSessions: number; + lastLogin?: string; + memberProfile?: MembershipProfileData; +} + +export interface AdminDashboardStats { + totalUsers: number; + activeUsers: number; + newRegistrationsToday: number; + totalSessions: number; + membershipStats: { + totalMembers: number; + paidMembers: number; + unpaidMembers: number; + overdueMembers: number; + }; + roleDistribution: { + [roleName: string]: number; + }; +}