953 lines
33 KiB
TypeScript
953 lines
33 KiB
TypeScript
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<string> {
|
|
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<any[]> {
|
|
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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
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<KeycloakRoleRepresentation> {
|
|
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<KeycloakRoleRepresentation[]> {
|
|
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 (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/');
|
|
|
|
console.log(`[keycloak-admin] Assigning realm role ${roleName} to user ${userId}`);
|
|
|
|
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}`);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a realm role from a user
|
|
*/
|
|
async removeRealmRoleFromUser(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);
|
|
|
|
// 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<KeycloakRoleRepresentation[]> {
|
|
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<KeycloakUserRepresentation> {
|
|
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<void> {
|
|
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 group-based registration (replaces role-based method)
|
|
*/
|
|
async createUserWithGroupAssignment(userData: {
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
username?: string;
|
|
membershipTier?: 'user' | 'board' | 'admin';
|
|
membershipData?: MembershipProfileData;
|
|
}): Promise<string> {
|
|
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) {
|
|
throw new Error('User with this email already exists');
|
|
}
|
|
|
|
// Build user attributes
|
|
const attributes: Record<string, string[]> = {
|
|
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');
|
|
}
|
|
|
|
console.log(`[keycloak-admin] Created user ${userData.email} with ID: ${userId}`);
|
|
|
|
// Assign appropriate group instead of role
|
|
const groupName = userData.membershipTier || 'user';
|
|
const groupPath = `/${groupName}`; // Keycloak groups use paths with leading slash
|
|
console.log(`[keycloak-admin] Assigning user to group: ${groupName} (path: ${groupPath})`);
|
|
|
|
try {
|
|
const groupId = await this.getGroupByPath(groupPath);
|
|
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] ✅ 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
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get all active sessions for a user
|
|
*/
|
|
async getUserSessions(userId: string): Promise<UserSessionRepresentation[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string> {
|
|
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<string> {
|
|
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<void> {
|
|
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}`);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Send custom email workflows
|
|
*/
|
|
async sendCustomEmail(userId: string, emailData: EmailWorkflowData): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|