strapi-plugin-keycloak-realm-users 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +485 -0
  3. package/__tests__/constants.test.mjs +207 -0
  4. package/__tests__/mocks/strapi.mjs +182 -0
  5. package/__tests__/services/audit-log.test.mjs +283 -0
  6. package/__tests__/services/keycloak-client.test.mjs +651 -0
  7. package/__tests__/services/permission.test.mjs +374 -0
  8. package/__tests__/services/realm.test.mjs +415 -0
  9. package/__tests__/services/user.test.mjs +487 -0
  10. package/__tests__/utils/errors.test.mjs +109 -0
  11. package/admin/src/components/Initializer.jsx +14 -0
  12. package/admin/src/components/RealmBadge.jsx +17 -0
  13. package/admin/src/constants.js +14 -0
  14. package/admin/src/hooks/useAuditLogs.js +142 -0
  15. package/admin/src/hooks/useKeycloakRoles.js +182 -0
  16. package/admin/src/hooks/useKeycloakUsers.js +477 -0
  17. package/admin/src/hooks/useRealmAdmins.js +249 -0
  18. package/admin/src/hooks/useRealms.js +269 -0
  19. package/admin/src/index.js +46 -0
  20. package/admin/src/pages/App.jsx +21 -0
  21. package/admin/src/pages/AuditPage/index.jsx +213 -0
  22. package/admin/src/pages/RealmsPage/RealmEditPage.jsx +791 -0
  23. package/admin/src/pages/RealmsPage/RealmListPage.jsx +231 -0
  24. package/admin/src/pages/RealmsPage/index.jsx +7 -0
  25. package/admin/src/pages/UsersPage/UserEditPage.jsx +313 -0
  26. package/admin/src/pages/UsersPage/UserListPage.jsx +437 -0
  27. package/admin/src/pages/UsersPage/index.jsx +7 -0
  28. package/admin/src/pluginId.js +2 -0
  29. package/admin/src/translations/en.json +77 -0
  30. package/admin/src/translations/fr.json +77 -0
  31. package/babel.config.cjs +17 -0
  32. package/coverage/clover.xml +422 -0
  33. package/coverage/coverage-final.json +8 -0
  34. package/coverage/lcov-report/base.css +224 -0
  35. package/coverage/lcov-report/block-navigation.js +87 -0
  36. package/coverage/lcov-report/favicon.png +0 -0
  37. package/coverage/lcov-report/index.html +146 -0
  38. package/coverage/lcov-report/prettify.css +1 -0
  39. package/coverage/lcov-report/prettify.js +2 -0
  40. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  41. package/coverage/lcov-report/sorter.js +210 -0
  42. package/coverage/lcov-report/src/bootstrap.js.html +346 -0
  43. package/coverage/lcov-report/src/config/index.html +116 -0
  44. package/coverage/lcov-report/src/config/index.js.html +106 -0
  45. package/coverage/lcov-report/src/constants.js.html +850 -0
  46. package/coverage/lcov-report/src/content-types/audit-log/index.html +116 -0
  47. package/coverage/lcov-report/src/content-types/audit-log/index.js.html +94 -0
  48. package/coverage/lcov-report/src/content-types/index.html +116 -0
  49. package/coverage/lcov-report/src/content-types/index.js.html +112 -0
  50. package/coverage/lcov-report/src/content-types/realm-admin/index.html +116 -0
  51. package/coverage/lcov-report/src/content-types/realm-admin/index.js.html +94 -0
  52. package/coverage/lcov-report/src/content-types/realm-config/index.html +116 -0
  53. package/coverage/lcov-report/src/content-types/realm-config/index.js.html +94 -0
  54. package/coverage/lcov-report/src/controllers/audit.js.html +517 -0
  55. package/coverage/lcov-report/src/controllers/index.html +161 -0
  56. package/coverage/lcov-report/src/controllers/index.js.html +112 -0
  57. package/coverage/lcov-report/src/controllers/realm.js.html +1057 -0
  58. package/coverage/lcov-report/src/controllers/user.js.html +1324 -0
  59. package/coverage/lcov-report/src/destroy.js.html +100 -0
  60. package/coverage/lcov-report/src/index.html +116 -0
  61. package/coverage/lcov-report/src/policies/can-access-realm.js.html +163 -0
  62. package/coverage/lcov-report/src/policies/index.html +146 -0
  63. package/coverage/lcov-report/src/policies/index.js.html +106 -0
  64. package/coverage/lcov-report/src/policies/is-authenticated.js.html +100 -0
  65. package/coverage/lcov-report/src/register.js.html +106 -0
  66. package/coverage/lcov-report/src/routes/admin.js.html +844 -0
  67. package/coverage/lcov-report/src/routes/index.html +131 -0
  68. package/coverage/lcov-report/src/routes/index.js.html +109 -0
  69. package/coverage/lcov-report/src/services/audit-log.js.html +673 -0
  70. package/coverage/lcov-report/src/services/index.html +176 -0
  71. package/coverage/lcov-report/src/services/index.js.html +124 -0
  72. package/coverage/lcov-report/src/services/keycloak-client.js.html +2359 -0
  73. package/coverage/lcov-report/src/services/permission.js.html +955 -0
  74. package/coverage/lcov-report/src/services/realm.js.html +1207 -0
  75. package/coverage/lcov-report/src/services/user.js.html +1924 -0
  76. package/coverage/lcov-report/src/utils/errors.js.html +274 -0
  77. package/coverage/lcov-report/src/utils/index.html +116 -0
  78. package/coverage/lcov-report/src/utils/index.js.html +103 -0
  79. package/coverage/lcov.info +804 -0
  80. package/dist/_chunks/App-BaKrvCeS.mjs +1975 -0
  81. package/dist/_chunks/App-DO6syS77.js +1975 -0
  82. package/dist/_chunks/en-Li-XBDe9.mjs +72 -0
  83. package/dist/_chunks/en-aCyfgNfr.js +72 -0
  84. package/dist/_chunks/fr-Cj33Q8jI.js +72 -0
  85. package/dist/_chunks/fr-vLrXph-Z.mjs +72 -0
  86. package/dist/_chunks/index-DwDO4-0C.js +69 -0
  87. package/dist/_chunks/index-jTVd7LdQ.mjs +70 -0
  88. package/dist/admin/index.js +3 -0
  89. package/dist/admin/index.mjs +4 -0
  90. package/dist/server/index.js +3003 -0
  91. package/dist/server/index.mjs +3004 -0
  92. package/jest.config.cjs +50 -0
  93. package/package.json +55 -0
  94. package/server/src/bootstrap.js +87 -0
  95. package/server/src/config/index.js +7 -0
  96. package/server/src/constants.js +255 -0
  97. package/server/src/content-types/audit-log/index.js +3 -0
  98. package/server/src/content-types/audit-log/schema.json +61 -0
  99. package/server/src/content-types/index.js +9 -0
  100. package/server/src/content-types/realm-admin/index.js +3 -0
  101. package/server/src/content-types/realm-admin/schema.json +45 -0
  102. package/server/src/content-types/realm-config/index.js +3 -0
  103. package/server/src/content-types/realm-config/schema.json +56 -0
  104. package/server/src/controllers/audit.js +144 -0
  105. package/server/src/controllers/index.js +9 -0
  106. package/server/src/controllers/realm.js +324 -0
  107. package/server/src/controllers/user.js +413 -0
  108. package/server/src/destroy.js +5 -0
  109. package/server/src/index.js +21 -0
  110. package/server/src/policies/can-access-realm.js +26 -0
  111. package/server/src/policies/index.js +7 -0
  112. package/server/src/policies/is-authenticated.js +5 -0
  113. package/server/src/register.js +7 -0
  114. package/server/src/routes/admin.js +253 -0
  115. package/server/src/routes/index.js +8 -0
  116. package/server/src/services/audit-log.js +196 -0
  117. package/server/src/services/index.js +13 -0
  118. package/server/src/services/keycloak-client.js +758 -0
  119. package/server/src/services/permission.js +290 -0
  120. package/server/src/services/realm.js +374 -0
  121. package/server/src/services/user.js +613 -0
  122. package/server/src/utils/errors.js +63 -0
  123. package/server/src/utils/index.js +6 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * @fileoverview Permission management service for realm access control.
3
+ * Handles permission checking and realm admin assignments.
4
+ *
5
+ * @module services/permission
6
+ */
7
+
8
+ import {
9
+ CONTENT_TYPES,
10
+ DEFAULT_REALM_PERMISSIONS,
11
+ STRAPI_SUPER_ADMIN_ROLE,
12
+ } from '../constants.js';
13
+
14
+ /**
15
+ * @typedef {Object} RealmPermissions
16
+ * @property {boolean} canRead - Can view users in the realm
17
+ * @property {boolean} canCreate - Can create new users
18
+ * @property {boolean} canUpdate - Can modify users (includes enable/disable)
19
+ * @property {boolean} canDelete - Can permanently delete users
20
+ * @property {boolean} canManageRoles - Can assign/remove realm roles
21
+ * @property {boolean} canResetPassword - Can reset passwords and send reset emails
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} RealmAdminAssignment
26
+ * @property {string} documentId - Assignment document ID
27
+ * @property {number} strapiUserId - Strapi admin user ID
28
+ * @property {string} strapiUserEmail - Strapi admin email
29
+ * @property {RealmPermissions} permissions - Granted permissions
30
+ * @property {Object} [realmConfig] - Populated realm configuration
31
+ */
32
+
33
+ /**
34
+ * @typedef {Object} StrapiUser
35
+ * @property {number} id - User ID
36
+ * @property {string} email - User email
37
+ * @property {Object[]} roles - Array of role objects
38
+ * @property {string} roles[].code - Role code
39
+ */
40
+
41
+ /**
42
+ * Full permissions object for super admins.
43
+ * @constant {RealmPermissions}
44
+ * @private
45
+ */
46
+ const SUPER_ADMIN_PERMISSIONS = {
47
+ canRead: true,
48
+ canCreate: true,
49
+ canUpdate: true,
50
+ canDelete: true,
51
+ canManageRoles: true,
52
+ canResetPassword: true,
53
+ };
54
+
55
+ /**
56
+ * Creates the permission service.
57
+ *
58
+ * @param {Object} params - Service parameters
59
+ * @param {Object} params.strapi - Strapi instance
60
+ * @returns {Object} Permission service methods
61
+ */
62
+ const permissionService = ({ strapi }) => ({
63
+ /**
64
+ * Checks if a user has the Strapi super admin role.
65
+ * Super admins have unrestricted access to all realms and operations.
66
+ *
67
+ * @param {StrapiUser} user - Strapi user object with roles
68
+ * @returns {boolean} True if user is a super admin
69
+ *
70
+ * @example
71
+ * if (permissionService.isSuperAdmin(ctx.state.user)) {
72
+ * // Allow unrestricted access
73
+ * }
74
+ */
75
+ isSuperAdmin(user) {
76
+ if (!user || !user.roles) return false;
77
+ return user.roles.some((role) => role.code === STRAPI_SUPER_ADMIN_ROLE);
78
+ },
79
+
80
+ /**
81
+ * Retrieves the realm admin assignment for a specific user and realm.
82
+ *
83
+ * @param {number} strapiUserId - Strapi user ID
84
+ * @param {string} realmConfigId - Realm document ID
85
+ * @returns {Promise<RealmAdminAssignment|null>} Assignment or null if none exists
86
+ *
87
+ * @example
88
+ * const assignment = await permissionService.getRealmAdminAssignment(user.id, realmId);
89
+ * if (assignment?.permissions.canCreate) {
90
+ * // User can create
91
+ * }
92
+ */
93
+ async getRealmAdminAssignment(strapiUserId, realmConfigId) {
94
+ const assignments = await strapi.documents(CONTENT_TYPES.REALM_ADMIN).findMany({
95
+ filters: {
96
+ strapiUserId,
97
+ realmConfig: { documentId: realmConfigId },
98
+ },
99
+ populate: ['realmConfig'],
100
+ });
101
+
102
+ return assignments[0] || null;
103
+ },
104
+
105
+ /**
106
+ * Checks if a user has a specific permission for a realm.
107
+ *
108
+ * @param {StrapiUser} user - Strapi user object
109
+ * @param {string} realmConfigId - Realm document ID
110
+ * @param {keyof RealmPermissions} [permission='canRead'] - Permission to check
111
+ * @returns {Promise<boolean>} True if user has the permission
112
+ *
113
+ * @example
114
+ * const canDelete = await permissionService.canAccessRealm(user, realmId, 'canDelete');
115
+ * if (!canDelete) {
116
+ * throw new ForbiddenError();
117
+ * }
118
+ */
119
+ async canAccessRealm(user, realmConfigId, permission = 'canRead') {
120
+ // Super admins have all permissions
121
+ if (this.isSuperAdmin(user)) {
122
+ return true;
123
+ }
124
+
125
+ const assignment = await this.getRealmAdminAssignment(user.id, realmConfigId);
126
+
127
+ if (!assignment) {
128
+ return false;
129
+ }
130
+
131
+ const permissions = assignment.permissions || DEFAULT_REALM_PERMISSIONS;
132
+ return permissions[permission] === true;
133
+ },
134
+
135
+ /**
136
+ * Retrieves all realms accessible by a user with their permissions.
137
+ * Super admins see all enabled realms with full permissions.
138
+ * Regular users see only realms they're assigned to.
139
+ *
140
+ * @param {StrapiUser} user - Strapi user object
141
+ * @returns {Promise<Array<Object & {permissions: RealmPermissions}>>} Accessible realms with permissions
142
+ *
143
+ * @example
144
+ * const realms = await permissionService.getAccessibleRealms(ctx.state.user);
145
+ * // Returns realms with permissions attached
146
+ */
147
+ async getAccessibleRealms(user) {
148
+ // Super admins see all enabled realms with full permissions
149
+ if (this.isSuperAdmin(user)) {
150
+ const allRealms = await strapi.documents(CONTENT_TYPES.REALM_CONFIG).findMany({
151
+ filters: { enabled: true },
152
+ sort: { displayName: 'asc' },
153
+ });
154
+
155
+ return allRealms.map((realm) => ({
156
+ ...realm,
157
+ permissions: SUPER_ADMIN_PERMISSIONS,
158
+ }));
159
+ }
160
+
161
+ // Regular users see only assigned realms
162
+ const assignments = await strapi.documents(CONTENT_TYPES.REALM_ADMIN).findMany({
163
+ filters: {
164
+ strapiUserId: user.id,
165
+ realmConfig: { enabled: true },
166
+ },
167
+ populate: ['realmConfig'],
168
+ });
169
+
170
+ return assignments
171
+ .filter((assignment) => assignment.realmConfig)
172
+ .map((assignment) => ({
173
+ ...assignment.realmConfig,
174
+ permissions: assignment.permissions || DEFAULT_REALM_PERMISSIONS,
175
+ }));
176
+ },
177
+
178
+ /**
179
+ * Gets a user's specific permissions for a realm.
180
+ *
181
+ * @param {StrapiUser} user - Strapi user object
182
+ * @param {string} realmConfigId - Realm document ID
183
+ * @returns {Promise<RealmPermissions|null>} User's permissions or null if no access
184
+ *
185
+ * @example
186
+ * const permissions = await permissionService.getRealmPermissions(user, realmId);
187
+ * if (permissions?.canUpdate) {
188
+ * // Show edit button
189
+ * }
190
+ */
191
+ async getRealmPermissions(user, realmConfigId) {
192
+ if (this.isSuperAdmin(user)) {
193
+ return SUPER_ADMIN_PERMISSIONS;
194
+ }
195
+
196
+ const assignment = await this.getRealmAdminAssignment(user.id, realmConfigId);
197
+ return assignment?.permissions || null;
198
+ },
199
+
200
+ /**
201
+ * Assigns a Strapi user to a realm with specified permissions.
202
+ * If an assignment already exists, it updates the permissions.
203
+ *
204
+ * @param {number} strapiUserId - Strapi user ID to assign
205
+ * @param {string} strapiUserEmail - User's email for reference
206
+ * @param {string} realmConfigId - Realm document ID
207
+ * @param {Partial<RealmPermissions>} [permissions={}] - Permissions to grant
208
+ * @returns {Promise<RealmAdminAssignment>} Created or updated assignment
209
+ *
210
+ * @example
211
+ * await permissionService.assignUserToRealm(
212
+ * userId,
213
+ * 'admin@example.com',
214
+ * realmId,
215
+ * { canRead: true, canCreate: true, canUpdate: true }
216
+ * );
217
+ */
218
+ async assignUserToRealm(strapiUserId, strapiUserEmail, realmConfigId, permissions = {}) {
219
+ // Check for existing assignment
220
+ const existing = await this.getRealmAdminAssignment(strapiUserId, realmConfigId);
221
+
222
+ // Merge with default permissions
223
+ const mergedPermissions = { ...DEFAULT_REALM_PERMISSIONS, ...permissions };
224
+
225
+ if (existing) {
226
+ // Update existing assignment
227
+ return strapi.documents(CONTENT_TYPES.REALM_ADMIN).update({
228
+ documentId: existing.documentId,
229
+ data: {
230
+ permissions: mergedPermissions,
231
+ },
232
+ });
233
+ }
234
+
235
+ // Create new assignment
236
+ return strapi.documents(CONTENT_TYPES.REALM_ADMIN).create({
237
+ data: {
238
+ strapiUserId,
239
+ strapiUserEmail,
240
+ realmConfig: realmConfigId,
241
+ permissions: mergedPermissions,
242
+ },
243
+ });
244
+ },
245
+
246
+ /**
247
+ * Removes a user's access to a realm.
248
+ *
249
+ * @param {number} strapiUserId - Strapi user ID
250
+ * @param {string} realmConfigId - Realm document ID
251
+ * @returns {Promise<boolean>} True if assignment was removed, false if none existed
252
+ *
253
+ * @example
254
+ * const removed = await permissionService.removeUserFromRealm(userId, realmId);
255
+ */
256
+ async removeUserFromRealm(strapiUserId, realmConfigId) {
257
+ const assignment = await this.getRealmAdminAssignment(strapiUserId, realmConfigId);
258
+
259
+ if (assignment) {
260
+ await strapi.documents(CONTENT_TYPES.REALM_ADMIN).delete({
261
+ documentId: assignment.documentId,
262
+ });
263
+ return true;
264
+ }
265
+
266
+ return false;
267
+ },
268
+
269
+ /**
270
+ * Retrieves all admin assignments for a specific realm.
271
+ *
272
+ * @param {string} realmConfigId - Realm document ID
273
+ * @returns {Promise<RealmAdminAssignment[]>} Array of admin assignments
274
+ *
275
+ * @example
276
+ * const admins = await permissionService.getRealmAdmins(realmId);
277
+ * // Display list of users who can manage this realm
278
+ */
279
+ async getRealmAdmins(realmConfigId) {
280
+ const assignments = await strapi.documents(CONTENT_TYPES.REALM_ADMIN).findMany({
281
+ filters: {
282
+ realmConfig: { documentId: realmConfigId },
283
+ },
284
+ });
285
+
286
+ return assignments;
287
+ },
288
+ });
289
+
290
+ export default permissionService;
@@ -0,0 +1,374 @@
1
+ /**
2
+ * @fileoverview Realm configuration management service.
3
+ * Handles CRUD operations for Keycloak realm configurations stored in Strapi.
4
+ *
5
+ * @module services/realm
6
+ */
7
+
8
+ import {
9
+ PLUGIN_ID,
10
+ CONTENT_TYPES,
11
+ SERVICES,
12
+ ERROR_MESSAGES,
13
+ DEFAULT_REALM_COLOR,
14
+ REALM_NAME_PATTERN,
15
+ } from '../constants.js';
16
+ import { createSanitizedError } from '../utils/errors.js';
17
+
18
+ /**
19
+ * @typedef {Object} RealmConfigData
20
+ * @property {string} name - Unique slug identifier (lowercase, hyphens only)
21
+ * @property {string} displayName - Human-readable name for UI
22
+ * @property {string} serverUrl - Keycloak server base URL
23
+ * @property {string} realmName - Keycloak realm name
24
+ * @property {string} clientId - Service account client ID
25
+ * @property {string} [clientSecret] - Service account client secret
26
+ * @property {boolean} [enabled=true] - Whether realm is active
27
+ * @property {string} [color] - UI accent color (hex)
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} RealmConfig
32
+ * @property {string} documentId - Strapi document ID
33
+ * @property {string} name - Unique slug identifier
34
+ * @property {string} displayName - Human-readable name
35
+ * @property {string} serverUrl - Keycloak server URL
36
+ * @property {string} realmName - Keycloak realm name
37
+ * @property {string} clientId - Client ID
38
+ * @property {boolean} enabled - Active status
39
+ * @property {string} color - UI color
40
+ * @property {Date} createdAt - Creation timestamp
41
+ * @property {Date} updatedAt - Last update timestamp
42
+ */
43
+
44
+ /**
45
+ * Creates the realm configuration service.
46
+ *
47
+ * @param {Object} params - Service parameters
48
+ * @param {Object} params.strapi - Strapi instance
49
+ * @returns {Object} Realm service methods
50
+ */
51
+ const realmService = ({ strapi }) => ({
52
+ /**
53
+ * Gets the Keycloak client service instance.
54
+ *
55
+ * @returns {Object} Keycloak client service
56
+ * @private
57
+ */
58
+ get keycloakClient() {
59
+ return strapi.plugin(PLUGIN_ID).service(SERVICES.KEYCLOAK_CLIENT);
60
+ },
61
+
62
+ /**
63
+ * Retrieves all realm configurations.
64
+ * Note: clientSecret is not included in results (private field).
65
+ *
66
+ * @returns {Promise<RealmConfig[]>} Array of realm configurations sorted by displayName
67
+ *
68
+ * @example
69
+ * const realms = await realmService.findAll();
70
+ */
71
+ async findAll() {
72
+ return strapi.documents(CONTENT_TYPES.REALM_CONFIG).findMany({
73
+ sort: { displayName: 'asc' },
74
+ });
75
+ },
76
+
77
+ /**
78
+ * Retrieves a single realm by document ID.
79
+ * Note: clientSecret is not included (private field).
80
+ *
81
+ * @param {string} documentId - Strapi document ID
82
+ * @returns {Promise<RealmConfig>} Realm configuration
83
+ * @throws {Error} If realm not found
84
+ *
85
+ * @example
86
+ * const realm = await realmService.findOne('abc123');
87
+ */
88
+ async findOne(documentId) {
89
+ const realm = await strapi.documents(CONTENT_TYPES.REALM_CONFIG).findOne({
90
+ documentId,
91
+ });
92
+
93
+ if (!realm) {
94
+ throw createSanitizedError(
95
+ `Realm ${documentId} not found`,
96
+ ERROR_MESSAGES.REALM_NOT_FOUND
97
+ );
98
+ }
99
+
100
+ return realm;
101
+ },
102
+
103
+ /**
104
+ * Retrieves a realm with its clientSecret for internal Keycloak API calls.
105
+ * Uses db.query to bypass Strapi's private field sanitization.
106
+ *
107
+ * SECURITY: Only use this method when the clientSecret is actually needed
108
+ * for Keycloak authentication. Never expose the result to API responses.
109
+ *
110
+ * @param {string} documentId - Strapi document ID
111
+ * @returns {Promise<RealmConfig & {clientSecret: string}>} Full realm config with secret
112
+ * @throws {Error} If realm not found
113
+ *
114
+ * @example
115
+ * // Internal use only - for Keycloak API calls
116
+ * const realm = await realmService.findOneWithSecret(documentId);
117
+ * const token = await keycloakClient.getAccessToken(realm);
118
+ */
119
+ async findOneWithSecret(documentId) {
120
+ const realm = await strapi.db.query(CONTENT_TYPES.REALM_CONFIG).findOne({
121
+ where: { documentId },
122
+ });
123
+
124
+ if (!realm) {
125
+ throw createSanitizedError(
126
+ `Realm ${documentId} not found`,
127
+ ERROR_MESSAGES.REALM_NOT_FOUND
128
+ );
129
+ }
130
+
131
+ return realm;
132
+ },
133
+
134
+ /**
135
+ * Finds a realm by its unique slug name.
136
+ *
137
+ * @param {string} name - Realm slug name
138
+ * @returns {Promise<RealmConfig|null>} Realm configuration or null if not found
139
+ *
140
+ * @example
141
+ * const realm = await realmService.findByName('production-users');
142
+ */
143
+ async findByName(name) {
144
+ const realms = await strapi.documents(CONTENT_TYPES.REALM_CONFIG).findMany({
145
+ filters: { name },
146
+ });
147
+
148
+ return realms[0] || null;
149
+ },
150
+
151
+ /**
152
+ * Validates realm name format.
153
+ *
154
+ * @param {string} name - Name to validate
155
+ * @returns {boolean} True if valid
156
+ * @private
157
+ */
158
+ isValidName(name) {
159
+ return REALM_NAME_PATTERN.test(name);
160
+ },
161
+
162
+ /**
163
+ * Normalizes a server URL by removing trailing slashes.
164
+ *
165
+ * @param {string} url - URL to normalize
166
+ * @returns {string} Normalized URL
167
+ * @private
168
+ */
169
+ normalizeServerUrl(url) {
170
+ return url.replace(/\/$/, '');
171
+ },
172
+
173
+ /**
174
+ * Creates a new realm configuration.
175
+ *
176
+ * @param {RealmConfigData} data - Realm configuration data
177
+ * @returns {Promise<RealmConfig>} Created realm configuration
178
+ * @throws {Error} If validation fails or name already exists
179
+ *
180
+ * @example
181
+ * const realm = await realmService.create({
182
+ * name: 'production-users',
183
+ * displayName: 'Production Users',
184
+ * serverUrl: 'https://keycloak.example.com',
185
+ * realmName: 'production',
186
+ * clientId: 'strapi-admin',
187
+ * clientSecret: 'secret-value'
188
+ * });
189
+ */
190
+ async create(data) {
191
+ const { name, displayName, serverUrl, realmName, clientId, clientSecret } = data;
192
+
193
+ // Validate required fields
194
+ if (!name || !displayName || !serverUrl || !realmName || !clientId) {
195
+ throw createSanitizedError(
196
+ 'Missing required fields',
197
+ ERROR_MESSAGES.MISSING_REQUIRED_FIELDS
198
+ );
199
+ }
200
+
201
+ // Validate name format
202
+ if (!this.isValidName(name)) {
203
+ throw createSanitizedError(
204
+ `Invalid name format: ${name}`,
205
+ ERROR_MESSAGES.INVALID_NAME_FORMAT
206
+ );
207
+ }
208
+
209
+ // Check for duplicate name
210
+ const existing = await this.findByName(name);
211
+ if (existing) {
212
+ throw createSanitizedError(
213
+ `Realm with name ${name} already exists`,
214
+ ERROR_MESSAGES.REALM_NAME_EXISTS
215
+ );
216
+ }
217
+
218
+ return strapi.documents(CONTENT_TYPES.REALM_CONFIG).create({
219
+ data: {
220
+ name,
221
+ displayName,
222
+ serverUrl: this.normalizeServerUrl(serverUrl),
223
+ realmName,
224
+ clientId,
225
+ clientSecret: clientSecret || null,
226
+ enabled: data.enabled !== false,
227
+ color: data.color || DEFAULT_REALM_COLOR,
228
+ },
229
+ });
230
+ },
231
+
232
+ /**
233
+ * Updates an existing realm configuration.
234
+ *
235
+ * @param {string} documentId - Strapi document ID
236
+ * @param {Partial<RealmConfigData>} data - Fields to update
237
+ * @returns {Promise<RealmConfig>} Updated realm configuration
238
+ * @throws {Error} If validation fails or realm not found
239
+ *
240
+ * @example
241
+ * const updated = await realmService.update(documentId, {
242
+ * displayName: 'New Display Name',
243
+ * enabled: false
244
+ * });
245
+ */
246
+ async update(documentId, data) {
247
+ const existing = await this.findOne(documentId);
248
+
249
+ // Validate name change if applicable
250
+ if (data.name && data.name !== existing.name) {
251
+ if (!this.isValidName(data.name)) {
252
+ throw createSanitizedError(
253
+ `Invalid name format: ${data.name}`,
254
+ ERROR_MESSAGES.INVALID_NAME_FORMAT
255
+ );
256
+ }
257
+
258
+ const duplicate = await this.findByName(data.name);
259
+ if (duplicate && duplicate.documentId !== documentId) {
260
+ throw createSanitizedError(
261
+ `Realm with name ${data.name} already exists`,
262
+ ERROR_MESSAGES.REALM_NAME_EXISTS
263
+ );
264
+ }
265
+ }
266
+
267
+ // Prepare update data
268
+ const updateData = { ...data };
269
+ if (updateData.serverUrl) {
270
+ updateData.serverUrl = this.normalizeServerUrl(updateData.serverUrl);
271
+ }
272
+
273
+ // Clear token cache if connection details changed
274
+ const connectionFieldsChanged =
275
+ updateData.serverUrl ||
276
+ updateData.realmName ||
277
+ updateData.clientId ||
278
+ updateData.clientSecret;
279
+
280
+ if (connectionFieldsChanged) {
281
+ this.keycloakClient.clearTokenCache(existing);
282
+ }
283
+
284
+ return strapi.documents(CONTENT_TYPES.REALM_CONFIG).update({
285
+ documentId,
286
+ data: updateData,
287
+ });
288
+ },
289
+
290
+ /**
291
+ * Deletes a realm configuration and all associated admin assignments.
292
+ *
293
+ * @param {string} documentId - Strapi document ID
294
+ * @returns {Promise<{success: boolean}>} Success indicator
295
+ * @throws {Error} If realm not found
296
+ *
297
+ * @example
298
+ * await realmService.delete(documentId);
299
+ */
300
+ async delete(documentId) {
301
+ const realm = await this.findOne(documentId);
302
+
303
+ // Clear token cache
304
+ this.keycloakClient.clearTokenCache(realm);
305
+
306
+ // Delete all realm admin assignments for this realm
307
+ const assignments = await strapi.documents(CONTENT_TYPES.REALM_ADMIN).findMany({
308
+ filters: {
309
+ realmConfig: { documentId },
310
+ },
311
+ });
312
+
313
+ // Delete assignments sequentially to avoid race conditions
314
+ for (const assignment of assignments) {
315
+ await strapi.documents(CONTENT_TYPES.REALM_ADMIN).delete({
316
+ documentId: assignment.documentId,
317
+ });
318
+ }
319
+
320
+ // Delete the realm config
321
+ await strapi.documents(CONTENT_TYPES.REALM_CONFIG).delete({
322
+ documentId,
323
+ });
324
+
325
+ return { success: true };
326
+ },
327
+
328
+ /**
329
+ * Tests connection to a saved realm configuration.
330
+ *
331
+ * @param {string} documentId - Strapi document ID
332
+ * @returns {Promise<{success: boolean, message?: string, realmDisplayName?: string}>} Test result
333
+ *
334
+ * @example
335
+ * const result = await realmService.testConnection(documentId);
336
+ * if (result.success) {
337
+ * console.log(`Connected to ${result.realmDisplayName}`);
338
+ * } else {
339
+ * console.error(result.message);
340
+ * }
341
+ */
342
+ async testConnection(documentId) {
343
+ const realmBasic = await this.findOne(documentId);
344
+
345
+ if (!realmBasic.enabled) {
346
+ return { success: false, message: ERROR_MESSAGES.REALM_DISABLED };
347
+ }
348
+
349
+ // Get realm with clientSecret for Keycloak connection
350
+ const realm = await this.findOneWithSecret(documentId);
351
+ return this.keycloakClient.testConnection(realm);
352
+ },
353
+
354
+ /**
355
+ * Tests connection with raw configuration data (before saving).
356
+ * Useful for validating credentials during realm creation/editing.
357
+ *
358
+ * @param {RealmConfigData} config - Raw realm configuration
359
+ * @returns {Promise<{success: boolean, message?: string, realmDisplayName?: string}>} Test result
360
+ *
361
+ * @example
362
+ * const result = await realmService.testConnectionRaw({
363
+ * serverUrl: 'https://keycloak.example.com',
364
+ * realmName: 'my-realm',
365
+ * clientId: 'strapi-admin',
366
+ * clientSecret: 'secret'
367
+ * });
368
+ */
369
+ async testConnectionRaw(config) {
370
+ return this.keycloakClient.testConnection(config);
371
+ },
372
+ });
373
+
374
+ export default realmService;