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')"
/>
+
+
+
+
+
+ Portal Access Control
+
+
+
+
+
+ mdi-shield-account
+
+
+
+
+
+
+
+ mdi-check-circle
+ Portal Account Active
+
+
+
@@ -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;