Add Keycloak group management for member portal access control
All checks were successful
Build And Push Image / docker (push) Successful in 3m53s
All checks were successful
Build And Push Image / docker (push) Successful in 3m53s
- Add portal access control section to EditMemberDialog for admins - Implement API endpoints for managing member Keycloak groups - Add group selection UI with user/board/admin access levels - Enhance admin config with reload functionality - Support real-time group synchronization and status feedback
This commit is contained in:
@@ -566,6 +566,35 @@ export function getSMTPConfig(): SMTPConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reload configuration from file and update cache
|
||||
*/
|
||||
export async function reloadAdminConfig(): Promise<void> {
|
||||
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<void> {
|
||||
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -443,13 +443,31 @@ let emailServiceInstance: EmailService | null = null;
|
||||
* Get or create EmailService instance with current SMTP config
|
||||
*/
|
||||
export async function getEmailService(): Promise<EmailService> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
const adminToken = await this.getAdminToken();
|
||||
const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/');
|
||||
|
||||
// First get the role
|
||||
const role = await this.getRealmRole(roleName, adminToken);
|
||||
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<string> {
|
||||
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<KeycloakGroupRepresentation[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user