import type { KeycloakAdminConfig, KeycloakUserRepresentation, KeycloakRoleRepresentation, KeycloakGroupRepresentation, UserSessionRepresentation, EmailWorkflowData, MembershipProfileData } from '~/utils/types'; export class KeycloakAdminClient { private config: KeycloakAdminConfig; constructor(config: KeycloakAdminConfig) { this.config = config; } /** * Get an admin access token using client credentials grant */ async getAdminToken(): Promise { const response = await fetch(`${this.config.issuer}/protocol/openid-connect/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'MonacoUSA-Portal/1.0' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: this.config.clientId, client_secret: this.config.clientSecret }) }); if (!response.ok) { const errorText = await response.text().catch(() => 'Unknown error'); throw new Error(`Failed to get admin token: ${response.status} - ${errorText}`); } const tokenData = await response.json(); return tokenData.access_token; } /** * Find a user by email address */ 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 ${token}`, 'User-Agent': 'MonacoUSA-Portal/1.0' } }); if (!response.ok) { const errorText = await response.text().catch(() => 'Unknown error'); throw new Error(`Failed to search users: ${response.status} - ${errorText}`); } 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 */ async sendPasswordResetEmail(userId: string, adminToken: string, portalClientId: string, callbackUrl: string): Promise { const adminBaseUrl = this.config.issuer.replace('/realms/', '/admin/realms/'); const resetUrl = new URL(`${adminBaseUrl}/users/${userId}/execute-actions-email`); // Add query parameters for better email template rendering resetUrl.searchParams.set('clientId', portalClientId); resetUrl.searchParams.set('redirectUri', callbackUrl.replace('/auth/callback', '/login')); resetUrl.searchParams.set('lifespan', '43200'); // 12 hours // Create AbortController for timeout handling const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout try { const response = await fetch(resetUrl.toString(), { method: 'PUT', headers: { 'Authorization': `Bearer ${adminToken}`, 'Content-Type': 'application/json', 'User-Agent': 'MonacoUSA-Portal/1.0' }, body: JSON.stringify(['UPDATE_PASSWORD']), signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { const errorText = await response.text().catch(() => 'Unknown error'); throw new Error(`Failed to send reset email: ${response.status} - ${errorText}`); } } catch (error) { clearTimeout(timeoutId); throw error; } } } export function createKeycloakAdminClient(): KeycloakAdminClient { const config = useRuntimeConfig() as any; if (!config.keycloakAdmin?.clientId || !config.keycloakAdmin?.clientSecret || !config.keycloak?.issuer) { throw new Error('Missing Keycloak admin configuration'); } return new KeycloakAdminClient({ issuer: config.keycloak.issuer, clientId: config.keycloakAdmin.clientId, clientSecret: config.keycloakAdmin.clientSecret }); } /** * Helper function to delete a Keycloak user by ID */ export async function deleteKeycloakUser(userId: string): Promise { try { console.log(`[Keycloak] Attempting to delete user: ${userId}`); const adminClient = createKeycloakAdminClient(); await adminClient.deleteUser(userId); console.log(`[Keycloak] User ${userId} deleted successfully`); } catch (error) { console.error(`[Keycloak] Failed to delete user ${userId}:`, error); throw error; } }