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,3004 @@
1
+ const PLUGIN_ID = "strapi-plugin-keycloak-realm-users";
2
+ const CONTENT_TYPES = {
3
+ REALM_CONFIG: `plugin::${PLUGIN_ID}.realm-config`,
4
+ REALM_ADMIN: `plugin::${PLUGIN_ID}.realm-admin`,
5
+ AUDIT_LOG: `plugin::${PLUGIN_ID}.audit-log`
6
+ };
7
+ const SERVICES = {
8
+ REALM: "realm",
9
+ KEYCLOAK_CLIENT: "keycloak-client",
10
+ USER: "user",
11
+ PERMISSION: "permission",
12
+ AUDIT_LOG: "audit-log"
13
+ };
14
+ const AUDIT_ACTIONS = {
15
+ /** User was created in Keycloak */
16
+ CREATE_USER: "CREATE_USER",
17
+ /** User details were modified */
18
+ UPDATE_USER: "UPDATE_USER",
19
+ /** User was permanently deleted */
20
+ DELETE_USER: "DELETE_USER",
21
+ /** User password was reset */
22
+ RESET_PASSWORD: "RESET_PASSWORD",
23
+ /** Realm role was assigned to user */
24
+ ASSIGN_ROLE: "ASSIGN_ROLE",
25
+ /** Realm role was removed from user */
26
+ REMOVE_ROLE: "REMOVE_ROLE",
27
+ /** User account was enabled */
28
+ ENABLE_USER: "ENABLE_USER",
29
+ /** User account was disabled */
30
+ DISABLE_USER: "DISABLE_USER",
31
+ /** Email verification was sent */
32
+ SEND_VERIFY_EMAIL: "SEND_VERIFY_EMAIL",
33
+ /** Password reset email was sent */
34
+ SEND_RESET_PASSWORD_EMAIL: "SEND_RESET_PASSWORD_EMAIL",
35
+ /** Bulk user import was performed */
36
+ BULK_IMPORT: "BULK_IMPORT"
37
+ };
38
+ const ERROR_MESSAGES = {
39
+ REALM_NOT_FOUND: "Realm configuration not found.",
40
+ REALM_DISABLED: "This realm is currently disabled.",
41
+ USER_NOT_FOUND: "Keycloak user not found.",
42
+ INSUFFICIENT_PERMISSIONS: "You do not have permission to perform this action.",
43
+ REALM_ACCESS_DENIED: "You do not have access to this realm.",
44
+ KEYCLOAK_CONNECTION_FAILED: "Failed to connect to Keycloak server.",
45
+ KEYCLOAK_AUTH_FAILED: "Failed to authenticate with Keycloak.",
46
+ INVALID_USER_DATA: "Invalid user data provided.",
47
+ MISSING_REQUIRED_FIELDS: "Missing required fields.",
48
+ UNKNOWN_ERROR: "An unexpected error occurred.",
49
+ USER_ALREADY_EXISTS: "A user with this username or email already exists.",
50
+ PASSWORD_RESET_FAILED: "Failed to reset password.",
51
+ EMAIL_SEND_FAILED: "Failed to send email.",
52
+ ROLE_ASSIGNMENT_FAILED: "Failed to assign roles.",
53
+ ROLE_REMOVAL_FAILED: "Failed to remove roles.",
54
+ LOGOUT_FAILED: "Failed to logout user.",
55
+ INVALID_NAME_FORMAT: "Name must contain only lowercase letters, numbers, and hyphens.",
56
+ REALM_NAME_EXISTS: "A realm with this name already exists."
57
+ };
58
+ const DEFAULT_REALM_PERMISSIONS = {
59
+ canRead: true,
60
+ canCreate: false,
61
+ canUpdate: false,
62
+ canDelete: false,
63
+ canManageRoles: false,
64
+ canResetPassword: false
65
+ };
66
+ const STRAPI_SUPER_ADMIN_ROLE = "strapi-super-admin";
67
+ const TOKEN_EXPIRY_BUFFER_MS = 6e4;
68
+ const EMAIL_ACTION_LIFESPAN_SECONDS = 43200;
69
+ const DEFAULT_REALM_COLOR = "#4945ff";
70
+ const PAGINATION = {
71
+ PAGE_SIZE: 25,
72
+ AUDIT_LOG_LIMIT: 50,
73
+ EXPORT_BATCH_SIZE: 100
74
+ };
75
+ const HTTP_STATUS = {
76
+ NO_CONTENT: 204,
77
+ NOT_FOUND: 404,
78
+ CONFLICT: 409
79
+ };
80
+ const HTTP_HEADERS = {
81
+ CONTENT_TYPE_JSON: "application/json",
82
+ CONTENT_TYPE_FORM: "application/x-www-form-urlencoded"
83
+ };
84
+ const KEYCLOAK_EMAIL_ACTIONS = {
85
+ VERIFY_EMAIL: "VERIFY_EMAIL",
86
+ UPDATE_PASSWORD: "UPDATE_PASSWORD"
87
+ };
88
+ const REALM_NAME_PATTERN = /^[a-z0-9-]+$/;
89
+ const UNKNOWN_USER_EMAIL = "unknown";
90
+ const bootstrap = async ({ strapi }) => {
91
+ try {
92
+ await strapi.admin.services.permission.actionProvider.registerMany([
93
+ {
94
+ uid: `plugin::${PLUGIN_ID}.realm.read`,
95
+ displayName: "Read Realms",
96
+ pluginName: PLUGIN_ID,
97
+ section: "settings",
98
+ category: "Keycloak User Management",
99
+ subCategory: "Realms"
100
+ },
101
+ {
102
+ uid: `plugin::${PLUGIN_ID}.realm.create`,
103
+ displayName: "Create Realms",
104
+ pluginName: PLUGIN_ID,
105
+ section: "settings",
106
+ category: "Keycloak User Management",
107
+ subCategory: "Realms"
108
+ },
109
+ {
110
+ uid: `plugin::${PLUGIN_ID}.realm.update`,
111
+ displayName: "Update Realms",
112
+ pluginName: PLUGIN_ID,
113
+ section: "settings",
114
+ category: "Keycloak User Management",
115
+ subCategory: "Realms"
116
+ },
117
+ {
118
+ uid: `plugin::${PLUGIN_ID}.realm.delete`,
119
+ displayName: "Delete Realms",
120
+ pluginName: PLUGIN_ID,
121
+ section: "settings",
122
+ category: "Keycloak User Management",
123
+ subCategory: "Realms"
124
+ },
125
+ {
126
+ uid: `plugin::${PLUGIN_ID}.user.read`,
127
+ displayName: "Read Keycloak Users",
128
+ pluginName: PLUGIN_ID,
129
+ section: "settings",
130
+ category: "Keycloak User Management",
131
+ subCategory: "Users"
132
+ },
133
+ {
134
+ uid: `plugin::${PLUGIN_ID}.user.create`,
135
+ displayName: "Create Keycloak Users",
136
+ pluginName: PLUGIN_ID,
137
+ section: "settings",
138
+ category: "Keycloak User Management",
139
+ subCategory: "Users"
140
+ },
141
+ {
142
+ uid: `plugin::${PLUGIN_ID}.user.update`,
143
+ displayName: "Update Keycloak Users",
144
+ pluginName: PLUGIN_ID,
145
+ section: "settings",
146
+ category: "Keycloak User Management",
147
+ subCategory: "Users"
148
+ },
149
+ {
150
+ uid: `plugin::${PLUGIN_ID}.user.delete`,
151
+ displayName: "Delete Keycloak Users",
152
+ pluginName: PLUGIN_ID,
153
+ section: "settings",
154
+ category: "Keycloak User Management",
155
+ subCategory: "Users"
156
+ },
157
+ {
158
+ uid: `plugin::${PLUGIN_ID}.audit.read`,
159
+ displayName: "Read Audit Logs",
160
+ pluginName: PLUGIN_ID,
161
+ section: "settings",
162
+ category: "Keycloak User Management",
163
+ subCategory: "Audit"
164
+ }
165
+ ]);
166
+ strapi.log.info(`[${PLUGIN_ID}] Plugin bootstrapped with permissions registered`);
167
+ } catch (err) {
168
+ strapi.log.error(`[${PLUGIN_ID}] Failed to register permissions:`, err);
169
+ }
170
+ };
171
+ const register = ({ strapi }) => {
172
+ strapi.log.info(`[${PLUGIN_ID}] Plugin registered successfully`);
173
+ };
174
+ const destroy = ({ strapi }) => {
175
+ };
176
+ const config = {
177
+ default: {},
178
+ validator(config2) {
179
+ }
180
+ };
181
+ const kind$2 = "collectionType";
182
+ const collectionName$2 = "kru_realm_configs";
183
+ const info$2 = {
184
+ singularName: "realm-config",
185
+ pluralName: "realm-configs",
186
+ displayName: "Keycloak Realm"
187
+ };
188
+ const options$2 = {
189
+ draftAndPublish: false
190
+ };
191
+ const pluginOptions$2 = {
192
+ "content-manager": {
193
+ visible: false
194
+ },
195
+ "content-type-builder": {
196
+ visible: false
197
+ }
198
+ };
199
+ const attributes$2 = {
200
+ name: {
201
+ type: "string",
202
+ required: true,
203
+ unique: true,
204
+ regex: "^[a-z0-9-]+$"
205
+ },
206
+ displayName: {
207
+ type: "string",
208
+ required: true
209
+ },
210
+ serverUrl: {
211
+ type: "string",
212
+ required: true
213
+ },
214
+ realmName: {
215
+ type: "string",
216
+ required: true
217
+ },
218
+ clientId: {
219
+ type: "string",
220
+ required: true
221
+ },
222
+ clientSecret: {
223
+ type: "string",
224
+ "private": true
225
+ },
226
+ enabled: {
227
+ type: "boolean",
228
+ "default": true
229
+ },
230
+ color: {
231
+ type: "string",
232
+ "default": "#4945ff"
233
+ }
234
+ };
235
+ const schema$2 = {
236
+ kind: kind$2,
237
+ collectionName: collectionName$2,
238
+ info: info$2,
239
+ options: options$2,
240
+ pluginOptions: pluginOptions$2,
241
+ attributes: attributes$2
242
+ };
243
+ const realmConfig = { schema: schema$2 };
244
+ const kind$1 = "collectionType";
245
+ const collectionName$1 = "kru_realm_admins";
246
+ const info$1 = {
247
+ singularName: "realm-admin",
248
+ pluralName: "realm-admins",
249
+ displayName: "Realm Admin Assignment"
250
+ };
251
+ const options$1 = {
252
+ draftAndPublish: false
253
+ };
254
+ const pluginOptions$1 = {
255
+ "content-manager": {
256
+ visible: false
257
+ },
258
+ "content-type-builder": {
259
+ visible: false
260
+ }
261
+ };
262
+ const attributes$1 = {
263
+ strapiUserId: {
264
+ type: "integer",
265
+ required: true
266
+ },
267
+ strapiUserEmail: {
268
+ type: "string"
269
+ },
270
+ realmConfig: {
271
+ type: "relation",
272
+ relation: "manyToOne",
273
+ target: "plugin::strapi-plugin-keycloak-realm-users.realm-config"
274
+ },
275
+ permissions: {
276
+ type: "json",
277
+ "default": {
278
+ canRead: true,
279
+ canCreate: false,
280
+ canUpdate: false,
281
+ canDelete: false,
282
+ canManageRoles: false,
283
+ canResetPassword: false
284
+ }
285
+ }
286
+ };
287
+ const schema$1 = {
288
+ kind: kind$1,
289
+ collectionName: collectionName$1,
290
+ info: info$1,
291
+ options: options$1,
292
+ pluginOptions: pluginOptions$1,
293
+ attributes: attributes$1
294
+ };
295
+ const realmAdmin = { schema: schema$1 };
296
+ const kind = "collectionType";
297
+ const collectionName = "kru_audit_logs";
298
+ const info = {
299
+ singularName: "audit-log",
300
+ pluralName: "audit-logs",
301
+ displayName: "Keycloak Audit Log"
302
+ };
303
+ const options = {
304
+ draftAndPublish: false
305
+ };
306
+ const pluginOptions = {
307
+ "content-manager": {
308
+ visible: true
309
+ },
310
+ "content-type-builder": {
311
+ visible: false
312
+ }
313
+ };
314
+ const attributes = {
315
+ realmName: {
316
+ type: "string",
317
+ required: true
318
+ },
319
+ realmDisplayName: {
320
+ type: "string"
321
+ },
322
+ action: {
323
+ type: "enumeration",
324
+ "enum": [
325
+ "CREATE_USER",
326
+ "UPDATE_USER",
327
+ "DELETE_USER",
328
+ "RESET_PASSWORD",
329
+ "ASSIGN_ROLE",
330
+ "REMOVE_ROLE",
331
+ "ENABLE_USER",
332
+ "DISABLE_USER",
333
+ "SEND_VERIFY_EMAIL",
334
+ "SEND_RESET_PASSWORD_EMAIL",
335
+ "BULK_IMPORT"
336
+ ],
337
+ required: true
338
+ },
339
+ keycloakUserId: {
340
+ type: "string"
341
+ },
342
+ keycloakUsername: {
343
+ type: "string"
344
+ },
345
+ details: {
346
+ type: "json"
347
+ },
348
+ performedById: {
349
+ type: "integer"
350
+ },
351
+ performedByEmail: {
352
+ type: "string"
353
+ }
354
+ };
355
+ const schema = {
356
+ kind,
357
+ collectionName,
358
+ info,
359
+ options,
360
+ pluginOptions,
361
+ attributes
362
+ };
363
+ const auditLog = { schema };
364
+ const contentTypes = {
365
+ "realm-config": realmConfig,
366
+ "realm-admin": realmAdmin,
367
+ "audit-log": auditLog
368
+ };
369
+ const realmController = ({ strapi }) => ({
370
+ /**
371
+ * Get all realms (filtered by user access)
372
+ */
373
+ async find(ctx) {
374
+ const user = ctx.state.user;
375
+ if (!user) {
376
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
377
+ }
378
+ try {
379
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
380
+ const realms = await permissionService2.getAccessibleRealms(user);
381
+ ctx.body = { data: realms };
382
+ } catch (err) {
383
+ strapi.log.error(`[${PLUGIN_ID}] realm.find error:`, err);
384
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
385
+ }
386
+ },
387
+ /**
388
+ * Get a single realm by ID
389
+ */
390
+ async findOne(ctx) {
391
+ const user = ctx.state.user;
392
+ const { id } = ctx.params;
393
+ if (!user) {
394
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
395
+ }
396
+ try {
397
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
398
+ const realmService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.REALM);
399
+ const canAccess = await permissionService2.canAccessRealm(user, id, "canRead");
400
+ if (!canAccess) {
401
+ return ctx.forbidden(ERROR_MESSAGES.REALM_ACCESS_DENIED);
402
+ }
403
+ const realm = await realmService2.findOne(id);
404
+ const permissions = await permissionService2.getRealmPermissions(user, id);
405
+ ctx.body = { data: { ...realm, permissions } };
406
+ } catch (err) {
407
+ strapi.log.error(`[${PLUGIN_ID}] realm.findOne error:`, err);
408
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
409
+ }
410
+ },
411
+ /**
412
+ * Create a new realm (super admin only)
413
+ */
414
+ async create(ctx) {
415
+ const user = ctx.state.user;
416
+ const { data } = ctx.request.body;
417
+ if (!user) {
418
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
419
+ }
420
+ try {
421
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
422
+ if (!permissionService2.isSuperAdmin(user)) {
423
+ return ctx.forbidden("Only super admins can create realm configurations.");
424
+ }
425
+ const realmService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.REALM);
426
+ const realm = await realmService2.create(data);
427
+ ctx.body = { data: realm };
428
+ } catch (err) {
429
+ strapi.log.error(`[${PLUGIN_ID}] realm.create error:`, err);
430
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
431
+ }
432
+ },
433
+ /**
434
+ * Update a realm (super admin only)
435
+ */
436
+ async update(ctx) {
437
+ const user = ctx.state.user;
438
+ const { id } = ctx.params;
439
+ const { data } = ctx.request.body;
440
+ if (!user) {
441
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
442
+ }
443
+ try {
444
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
445
+ if (!permissionService2.isSuperAdmin(user)) {
446
+ return ctx.forbidden("Only super admins can update realm configurations.");
447
+ }
448
+ const realmService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.REALM);
449
+ const realm = await realmService2.update(id, data);
450
+ ctx.body = { data: realm };
451
+ } catch (err) {
452
+ strapi.log.error(`[${PLUGIN_ID}] realm.update error:`, err);
453
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
454
+ }
455
+ },
456
+ /**
457
+ * Delete a realm (super admin only)
458
+ */
459
+ async delete(ctx) {
460
+ const user = ctx.state.user;
461
+ const { id } = ctx.params;
462
+ if (!user) {
463
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
464
+ }
465
+ try {
466
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
467
+ if (!permissionService2.isSuperAdmin(user)) {
468
+ return ctx.forbidden("Only super admins can delete realm configurations.");
469
+ }
470
+ const realmService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.REALM);
471
+ await realmService2.delete(id);
472
+ ctx.body = { data: { success: true } };
473
+ } catch (err) {
474
+ strapi.log.error(`[${PLUGIN_ID}] realm.delete error:`, err);
475
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
476
+ }
477
+ },
478
+ /**
479
+ * Test realm connection
480
+ */
481
+ async testConnection(ctx) {
482
+ const user = ctx.state.user;
483
+ const { id } = ctx.params;
484
+ if (!user) {
485
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
486
+ }
487
+ try {
488
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
489
+ const realmService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.REALM);
490
+ const canAccess = await permissionService2.canAccessRealm(user, id, "canRead");
491
+ if (!canAccess) {
492
+ return ctx.forbidden(ERROR_MESSAGES.REALM_ACCESS_DENIED);
493
+ }
494
+ const result = await realmService2.testConnection(id);
495
+ ctx.body = { data: result };
496
+ } catch (err) {
497
+ strapi.log.error(`[${PLUGIN_ID}] realm.testConnection error:`, err);
498
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
499
+ }
500
+ },
501
+ /**
502
+ * Test connection before saving (with raw config)
503
+ */
504
+ async testConnectionRaw(ctx) {
505
+ const user = ctx.state.user;
506
+ const { data } = ctx.request.body;
507
+ if (!user) {
508
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
509
+ }
510
+ try {
511
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
512
+ if (!permissionService2.isSuperAdmin(user)) {
513
+ return ctx.forbidden("Only super admins can test realm configurations.");
514
+ }
515
+ const realmService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.REALM);
516
+ const result = await realmService2.testConnectionRaw(data);
517
+ ctx.body = { data: result };
518
+ } catch (err) {
519
+ strapi.log.error(`[${PLUGIN_ID}] realm.testConnectionRaw error:`, err);
520
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
521
+ }
522
+ },
523
+ /**
524
+ * Get admins assigned to a realm
525
+ */
526
+ async getAdmins(ctx) {
527
+ const user = ctx.state.user;
528
+ const { id } = ctx.params;
529
+ if (!user) {
530
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
531
+ }
532
+ try {
533
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
534
+ if (!permissionService2.isSuperAdmin(user)) {
535
+ return ctx.forbidden("Only super admins can view realm admin assignments.");
536
+ }
537
+ const admins = await permissionService2.getRealmAdmins(id);
538
+ ctx.body = { data: admins };
539
+ } catch (err) {
540
+ strapi.log.error(`[${PLUGIN_ID}] realm.getAdmins error:`, err);
541
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
542
+ }
543
+ },
544
+ /**
545
+ * Assign an admin to a realm
546
+ */
547
+ async addAdmin(ctx) {
548
+ const user = ctx.state.user;
549
+ const { id } = ctx.params;
550
+ const { data } = ctx.request.body;
551
+ if (!user) {
552
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
553
+ }
554
+ try {
555
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
556
+ if (!permissionService2.isSuperAdmin(user)) {
557
+ return ctx.forbidden("Only super admins can assign realm admins.");
558
+ }
559
+ const { strapiUserId, strapiUserEmail, permissions } = data;
560
+ if (!strapiUserId) {
561
+ return ctx.badRequest("strapiUserId is required.");
562
+ }
563
+ const assignment = await permissionService2.assignUserToRealm(
564
+ strapiUserId,
565
+ strapiUserEmail,
566
+ id,
567
+ permissions
568
+ );
569
+ ctx.body = { data: assignment };
570
+ } catch (err) {
571
+ strapi.log.error(`[${PLUGIN_ID}] realm.addAdmin error:`, err);
572
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
573
+ }
574
+ },
575
+ /**
576
+ * Update admin permissions for a realm
577
+ */
578
+ async updateAdmin(ctx) {
579
+ const user = ctx.state.user;
580
+ const { id, adminId } = ctx.params;
581
+ const { data } = ctx.request.body;
582
+ if (!user) {
583
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
584
+ }
585
+ try {
586
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
587
+ if (!permissionService2.isSuperAdmin(user)) {
588
+ return ctx.forbidden("Only super admins can update realm admin assignments.");
589
+ }
590
+ const { permissions } = data;
591
+ const assignment = await permissionService2.assignUserToRealm(
592
+ parseInt(adminId, 10),
593
+ null,
594
+ id,
595
+ permissions
596
+ );
597
+ ctx.body = { data: assignment };
598
+ } catch (err) {
599
+ strapi.log.error(`[${PLUGIN_ID}] realm.updateAdmin error:`, err);
600
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
601
+ }
602
+ },
603
+ /**
604
+ * Remove an admin from a realm
605
+ */
606
+ async removeAdmin(ctx) {
607
+ const user = ctx.state.user;
608
+ const { id, adminId } = ctx.params;
609
+ if (!user) {
610
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
611
+ }
612
+ try {
613
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
614
+ if (!permissionService2.isSuperAdmin(user)) {
615
+ return ctx.forbidden("Only super admins can remove realm admins.");
616
+ }
617
+ await permissionService2.removeUserFromRealm(parseInt(adminId, 10), id);
618
+ ctx.body = { data: { success: true } };
619
+ } catch (err) {
620
+ strapi.log.error(`[${PLUGIN_ID}] realm.removeAdmin error:`, err);
621
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
622
+ }
623
+ }
624
+ });
625
+ const userController = ({ strapi }) => ({
626
+ /**
627
+ * List users in a realm
628
+ */
629
+ async find(ctx) {
630
+ const user = ctx.state.user;
631
+ const { realmId } = ctx.params;
632
+ const { search, page = 1, pageSize = 25 } = ctx.query;
633
+ if (!user) {
634
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
635
+ }
636
+ try {
637
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
638
+ const result = await userService2.listUsers(
639
+ realmId,
640
+ { search, page: parseInt(page, 10), pageSize: parseInt(pageSize, 10) },
641
+ user
642
+ );
643
+ ctx.body = { data: result };
644
+ } catch (err) {
645
+ strapi.log.error(`[${PLUGIN_ID}] user.find error:`, err);
646
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
647
+ }
648
+ },
649
+ /**
650
+ * Get a single user
651
+ */
652
+ async findOne(ctx) {
653
+ const user = ctx.state.user;
654
+ const { realmId, id } = ctx.params;
655
+ if (!user) {
656
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
657
+ }
658
+ try {
659
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
660
+ const keycloakUser = await userService2.getUser(realmId, id, user);
661
+ ctx.body = { data: keycloakUser };
662
+ } catch (err) {
663
+ strapi.log.error(`[${PLUGIN_ID}] user.findOne error:`, err);
664
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
665
+ }
666
+ },
667
+ /**
668
+ * Create a new user
669
+ */
670
+ async create(ctx) {
671
+ const user = ctx.state.user;
672
+ const { realmId } = ctx.params;
673
+ const { data } = ctx.request.body;
674
+ if (!user) {
675
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
676
+ }
677
+ try {
678
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
679
+ const keycloakUser = await userService2.createUser(realmId, data, user);
680
+ ctx.body = { data: keycloakUser };
681
+ } catch (err) {
682
+ strapi.log.error(`[${PLUGIN_ID}] user.create error:`, err);
683
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
684
+ }
685
+ },
686
+ /**
687
+ * Update a user
688
+ */
689
+ async update(ctx) {
690
+ const user = ctx.state.user;
691
+ const { realmId, id } = ctx.params;
692
+ const { data } = ctx.request.body;
693
+ if (!user) {
694
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
695
+ }
696
+ try {
697
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
698
+ const keycloakUser = await userService2.updateUser(realmId, id, data, user);
699
+ ctx.body = { data: keycloakUser };
700
+ } catch (err) {
701
+ strapi.log.error(`[${PLUGIN_ID}] user.update error:`, err);
702
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
703
+ }
704
+ },
705
+ /**
706
+ * Delete a user
707
+ */
708
+ async delete(ctx) {
709
+ const user = ctx.state.user;
710
+ const { realmId, id } = ctx.params;
711
+ if (!user) {
712
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
713
+ }
714
+ try {
715
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
716
+ await userService2.deleteUser(realmId, id, user);
717
+ ctx.body = { data: { success: true } };
718
+ } catch (err) {
719
+ strapi.log.error(`[${PLUGIN_ID}] user.delete error:`, err);
720
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
721
+ }
722
+ },
723
+ /**
724
+ * Reset user password
725
+ */
726
+ async resetPassword(ctx) {
727
+ const user = ctx.state.user;
728
+ const { realmId, id } = ctx.params;
729
+ const { data } = ctx.request.body;
730
+ if (!user) {
731
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
732
+ }
733
+ try {
734
+ const { password, temporary = true } = data;
735
+ if (!password) {
736
+ return ctx.badRequest("Password is required.");
737
+ }
738
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
739
+ await userService2.resetPassword(realmId, id, password, temporary, user);
740
+ ctx.body = { data: { success: true } };
741
+ } catch (err) {
742
+ strapi.log.error(`[${PLUGIN_ID}] user.resetPassword error:`, err);
743
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
744
+ }
745
+ },
746
+ /**
747
+ * Enable a user
748
+ */
749
+ async enable(ctx) {
750
+ const user = ctx.state.user;
751
+ const { realmId, id } = ctx.params;
752
+ if (!user) {
753
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
754
+ }
755
+ try {
756
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
757
+ await userService2.enableUser(realmId, id, user);
758
+ ctx.body = { data: { success: true } };
759
+ } catch (err) {
760
+ strapi.log.error(`[${PLUGIN_ID}] user.enable error:`, err);
761
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
762
+ }
763
+ },
764
+ /**
765
+ * Disable a user
766
+ */
767
+ async disable(ctx) {
768
+ const user = ctx.state.user;
769
+ const { realmId, id } = ctx.params;
770
+ if (!user) {
771
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
772
+ }
773
+ try {
774
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
775
+ await userService2.disableUser(realmId, id, user);
776
+ ctx.body = { data: { success: true } };
777
+ } catch (err) {
778
+ strapi.log.error(`[${PLUGIN_ID}] user.disable error:`, err);
779
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
780
+ }
781
+ },
782
+ /**
783
+ * Send verification email
784
+ */
785
+ async sendVerifyEmail(ctx) {
786
+ const user = ctx.state.user;
787
+ const { realmId, id } = ctx.params;
788
+ if (!user) {
789
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
790
+ }
791
+ try {
792
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
793
+ await userService2.sendVerificationEmail(realmId, id, user);
794
+ ctx.body = { data: { success: true } };
795
+ } catch (err) {
796
+ strapi.log.error(`[${PLUGIN_ID}] user.sendVerifyEmail error:`, err);
797
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
798
+ }
799
+ },
800
+ /**
801
+ * Send password reset email
802
+ */
803
+ async sendResetPasswordEmail(ctx) {
804
+ const user = ctx.state.user;
805
+ const { realmId, id } = ctx.params;
806
+ if (!user) {
807
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
808
+ }
809
+ try {
810
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
811
+ await userService2.sendResetPasswordEmail(realmId, id, user);
812
+ ctx.body = { data: { success: true } };
813
+ } catch (err) {
814
+ strapi.log.error(`[${PLUGIN_ID}] user.sendResetPasswordEmail error:`, err);
815
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
816
+ }
817
+ },
818
+ /**
819
+ * Get realm roles
820
+ */
821
+ async getRoles(ctx) {
822
+ const user = ctx.state.user;
823
+ const { realmId } = ctx.params;
824
+ if (!user) {
825
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
826
+ }
827
+ try {
828
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
829
+ const roles = await userService2.getRoles(realmId, user);
830
+ ctx.body = { data: roles };
831
+ } catch (err) {
832
+ strapi.log.error(`[${PLUGIN_ID}] user.getRoles error:`, err);
833
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
834
+ }
835
+ },
836
+ /**
837
+ * Get user's roles
838
+ */
839
+ async getUserRoles(ctx) {
840
+ const user = ctx.state.user;
841
+ const { realmId, id } = ctx.params;
842
+ if (!user) {
843
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
844
+ }
845
+ try {
846
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
847
+ const roles = await userService2.getUserRoles(realmId, id, user);
848
+ ctx.body = { data: roles };
849
+ } catch (err) {
850
+ strapi.log.error(`[${PLUGIN_ID}] user.getUserRoles error:`, err);
851
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
852
+ }
853
+ },
854
+ /**
855
+ * Assign roles to user
856
+ */
857
+ async assignRoles(ctx) {
858
+ const user = ctx.state.user;
859
+ const { realmId, id } = ctx.params;
860
+ const { data } = ctx.request.body;
861
+ if (!user) {
862
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
863
+ }
864
+ try {
865
+ const { roles } = data;
866
+ if (!roles || !Array.isArray(roles)) {
867
+ return ctx.badRequest("Roles array is required.");
868
+ }
869
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
870
+ await userService2.assignRoles(realmId, id, roles, user);
871
+ ctx.body = { data: { success: true } };
872
+ } catch (err) {
873
+ strapi.log.error(`[${PLUGIN_ID}] user.assignRoles error:`, err);
874
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
875
+ }
876
+ },
877
+ /**
878
+ * Remove roles from user
879
+ */
880
+ async removeRoles(ctx) {
881
+ const user = ctx.state.user;
882
+ const { realmId, id } = ctx.params;
883
+ const { data } = ctx.request.body;
884
+ if (!user) {
885
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
886
+ }
887
+ try {
888
+ const { roles } = data;
889
+ if (!roles || !Array.isArray(roles)) {
890
+ return ctx.badRequest("Roles array is required.");
891
+ }
892
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
893
+ await userService2.removeRoles(realmId, id, roles, user);
894
+ ctx.body = { data: { success: true } };
895
+ } catch (err) {
896
+ strapi.log.error(`[${PLUGIN_ID}] user.removeRoles error:`, err);
897
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
898
+ }
899
+ },
900
+ /**
901
+ * Bulk import users
902
+ */
903
+ async bulkImport(ctx) {
904
+ const user = ctx.state.user;
905
+ const { realmId } = ctx.params;
906
+ const { data } = ctx.request.body;
907
+ if (!user) {
908
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
909
+ }
910
+ try {
911
+ const { users } = data;
912
+ if (!users || !Array.isArray(users)) {
913
+ return ctx.badRequest("Users array is required.");
914
+ }
915
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
916
+ const result = await userService2.bulkImport(realmId, users, user);
917
+ ctx.body = { data: result };
918
+ } catch (err) {
919
+ strapi.log.error(`[${PLUGIN_ID}] user.bulkImport error:`, err);
920
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
921
+ }
922
+ },
923
+ /**
924
+ * Export users
925
+ */
926
+ async exportUsers(ctx) {
927
+ const user = ctx.state.user;
928
+ const { realmId } = ctx.params;
929
+ const { format = "json" } = ctx.query;
930
+ if (!user) {
931
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
932
+ }
933
+ try {
934
+ const userService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.USER);
935
+ const users = await userService2.exportUsers(realmId, user);
936
+ if (format === "csv") {
937
+ const headers = ["id", "username", "email", "firstName", "lastName", "enabled", "emailVerified"];
938
+ const csvRows = [headers.join(",")];
939
+ for (const u of users) {
940
+ const row = headers.map((h) => {
941
+ const val = u[h];
942
+ if (val === void 0 || val === null) return "";
943
+ if (typeof val === "string" && val.includes(",")) return `"${val}"`;
944
+ return String(val);
945
+ });
946
+ csvRows.push(row.join(","));
947
+ }
948
+ ctx.set("Content-Type", "text/csv");
949
+ ctx.set("Content-Disposition", `attachment; filename="keycloak-users-${realmId}.csv"`);
950
+ ctx.body = csvRows.join("\n");
951
+ } else {
952
+ ctx.body = { data: users };
953
+ }
954
+ } catch (err) {
955
+ strapi.log.error(`[${PLUGIN_ID}] user.exportUsers error:`, err);
956
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
957
+ }
958
+ }
959
+ });
960
+ const auditController = ({ strapi }) => ({
961
+ /**
962
+ * Find audit logs with filters
963
+ */
964
+ async find(ctx) {
965
+ const user = ctx.state.user;
966
+ const { realmName, action, limit = 50, offset = 0 } = ctx.query;
967
+ if (!user) {
968
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
969
+ }
970
+ try {
971
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
972
+ if (!permissionService2.isSuperAdmin(user)) {
973
+ const accessibleRealms = await permissionService2.getAccessibleRealms(user);
974
+ const accessibleRealmNames = accessibleRealms.map((r) => r.name);
975
+ if (realmName && !accessibleRealmNames.includes(realmName)) {
976
+ return ctx.forbidden(ERROR_MESSAGES.REALM_ACCESS_DENIED);
977
+ }
978
+ const auditLogService3 = strapi.plugin(PLUGIN_ID).service(SERVICES.AUDIT_LOG);
979
+ if (!realmName) {
980
+ const allLogs = [];
981
+ for (const rn of accessibleRealmNames) {
982
+ const { entries } = await auditLogService3.find({
983
+ realmName: rn,
984
+ action,
985
+ limit: parseInt(limit, 10),
986
+ offset: parseInt(offset, 10)
987
+ });
988
+ allLogs.push(...entries);
989
+ }
990
+ allLogs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
991
+ ctx.body = { data: allLogs.slice(0, parseInt(limit, 10)) };
992
+ return;
993
+ }
994
+ }
995
+ const auditLogService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.AUDIT_LOG);
996
+ const result = await auditLogService2.find({
997
+ realmName,
998
+ action,
999
+ limit: parseInt(limit, 10),
1000
+ offset: parseInt(offset, 10)
1001
+ });
1002
+ ctx.body = { data: result.entries, meta: { total: result.total } };
1003
+ } catch (err) {
1004
+ strapi.log.error(`[${PLUGIN_ID}] audit.find error:`, err);
1005
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
1006
+ }
1007
+ },
1008
+ /**
1009
+ * Find audit logs by realm
1010
+ */
1011
+ async findByRealm(ctx) {
1012
+ const user = ctx.state.user;
1013
+ const { realmId } = ctx.params;
1014
+ const { limit = 50, offset = 0 } = ctx.query;
1015
+ if (!user) {
1016
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
1017
+ }
1018
+ try {
1019
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
1020
+ const realmService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.REALM);
1021
+ const canAccess = await permissionService2.canAccessRealm(user, realmId, "canRead");
1022
+ if (!canAccess) {
1023
+ return ctx.forbidden(ERROR_MESSAGES.REALM_ACCESS_DENIED);
1024
+ }
1025
+ const realm = await realmService2.findOne(realmId);
1026
+ const auditLogService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.AUDIT_LOG);
1027
+ const result = await auditLogService2.findByRealm(realm.name, {
1028
+ limit: parseInt(limit, 10),
1029
+ offset: parseInt(offset, 10)
1030
+ });
1031
+ ctx.body = { data: result.entries, meta: { total: result.total } };
1032
+ } catch (err) {
1033
+ strapi.log.error(`[${PLUGIN_ID}] audit.findByRealm error:`, err);
1034
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
1035
+ }
1036
+ },
1037
+ /**
1038
+ * Find audit logs by Keycloak user
1039
+ */
1040
+ async findByUser(ctx) {
1041
+ const user = ctx.state.user;
1042
+ const { keycloakUserId } = ctx.params;
1043
+ const { limit = 50, offset = 0 } = ctx.query;
1044
+ if (!user) {
1045
+ return ctx.unauthorized(ERROR_MESSAGES.INSUFFICIENT_PERMISSIONS);
1046
+ }
1047
+ try {
1048
+ const auditLogService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.AUDIT_LOG);
1049
+ const result = await auditLogService2.findByKeycloakUser(keycloakUserId, {
1050
+ limit: parseInt(limit, 10),
1051
+ offset: parseInt(offset, 10)
1052
+ });
1053
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
1054
+ if (!permissionService2.isSuperAdmin(user)) {
1055
+ const accessibleRealms = await permissionService2.getAccessibleRealms(user);
1056
+ const accessibleRealmNames = accessibleRealms.map((r) => r.name);
1057
+ const filteredEntries = result.entries.filter(
1058
+ (e) => accessibleRealmNames.includes(e.realmName)
1059
+ );
1060
+ ctx.body = { data: filteredEntries, meta: { total: filteredEntries.length } };
1061
+ return;
1062
+ }
1063
+ ctx.body = { data: result.entries, meta: { total: result.total } };
1064
+ } catch (err) {
1065
+ strapi.log.error(`[${PLUGIN_ID}] audit.findByUser error:`, err);
1066
+ ctx.throw(400, err.sanitizedMessage || ERROR_MESSAGES.UNKNOWN_ERROR);
1067
+ }
1068
+ }
1069
+ });
1070
+ const controllers = {
1071
+ realm: realmController,
1072
+ user: userController,
1073
+ audit: auditController
1074
+ };
1075
+ const createSanitizedError = (internalMessage, sanitizedMessage) => {
1076
+ const err = new Error(internalMessage);
1077
+ err.sanitizedMessage = sanitizedMessage;
1078
+ return err;
1079
+ };
1080
+ const TOKEN_CACHE = /* @__PURE__ */ new Map();
1081
+ const getCacheKey = (realmConfig2) => `${realmConfig2.serverUrl}:${realmConfig2.realmName}:${realmConfig2.clientId}`;
1082
+ const buildTokenUrl = (serverUrl, realmName) => `${serverUrl}/realms/${realmName}/protocol/openid-connect/token`;
1083
+ const buildAdminUrl = (serverUrl, realmName, path = "") => `${serverUrl}/admin/realms/${realmName}${path}`;
1084
+ const keycloakClientService = ({ strapi }) => ({
1085
+ /**
1086
+ * Retrieves or refreshes an access token for the specified realm.
1087
+ * Tokens are cached and automatically refreshed before expiration.
1088
+ *
1089
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1090
+ * @returns {Promise<string>} Valid access token
1091
+ * @throws {Error} If authentication fails
1092
+ *
1093
+ * @example
1094
+ * const token = await keycloakClient.getAccessToken(realmConfig);
1095
+ * // Use token for API requests
1096
+ */
1097
+ async getAccessToken(realmConfig2) {
1098
+ const cacheKey = getCacheKey(realmConfig2);
1099
+ const cached = TOKEN_CACHE.get(cacheKey);
1100
+ if (cached && Date.now() < cached.expiresAt - TOKEN_EXPIRY_BUFFER_MS) {
1101
+ return cached.accessToken;
1102
+ }
1103
+ try {
1104
+ const tokenUrl = buildTokenUrl(realmConfig2.serverUrl, realmConfig2.realmName);
1105
+ const response = await fetch(tokenUrl, {
1106
+ method: "POST",
1107
+ headers: {
1108
+ "Content-Type": HTTP_HEADERS.CONTENT_TYPE_FORM
1109
+ },
1110
+ body: new URLSearchParams({
1111
+ grant_type: "client_credentials",
1112
+ client_id: realmConfig2.clientId,
1113
+ client_secret: realmConfig2.clientSecret
1114
+ })
1115
+ });
1116
+ if (!response.ok) {
1117
+ const errorText = await response.text();
1118
+ strapi.log.error(`[${PLUGIN_ID}] Token fetch failed: ${errorText}`);
1119
+ throw createSanitizedError(
1120
+ `Token fetch failed: ${errorText}`,
1121
+ ERROR_MESSAGES.KEYCLOAK_AUTH_FAILED
1122
+ );
1123
+ }
1124
+ const data = await response.json();
1125
+ TOKEN_CACHE.set(cacheKey, {
1126
+ accessToken: data.access_token,
1127
+ expiresAt: Date.now() + data.expires_in * 1e3
1128
+ });
1129
+ return data.access_token;
1130
+ } catch (err) {
1131
+ if (err.sanitizedMessage) throw err;
1132
+ strapi.log.error(`[${PLUGIN_ID}] Token error:`, err);
1133
+ throw createSanitizedError(err.message, ERROR_MESSAGES.KEYCLOAK_CONNECTION_FAILED);
1134
+ }
1135
+ },
1136
+ /**
1137
+ * Tests connection to a Keycloak realm by attempting authentication
1138
+ * and fetching realm metadata.
1139
+ *
1140
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1141
+ * @returns {Promise<ConnectionTestResult>} Test result with success status
1142
+ *
1143
+ * @example
1144
+ * const result = await keycloakClient.testConnection(config);
1145
+ * if (result.success) {
1146
+ * console.log(`Connected to ${result.realmDisplayName}`);
1147
+ * }
1148
+ */
1149
+ async testConnection(realmConfig2) {
1150
+ try {
1151
+ const token = await this.getAccessToken(realmConfig2);
1152
+ const url = buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName);
1153
+ const response = await fetch(url, {
1154
+ headers: { Authorization: `Bearer ${token}` }
1155
+ });
1156
+ if (!response.ok) {
1157
+ return { success: false, message: `HTTP ${response.status}: ${response.statusText}` };
1158
+ }
1159
+ const realm = await response.json();
1160
+ return { success: true, realmDisplayName: realm.displayName || realm.realm };
1161
+ } catch (err) {
1162
+ return { success: false, message: err.sanitizedMessage || err.message };
1163
+ }
1164
+ },
1165
+ /**
1166
+ * Fetches a paginated list of users from Keycloak.
1167
+ *
1168
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1169
+ * @param {Object} [options={}] - Query options
1170
+ * @param {string} [options.search=''] - Search query (matches username, email, name)
1171
+ * @param {number} [options.first=0] - Starting index for pagination
1172
+ * @param {number} [options.max=25] - Maximum results to return
1173
+ * @param {boolean} [options.briefRepresentation=true] - Return minimal user data
1174
+ * @returns {Promise<KeycloakUser[]>} Array of user objects
1175
+ * @throws {Error} If the request fails
1176
+ *
1177
+ * @example
1178
+ * const users = await keycloakClient.getUsers(realm, {
1179
+ * search: 'john',
1180
+ * first: 0,
1181
+ * max: 10
1182
+ * });
1183
+ */
1184
+ async getUsers(realmConfig2, { search = "", first = 0, max = PAGINATION.PAGE_SIZE, briefRepresentation = true } = {}) {
1185
+ const token = await this.getAccessToken(realmConfig2);
1186
+ const params = new URLSearchParams({
1187
+ first: String(first),
1188
+ max: String(max),
1189
+ briefRepresentation: String(briefRepresentation)
1190
+ });
1191
+ if (search) {
1192
+ params.set("search", search);
1193
+ }
1194
+ const url = `${buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName, "/users")}?${params}`;
1195
+ const response = await fetch(url, {
1196
+ headers: { Authorization: `Bearer ${token}` }
1197
+ });
1198
+ if (!response.ok) {
1199
+ const errorText = await response.text();
1200
+ strapi.log.error(`[${PLUGIN_ID}] Failed to fetch users: ${errorText}`);
1201
+ throw createSanitizedError(errorText, ERROR_MESSAGES.KEYCLOAK_CONNECTION_FAILED);
1202
+ }
1203
+ return response.json();
1204
+ },
1205
+ /**
1206
+ * Counts total users in a realm, optionally filtered by search query.
1207
+ *
1208
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1209
+ * @param {Object} [options={}] - Query options
1210
+ * @param {string} [options.search=''] - Search query to filter count
1211
+ * @returns {Promise<number>} Total user count
1212
+ *
1213
+ * @example
1214
+ * const total = await keycloakClient.countUsers(realm, { search: 'admin' });
1215
+ */
1216
+ async countUsers(realmConfig2, { search = "" } = {}) {
1217
+ const token = await this.getAccessToken(realmConfig2);
1218
+ const params = new URLSearchParams();
1219
+ if (search) params.set("search", search);
1220
+ const url = `${buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName, "/users/count")}?${params}`;
1221
+ const response = await fetch(url, {
1222
+ headers: { Authorization: `Bearer ${token}` }
1223
+ });
1224
+ if (!response.ok) {
1225
+ return 0;
1226
+ }
1227
+ return response.json();
1228
+ },
1229
+ /**
1230
+ * Retrieves a single user by their Keycloak ID.
1231
+ *
1232
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1233
+ * @param {string} userId - Keycloak user ID
1234
+ * @returns {Promise<KeycloakUser>} User object with full details
1235
+ * @throws {Error} If user not found or request fails
1236
+ *
1237
+ * @example
1238
+ * const user = await keycloakClient.getUserById(realm, 'abc-123');
1239
+ */
1240
+ async getUserById(realmConfig2, userId) {
1241
+ const token = await this.getAccessToken(realmConfig2);
1242
+ const url = buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName, `/users/${userId}`);
1243
+ const response = await fetch(url, {
1244
+ headers: { Authorization: `Bearer ${token}` }
1245
+ });
1246
+ if (!response.ok) {
1247
+ if (response.status === HTTP_STATUS.NOT_FOUND) {
1248
+ throw createSanitizedError(`User ${userId} not found`, ERROR_MESSAGES.USER_NOT_FOUND);
1249
+ }
1250
+ const errorText = await response.text();
1251
+ throw createSanitizedError(errorText, ERROR_MESSAGES.KEYCLOAK_CONNECTION_FAILED);
1252
+ }
1253
+ return response.json();
1254
+ },
1255
+ /**
1256
+ * Creates a new user in Keycloak.
1257
+ *
1258
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1259
+ * @param {Object} userData - User data to create
1260
+ * @param {string} userData.username - Required username
1261
+ * @param {string} [userData.email] - Email address
1262
+ * @param {string} [userData.firstName] - First name
1263
+ * @param {string} [userData.lastName] - Last name
1264
+ * @param {boolean} [userData.enabled=true] - Account enabled status
1265
+ * @param {boolean} [userData.emailVerified=false] - Email verification status
1266
+ * @returns {Promise<{userId: string|null, location: string|null}>} Created user info
1267
+ * @throws {Error} If user creation fails (e.g., duplicate username/email)
1268
+ *
1269
+ * @example
1270
+ * const result = await keycloakClient.createUser(realm, {
1271
+ * username: 'newuser',
1272
+ * email: 'new@example.com',
1273
+ * enabled: true
1274
+ * });
1275
+ */
1276
+ async createUser(realmConfig2, userData) {
1277
+ const token = await this.getAccessToken(realmConfig2);
1278
+ const url = buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName, "/users");
1279
+ const response = await fetch(url, {
1280
+ method: "POST",
1281
+ headers: {
1282
+ Authorization: `Bearer ${token}`,
1283
+ "Content-Type": HTTP_HEADERS.CONTENT_TYPE_JSON
1284
+ },
1285
+ body: JSON.stringify(userData)
1286
+ });
1287
+ if (!response.ok) {
1288
+ const errorText = await response.text();
1289
+ strapi.log.error(`[${PLUGIN_ID}] Failed to create user: ${errorText}`);
1290
+ if (response.status === HTTP_STATUS.CONFLICT) {
1291
+ throw createSanitizedError(errorText, ERROR_MESSAGES.USER_ALREADY_EXISTS);
1292
+ }
1293
+ throw createSanitizedError(errorText, ERROR_MESSAGES.INVALID_USER_DATA);
1294
+ }
1295
+ const location = response.headers.get("Location");
1296
+ const userId = location ? location.split("/").pop() : null;
1297
+ return { userId, location };
1298
+ },
1299
+ /**
1300
+ * Updates an existing user's attributes.
1301
+ *
1302
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1303
+ * @param {string} userId - Keycloak user ID
1304
+ * @param {Object} userData - Updated user attributes
1305
+ * @returns {Promise<{success: boolean}>} Success indicator
1306
+ * @throws {Error} If user not found or update fails
1307
+ *
1308
+ * @example
1309
+ * await keycloakClient.updateUser(realm, userId, {
1310
+ * firstName: 'Updated',
1311
+ * enabled: false
1312
+ * });
1313
+ */
1314
+ async updateUser(realmConfig2, userId, userData) {
1315
+ const token = await this.getAccessToken(realmConfig2);
1316
+ const url = buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName, `/users/${userId}`);
1317
+ const response = await fetch(url, {
1318
+ method: "PUT",
1319
+ headers: {
1320
+ Authorization: `Bearer ${token}`,
1321
+ "Content-Type": HTTP_HEADERS.CONTENT_TYPE_JSON
1322
+ },
1323
+ body: JSON.stringify(userData)
1324
+ });
1325
+ if (!response.ok) {
1326
+ const errorText = await response.text();
1327
+ strapi.log.error(`[${PLUGIN_ID}] Failed to update user: ${errorText}`);
1328
+ if (response.status === HTTP_STATUS.NOT_FOUND) {
1329
+ throw createSanitizedError(errorText, ERROR_MESSAGES.USER_NOT_FOUND);
1330
+ }
1331
+ throw createSanitizedError(errorText, ERROR_MESSAGES.INVALID_USER_DATA);
1332
+ }
1333
+ return { success: true };
1334
+ },
1335
+ /**
1336
+ * Permanently deletes a user from Keycloak.
1337
+ *
1338
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1339
+ * @param {string} userId - Keycloak user ID
1340
+ * @returns {Promise<{success: boolean}>} Success indicator
1341
+ * @throws {Error} If user not found or deletion fails
1342
+ *
1343
+ * @example
1344
+ * await keycloakClient.deleteUser(realm, 'user-id-123');
1345
+ */
1346
+ async deleteUser(realmConfig2, userId) {
1347
+ const token = await this.getAccessToken(realmConfig2);
1348
+ const url = buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName, `/users/${userId}`);
1349
+ const response = await fetch(url, {
1350
+ method: "DELETE",
1351
+ headers: { Authorization: `Bearer ${token}` }
1352
+ });
1353
+ if (!response.ok) {
1354
+ const errorText = await response.text();
1355
+ if (response.status === HTTP_STATUS.NOT_FOUND) {
1356
+ throw createSanitizedError(errorText, ERROR_MESSAGES.USER_NOT_FOUND);
1357
+ }
1358
+ throw createSanitizedError(errorText, ERROR_MESSAGES.KEYCLOAK_CONNECTION_FAILED);
1359
+ }
1360
+ return { success: true };
1361
+ },
1362
+ /**
1363
+ * Sets a new password for a user.
1364
+ *
1365
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1366
+ * @param {string} userId - Keycloak user ID
1367
+ * @param {string} password - New password value
1368
+ * @param {boolean} [temporary=true] - If true, user must change password on next login
1369
+ * @returns {Promise<{success: boolean}>} Success indicator
1370
+ * @throws {Error} If user not found or password reset fails
1371
+ *
1372
+ * @example
1373
+ * // Set temporary password (user must change on login)
1374
+ * await keycloakClient.resetPassword(realm, userId, 'TempPass123!', true);
1375
+ *
1376
+ * // Set permanent password
1377
+ * await keycloakClient.resetPassword(realm, userId, 'NewPass123!', false);
1378
+ */
1379
+ async resetPassword(realmConfig2, userId, password, temporary = true) {
1380
+ const token = await this.getAccessToken(realmConfig2);
1381
+ const url = buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName, `/users/${userId}/reset-password`);
1382
+ const response = await fetch(url, {
1383
+ method: "PUT",
1384
+ headers: {
1385
+ Authorization: `Bearer ${token}`,
1386
+ "Content-Type": HTTP_HEADERS.CONTENT_TYPE_JSON
1387
+ },
1388
+ body: JSON.stringify({
1389
+ type: "password",
1390
+ value: password,
1391
+ temporary
1392
+ })
1393
+ });
1394
+ if (!response.ok) {
1395
+ const errorText = await response.text();
1396
+ if (response.status === HTTP_STATUS.NOT_FOUND) {
1397
+ throw createSanitizedError(errorText, ERROR_MESSAGES.USER_NOT_FOUND);
1398
+ }
1399
+ throw createSanitizedError(errorText, ERROR_MESSAGES.PASSWORD_RESET_FAILED);
1400
+ }
1401
+ return { success: true };
1402
+ },
1403
+ /**
1404
+ * Enables a user account.
1405
+ *
1406
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1407
+ * @param {string} userId - Keycloak user ID
1408
+ * @returns {Promise<{success: boolean}>} Success indicator
1409
+ */
1410
+ async enableUser(realmConfig2, userId) {
1411
+ return this.updateUser(realmConfig2, userId, { enabled: true });
1412
+ },
1413
+ /**
1414
+ * Disables a user account.
1415
+ *
1416
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1417
+ * @param {string} userId - Keycloak user ID
1418
+ * @returns {Promise<{success: boolean}>} Success indicator
1419
+ */
1420
+ async disableUser(realmConfig2, userId) {
1421
+ return this.updateUser(realmConfig2, userId, { enabled: false });
1422
+ },
1423
+ /**
1424
+ * Triggers email actions for a user (e.g., verify email, update password).
1425
+ *
1426
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1427
+ * @param {string} userId - Keycloak user ID
1428
+ * @param {string[]} actions - Array of action types (e.g., ['VERIFY_EMAIL'])
1429
+ * @param {number} [lifespan] - Link validity in seconds (default: 12 hours)
1430
+ * @returns {Promise<{success: boolean}>} Success indicator
1431
+ * @throws {Error} If user not found or email fails
1432
+ *
1433
+ * @example
1434
+ * // Send verification email
1435
+ * await keycloakClient.executeEmailActions(realm, userId, ['VERIFY_EMAIL']);
1436
+ *
1437
+ * // Send password reset with custom lifespan (1 hour)
1438
+ * await keycloakClient.executeEmailActions(realm, userId, ['UPDATE_PASSWORD'], 3600);
1439
+ */
1440
+ async executeEmailActions(realmConfig2, userId, actions, lifespan = EMAIL_ACTION_LIFESPAN_SECONDS) {
1441
+ const token = await this.getAccessToken(realmConfig2);
1442
+ const params = new URLSearchParams({ lifespan: String(lifespan) });
1443
+ const url = `${buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName, `/users/${userId}/execute-actions-email`)}?${params}`;
1444
+ const response = await fetch(url, {
1445
+ method: "PUT",
1446
+ headers: {
1447
+ Authorization: `Bearer ${token}`,
1448
+ "Content-Type": HTTP_HEADERS.CONTENT_TYPE_JSON
1449
+ },
1450
+ body: JSON.stringify(actions)
1451
+ });
1452
+ if (!response.ok) {
1453
+ const errorText = await response.text();
1454
+ if (response.status === HTTP_STATUS.NOT_FOUND) {
1455
+ throw createSanitizedError(errorText, ERROR_MESSAGES.USER_NOT_FOUND);
1456
+ }
1457
+ throw createSanitizedError(errorText, ERROR_MESSAGES.EMAIL_SEND_FAILED);
1458
+ }
1459
+ return { success: true };
1460
+ },
1461
+ /**
1462
+ * Sends an email verification link to the user.
1463
+ *
1464
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1465
+ * @param {string} userId - Keycloak user ID
1466
+ * @returns {Promise<{success: boolean}>} Success indicator
1467
+ */
1468
+ async sendVerificationEmail(realmConfig2, userId) {
1469
+ return this.executeEmailActions(realmConfig2, userId, [KEYCLOAK_EMAIL_ACTIONS.VERIFY_EMAIL]);
1470
+ },
1471
+ /**
1472
+ * Sends a password reset email to the user.
1473
+ *
1474
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1475
+ * @param {string} userId - Keycloak user ID
1476
+ * @returns {Promise<{success: boolean}>} Success indicator
1477
+ */
1478
+ async sendResetPasswordEmail(realmConfig2, userId) {
1479
+ return this.executeEmailActions(realmConfig2, userId, [KEYCLOAK_EMAIL_ACTIONS.UPDATE_PASSWORD]);
1480
+ },
1481
+ /**
1482
+ * Retrieves all realm-level roles.
1483
+ *
1484
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1485
+ * @returns {Promise<KeycloakRole[]>} Array of realm roles
1486
+ *
1487
+ * @example
1488
+ * const roles = await keycloakClient.getRealmRoles(realm);
1489
+ * // [{ id: '...', name: 'admin', ... }, ...]
1490
+ */
1491
+ async getRealmRoles(realmConfig2) {
1492
+ const token = await this.getAccessToken(realmConfig2);
1493
+ const url = buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName, "/roles");
1494
+ const response = await fetch(url, {
1495
+ headers: { Authorization: `Bearer ${token}` }
1496
+ });
1497
+ if (!response.ok) {
1498
+ const errorText = await response.text();
1499
+ throw createSanitizedError(errorText, ERROR_MESSAGES.KEYCLOAK_CONNECTION_FAILED);
1500
+ }
1501
+ return response.json();
1502
+ },
1503
+ /**
1504
+ * Gets realm roles assigned to a specific user.
1505
+ *
1506
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1507
+ * @param {string} userId - Keycloak user ID
1508
+ * @returns {Promise<KeycloakRole[]>} Array of assigned roles
1509
+ *
1510
+ * @example
1511
+ * const userRoles = await keycloakClient.getUserRoles(realm, userId);
1512
+ */
1513
+ async getUserRoles(realmConfig2, userId) {
1514
+ const token = await this.getAccessToken(realmConfig2);
1515
+ const url = buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName, `/users/${userId}/role-mappings/realm`);
1516
+ const response = await fetch(url, {
1517
+ headers: { Authorization: `Bearer ${token}` }
1518
+ });
1519
+ if (!response.ok) {
1520
+ if (response.status === HTTP_STATUS.NOT_FOUND) {
1521
+ throw createSanitizedError(`User ${userId} not found`, ERROR_MESSAGES.USER_NOT_FOUND);
1522
+ }
1523
+ const errorText = await response.text();
1524
+ throw createSanitizedError(errorText, ERROR_MESSAGES.KEYCLOAK_CONNECTION_FAILED);
1525
+ }
1526
+ return response.json();
1527
+ },
1528
+ /**
1529
+ * Assigns realm roles to a user.
1530
+ *
1531
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1532
+ * @param {string} userId - Keycloak user ID
1533
+ * @param {KeycloakRole[]} roles - Array of role objects with id and name
1534
+ * @returns {Promise<{success: boolean}>} Success indicator
1535
+ *
1536
+ * @example
1537
+ * await keycloakClient.assignRoles(realm, userId, [
1538
+ * { id: 'role-id-1', name: 'admin' },
1539
+ * { id: 'role-id-2', name: 'editor' }
1540
+ * ]);
1541
+ */
1542
+ async assignRoles(realmConfig2, userId, roles) {
1543
+ const token = await this.getAccessToken(realmConfig2);
1544
+ const url = buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName, `/users/${userId}/role-mappings/realm`);
1545
+ const response = await fetch(url, {
1546
+ method: "POST",
1547
+ headers: {
1548
+ Authorization: `Bearer ${token}`,
1549
+ "Content-Type": HTTP_HEADERS.CONTENT_TYPE_JSON
1550
+ },
1551
+ body: JSON.stringify(roles)
1552
+ });
1553
+ if (!response.ok) {
1554
+ const errorText = await response.text();
1555
+ throw createSanitizedError(errorText, ERROR_MESSAGES.ROLE_ASSIGNMENT_FAILED);
1556
+ }
1557
+ return { success: true };
1558
+ },
1559
+ /**
1560
+ * Removes realm roles from a user.
1561
+ *
1562
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1563
+ * @param {string} userId - Keycloak user ID
1564
+ * @param {KeycloakRole[]} roles - Array of role objects to remove
1565
+ * @returns {Promise<{success: boolean}>} Success indicator
1566
+ *
1567
+ * @example
1568
+ * await keycloakClient.removeRoles(realm, userId, [
1569
+ * { id: 'role-id-1', name: 'admin' }
1570
+ * ]);
1571
+ */
1572
+ async removeRoles(realmConfig2, userId, roles) {
1573
+ const token = await this.getAccessToken(realmConfig2);
1574
+ const url = buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName, `/users/${userId}/role-mappings/realm`);
1575
+ const response = await fetch(url, {
1576
+ method: "DELETE",
1577
+ headers: {
1578
+ Authorization: `Bearer ${token}`,
1579
+ "Content-Type": HTTP_HEADERS.CONTENT_TYPE_JSON
1580
+ },
1581
+ body: JSON.stringify(roles)
1582
+ });
1583
+ if (!response.ok) {
1584
+ const errorText = await response.text();
1585
+ throw createSanitizedError(errorText, ERROR_MESSAGES.ROLE_REMOVAL_FAILED);
1586
+ }
1587
+ return { success: true };
1588
+ },
1589
+ /**
1590
+ * Gets active sessions for a user.
1591
+ *
1592
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1593
+ * @param {string} userId - Keycloak user ID
1594
+ * @returns {Promise<Object[]>} Array of session objects
1595
+ *
1596
+ * @example
1597
+ * const sessions = await keycloakClient.getUserSessions(realm, userId);
1598
+ */
1599
+ async getUserSessions(realmConfig2, userId) {
1600
+ const token = await this.getAccessToken(realmConfig2);
1601
+ const url = buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName, `/users/${userId}/sessions`);
1602
+ const response = await fetch(url, {
1603
+ headers: { Authorization: `Bearer ${token}` }
1604
+ });
1605
+ if (!response.ok) {
1606
+ return [];
1607
+ }
1608
+ return response.json();
1609
+ },
1610
+ /**
1611
+ * Terminates all active sessions for a user.
1612
+ *
1613
+ * @param {RealmConfig} realmConfig - Realm connection configuration
1614
+ * @param {string} userId - Keycloak user ID
1615
+ * @returns {Promise<{success: boolean}>} Success indicator
1616
+ *
1617
+ * @example
1618
+ * await keycloakClient.logoutUser(realm, userId);
1619
+ */
1620
+ async logoutUser(realmConfig2, userId) {
1621
+ const token = await this.getAccessToken(realmConfig2);
1622
+ const url = buildAdminUrl(realmConfig2.serverUrl, realmConfig2.realmName, `/users/${userId}/logout`);
1623
+ const response = await fetch(url, {
1624
+ method: "POST",
1625
+ headers: { Authorization: `Bearer ${token}` }
1626
+ });
1627
+ if (!response.ok && response.status !== HTTP_STATUS.NO_CONTENT) {
1628
+ const errorText = await response.text();
1629
+ throw createSanitizedError(errorText, ERROR_MESSAGES.LOGOUT_FAILED);
1630
+ }
1631
+ return { success: true };
1632
+ },
1633
+ /**
1634
+ * Clears cached access tokens.
1635
+ * Call this when realm configuration changes to force re-authentication.
1636
+ *
1637
+ * @param {RealmConfig} [realmConfig] - Specific realm to clear, or all if omitted
1638
+ *
1639
+ * @example
1640
+ * // Clear specific realm's token
1641
+ * keycloakClient.clearTokenCache(realmConfig);
1642
+ *
1643
+ * // Clear all cached tokens
1644
+ * keycloakClient.clearTokenCache();
1645
+ */
1646
+ clearTokenCache(realmConfig2) {
1647
+ if (realmConfig2) {
1648
+ const cacheKey = getCacheKey(realmConfig2);
1649
+ TOKEN_CACHE.delete(cacheKey);
1650
+ } else {
1651
+ TOKEN_CACHE.clear();
1652
+ }
1653
+ }
1654
+ });
1655
+ const auditLogService = ({ strapi }) => ({
1656
+ /**
1657
+ * Creates an audit log entry.
1658
+ * This method is designed to be non-blocking and fail-safe - audit logging
1659
+ * errors are caught and logged but do not interrupt the main operation.
1660
+ *
1661
+ * @param {AuditLogParams} params - Log entry parameters
1662
+ * @returns {Promise<AuditLogEntry|undefined>} Created entry or undefined on error
1663
+ *
1664
+ * @example
1665
+ * await auditLogService.log({
1666
+ * realmName: 'production',
1667
+ * realmDisplayName: 'Production Users',
1668
+ * action: AUDIT_ACTIONS.CREATE_USER,
1669
+ * keycloakUserId: 'user-123',
1670
+ * keycloakUsername: 'john.doe',
1671
+ * details: { email: 'john@example.com' },
1672
+ * user: ctx.state.user
1673
+ * });
1674
+ */
1675
+ async log({ realmName, realmDisplayName, action, keycloakUserId, keycloakUsername, details, user }) {
1676
+ try {
1677
+ return await strapi.documents(CONTENT_TYPES.AUDIT_LOG).create({
1678
+ data: {
1679
+ realmName,
1680
+ realmDisplayName: realmDisplayName || realmName,
1681
+ action,
1682
+ keycloakUserId: keycloakUserId || null,
1683
+ keycloakUsername: keycloakUsername || null,
1684
+ details: details || null,
1685
+ performedById: user?.id || null,
1686
+ performedByEmail: user?.email || UNKNOWN_USER_EMAIL
1687
+ }
1688
+ });
1689
+ } catch (err) {
1690
+ strapi.log.error(`[${PLUGIN_ID}] Failed to create audit log entry:`, err);
1691
+ }
1692
+ },
1693
+ /**
1694
+ * Queries audit log entries with optional filters.
1695
+ *
1696
+ * @param {AuditLogQueryParams} [params={}] - Query parameters
1697
+ * @returns {Promise<AuditLogQueryResult>} Paginated log entries with total count
1698
+ *
1699
+ * @example
1700
+ * // Get all entries for a realm
1701
+ * const { entries, total } = await auditLogService.find({
1702
+ * realmName: 'production',
1703
+ * limit: 25,
1704
+ * offset: 0
1705
+ * });
1706
+ *
1707
+ * @example
1708
+ * // Get all password reset actions
1709
+ * const { entries } = await auditLogService.find({
1710
+ * action: AUDIT_ACTIONS.RESET_PASSWORD
1711
+ * });
1712
+ */
1713
+ async find({
1714
+ realmName,
1715
+ action,
1716
+ keycloakUserId,
1717
+ limit = PAGINATION.AUDIT_LOG_LIMIT,
1718
+ offset = 0
1719
+ } = {}) {
1720
+ const filters = {};
1721
+ if (realmName) {
1722
+ filters.realmName = realmName;
1723
+ }
1724
+ if (action) {
1725
+ filters.action = action;
1726
+ }
1727
+ if (keycloakUserId) {
1728
+ filters.keycloakUserId = keycloakUserId;
1729
+ }
1730
+ const [entries, count] = await Promise.all([
1731
+ strapi.documents(CONTENT_TYPES.AUDIT_LOG).findMany({
1732
+ filters,
1733
+ sort: { createdAt: "desc" },
1734
+ limit,
1735
+ offset
1736
+ }),
1737
+ strapi.documents(CONTENT_TYPES.AUDIT_LOG).count({ filters })
1738
+ ]);
1739
+ return { entries, total: count };
1740
+ },
1741
+ /**
1742
+ * Retrieves audit logs for a specific realm.
1743
+ * Convenience method that wraps find() with realm filter.
1744
+ *
1745
+ * @param {string} realmName - Realm slug identifier
1746
+ * @param {Object} [options={}] - Query options
1747
+ * @param {number} [options.limit=50] - Maximum entries
1748
+ * @param {number} [options.offset=0] - Starting offset
1749
+ * @returns {Promise<AuditLogQueryResult>} Paginated log entries
1750
+ *
1751
+ * @example
1752
+ * const { entries, total } = await auditLogService.findByRealm('production', {
1753
+ * limit: 100
1754
+ * });
1755
+ */
1756
+ async findByRealm(realmName, { limit = PAGINATION.AUDIT_LOG_LIMIT, offset = 0 } = {}) {
1757
+ return this.find({ realmName, limit, offset });
1758
+ },
1759
+ /**
1760
+ * Retrieves audit logs for a specific Keycloak user.
1761
+ * Useful for viewing all actions performed on a single user.
1762
+ *
1763
+ * @param {string} keycloakUserId - Keycloak user ID
1764
+ * @param {Object} [options={}] - Query options
1765
+ * @param {number} [options.limit=50] - Maximum entries
1766
+ * @param {number} [options.offset=0] - Starting offset
1767
+ * @returns {Promise<AuditLogQueryResult>} Paginated log entries
1768
+ *
1769
+ * @example
1770
+ * const { entries } = await auditLogService.findByKeycloakUser('user-123');
1771
+ * // Shows: created, updated, password reset, role changes, etc.
1772
+ */
1773
+ async findByKeycloakUser(keycloakUserId, { limit = PAGINATION.AUDIT_LOG_LIMIT, offset = 0 } = {}) {
1774
+ return this.find({ keycloakUserId, limit, offset });
1775
+ }
1776
+ });
1777
+ const SUPER_ADMIN_PERMISSIONS = {
1778
+ canRead: true,
1779
+ canCreate: true,
1780
+ canUpdate: true,
1781
+ canDelete: true,
1782
+ canManageRoles: true,
1783
+ canResetPassword: true
1784
+ };
1785
+ const permissionService = ({ strapi }) => ({
1786
+ /**
1787
+ * Checks if a user has the Strapi super admin role.
1788
+ * Super admins have unrestricted access to all realms and operations.
1789
+ *
1790
+ * @param {StrapiUser} user - Strapi user object with roles
1791
+ * @returns {boolean} True if user is a super admin
1792
+ *
1793
+ * @example
1794
+ * if (permissionService.isSuperAdmin(ctx.state.user)) {
1795
+ * // Allow unrestricted access
1796
+ * }
1797
+ */
1798
+ isSuperAdmin(user) {
1799
+ if (!user || !user.roles) return false;
1800
+ return user.roles.some((role) => role.code === STRAPI_SUPER_ADMIN_ROLE);
1801
+ },
1802
+ /**
1803
+ * Retrieves the realm admin assignment for a specific user and realm.
1804
+ *
1805
+ * @param {number} strapiUserId - Strapi user ID
1806
+ * @param {string} realmConfigId - Realm document ID
1807
+ * @returns {Promise<RealmAdminAssignment|null>} Assignment or null if none exists
1808
+ *
1809
+ * @example
1810
+ * const assignment = await permissionService.getRealmAdminAssignment(user.id, realmId);
1811
+ * if (assignment?.permissions.canCreate) {
1812
+ * // User can create
1813
+ * }
1814
+ */
1815
+ async getRealmAdminAssignment(strapiUserId, realmConfigId) {
1816
+ const assignments = await strapi.documents(CONTENT_TYPES.REALM_ADMIN).findMany({
1817
+ filters: {
1818
+ strapiUserId,
1819
+ realmConfig: { documentId: realmConfigId }
1820
+ },
1821
+ populate: ["realmConfig"]
1822
+ });
1823
+ return assignments[0] || null;
1824
+ },
1825
+ /**
1826
+ * Checks if a user has a specific permission for a realm.
1827
+ *
1828
+ * @param {StrapiUser} user - Strapi user object
1829
+ * @param {string} realmConfigId - Realm document ID
1830
+ * @param {keyof RealmPermissions} [permission='canRead'] - Permission to check
1831
+ * @returns {Promise<boolean>} True if user has the permission
1832
+ *
1833
+ * @example
1834
+ * const canDelete = await permissionService.canAccessRealm(user, realmId, 'canDelete');
1835
+ * if (!canDelete) {
1836
+ * throw new ForbiddenError();
1837
+ * }
1838
+ */
1839
+ async canAccessRealm(user, realmConfigId, permission = "canRead") {
1840
+ if (this.isSuperAdmin(user)) {
1841
+ return true;
1842
+ }
1843
+ const assignment = await this.getRealmAdminAssignment(user.id, realmConfigId);
1844
+ if (!assignment) {
1845
+ return false;
1846
+ }
1847
+ const permissions = assignment.permissions || DEFAULT_REALM_PERMISSIONS;
1848
+ return permissions[permission] === true;
1849
+ },
1850
+ /**
1851
+ * Retrieves all realms accessible by a user with their permissions.
1852
+ * Super admins see all enabled realms with full permissions.
1853
+ * Regular users see only realms they're assigned to.
1854
+ *
1855
+ * @param {StrapiUser} user - Strapi user object
1856
+ * @returns {Promise<Array<Object & {permissions: RealmPermissions}>>} Accessible realms with permissions
1857
+ *
1858
+ * @example
1859
+ * const realms = await permissionService.getAccessibleRealms(ctx.state.user);
1860
+ * // Returns realms with permissions attached
1861
+ */
1862
+ async getAccessibleRealms(user) {
1863
+ if (this.isSuperAdmin(user)) {
1864
+ const allRealms = await strapi.documents(CONTENT_TYPES.REALM_CONFIG).findMany({
1865
+ filters: { enabled: true },
1866
+ sort: { displayName: "asc" }
1867
+ });
1868
+ return allRealms.map((realm) => ({
1869
+ ...realm,
1870
+ permissions: SUPER_ADMIN_PERMISSIONS
1871
+ }));
1872
+ }
1873
+ const assignments = await strapi.documents(CONTENT_TYPES.REALM_ADMIN).findMany({
1874
+ filters: {
1875
+ strapiUserId: user.id,
1876
+ realmConfig: { enabled: true }
1877
+ },
1878
+ populate: ["realmConfig"]
1879
+ });
1880
+ return assignments.filter((assignment) => assignment.realmConfig).map((assignment) => ({
1881
+ ...assignment.realmConfig,
1882
+ permissions: assignment.permissions || DEFAULT_REALM_PERMISSIONS
1883
+ }));
1884
+ },
1885
+ /**
1886
+ * Gets a user's specific permissions for a realm.
1887
+ *
1888
+ * @param {StrapiUser} user - Strapi user object
1889
+ * @param {string} realmConfigId - Realm document ID
1890
+ * @returns {Promise<RealmPermissions|null>} User's permissions or null if no access
1891
+ *
1892
+ * @example
1893
+ * const permissions = await permissionService.getRealmPermissions(user, realmId);
1894
+ * if (permissions?.canUpdate) {
1895
+ * // Show edit button
1896
+ * }
1897
+ */
1898
+ async getRealmPermissions(user, realmConfigId) {
1899
+ if (this.isSuperAdmin(user)) {
1900
+ return SUPER_ADMIN_PERMISSIONS;
1901
+ }
1902
+ const assignment = await this.getRealmAdminAssignment(user.id, realmConfigId);
1903
+ return assignment?.permissions || null;
1904
+ },
1905
+ /**
1906
+ * Assigns a Strapi user to a realm with specified permissions.
1907
+ * If an assignment already exists, it updates the permissions.
1908
+ *
1909
+ * @param {number} strapiUserId - Strapi user ID to assign
1910
+ * @param {string} strapiUserEmail - User's email for reference
1911
+ * @param {string} realmConfigId - Realm document ID
1912
+ * @param {Partial<RealmPermissions>} [permissions={}] - Permissions to grant
1913
+ * @returns {Promise<RealmAdminAssignment>} Created or updated assignment
1914
+ *
1915
+ * @example
1916
+ * await permissionService.assignUserToRealm(
1917
+ * userId,
1918
+ * 'admin@example.com',
1919
+ * realmId,
1920
+ * { canRead: true, canCreate: true, canUpdate: true }
1921
+ * );
1922
+ */
1923
+ async assignUserToRealm(strapiUserId, strapiUserEmail, realmConfigId, permissions = {}) {
1924
+ const existing = await this.getRealmAdminAssignment(strapiUserId, realmConfigId);
1925
+ const mergedPermissions = { ...DEFAULT_REALM_PERMISSIONS, ...permissions };
1926
+ if (existing) {
1927
+ return strapi.documents(CONTENT_TYPES.REALM_ADMIN).update({
1928
+ documentId: existing.documentId,
1929
+ data: {
1930
+ permissions: mergedPermissions
1931
+ }
1932
+ });
1933
+ }
1934
+ return strapi.documents(CONTENT_TYPES.REALM_ADMIN).create({
1935
+ data: {
1936
+ strapiUserId,
1937
+ strapiUserEmail,
1938
+ realmConfig: realmConfigId,
1939
+ permissions: mergedPermissions
1940
+ }
1941
+ });
1942
+ },
1943
+ /**
1944
+ * Removes a user's access to a realm.
1945
+ *
1946
+ * @param {number} strapiUserId - Strapi user ID
1947
+ * @param {string} realmConfigId - Realm document ID
1948
+ * @returns {Promise<boolean>} True if assignment was removed, false if none existed
1949
+ *
1950
+ * @example
1951
+ * const removed = await permissionService.removeUserFromRealm(userId, realmId);
1952
+ */
1953
+ async removeUserFromRealm(strapiUserId, realmConfigId) {
1954
+ const assignment = await this.getRealmAdminAssignment(strapiUserId, realmConfigId);
1955
+ if (assignment) {
1956
+ await strapi.documents(CONTENT_TYPES.REALM_ADMIN).delete({
1957
+ documentId: assignment.documentId
1958
+ });
1959
+ return true;
1960
+ }
1961
+ return false;
1962
+ },
1963
+ /**
1964
+ * Retrieves all admin assignments for a specific realm.
1965
+ *
1966
+ * @param {string} realmConfigId - Realm document ID
1967
+ * @returns {Promise<RealmAdminAssignment[]>} Array of admin assignments
1968
+ *
1969
+ * @example
1970
+ * const admins = await permissionService.getRealmAdmins(realmId);
1971
+ * // Display list of users who can manage this realm
1972
+ */
1973
+ async getRealmAdmins(realmConfigId) {
1974
+ const assignments = await strapi.documents(CONTENT_TYPES.REALM_ADMIN).findMany({
1975
+ filters: {
1976
+ realmConfig: { documentId: realmConfigId }
1977
+ }
1978
+ });
1979
+ return assignments;
1980
+ }
1981
+ });
1982
+ const realmService = ({ strapi }) => ({
1983
+ /**
1984
+ * Gets the Keycloak client service instance.
1985
+ *
1986
+ * @returns {Object} Keycloak client service
1987
+ * @private
1988
+ */
1989
+ get keycloakClient() {
1990
+ return strapi.plugin(PLUGIN_ID).service(SERVICES.KEYCLOAK_CLIENT);
1991
+ },
1992
+ /**
1993
+ * Retrieves all realm configurations.
1994
+ * Note: clientSecret is not included in results (private field).
1995
+ *
1996
+ * @returns {Promise<RealmConfig[]>} Array of realm configurations sorted by displayName
1997
+ *
1998
+ * @example
1999
+ * const realms = await realmService.findAll();
2000
+ */
2001
+ async findAll() {
2002
+ return strapi.documents(CONTENT_TYPES.REALM_CONFIG).findMany({
2003
+ sort: { displayName: "asc" }
2004
+ });
2005
+ },
2006
+ /**
2007
+ * Retrieves a single realm by document ID.
2008
+ * Note: clientSecret is not included (private field).
2009
+ *
2010
+ * @param {string} documentId - Strapi document ID
2011
+ * @returns {Promise<RealmConfig>} Realm configuration
2012
+ * @throws {Error} If realm not found
2013
+ *
2014
+ * @example
2015
+ * const realm = await realmService.findOne('abc123');
2016
+ */
2017
+ async findOne(documentId) {
2018
+ const realm = await strapi.documents(CONTENT_TYPES.REALM_CONFIG).findOne({
2019
+ documentId
2020
+ });
2021
+ if (!realm) {
2022
+ throw createSanitizedError(
2023
+ `Realm ${documentId} not found`,
2024
+ ERROR_MESSAGES.REALM_NOT_FOUND
2025
+ );
2026
+ }
2027
+ return realm;
2028
+ },
2029
+ /**
2030
+ * Retrieves a realm with its clientSecret for internal Keycloak API calls.
2031
+ * Uses db.query to bypass Strapi's private field sanitization.
2032
+ *
2033
+ * SECURITY: Only use this method when the clientSecret is actually needed
2034
+ * for Keycloak authentication. Never expose the result to API responses.
2035
+ *
2036
+ * @param {string} documentId - Strapi document ID
2037
+ * @returns {Promise<RealmConfig & {clientSecret: string}>} Full realm config with secret
2038
+ * @throws {Error} If realm not found
2039
+ *
2040
+ * @example
2041
+ * // Internal use only - for Keycloak API calls
2042
+ * const realm = await realmService.findOneWithSecret(documentId);
2043
+ * const token = await keycloakClient.getAccessToken(realm);
2044
+ */
2045
+ async findOneWithSecret(documentId) {
2046
+ const realm = await strapi.db.query(CONTENT_TYPES.REALM_CONFIG).findOne({
2047
+ where: { documentId }
2048
+ });
2049
+ if (!realm) {
2050
+ throw createSanitizedError(
2051
+ `Realm ${documentId} not found`,
2052
+ ERROR_MESSAGES.REALM_NOT_FOUND
2053
+ );
2054
+ }
2055
+ return realm;
2056
+ },
2057
+ /**
2058
+ * Finds a realm by its unique slug name.
2059
+ *
2060
+ * @param {string} name - Realm slug name
2061
+ * @returns {Promise<RealmConfig|null>} Realm configuration or null if not found
2062
+ *
2063
+ * @example
2064
+ * const realm = await realmService.findByName('production-users');
2065
+ */
2066
+ async findByName(name) {
2067
+ const realms = await strapi.documents(CONTENT_TYPES.REALM_CONFIG).findMany({
2068
+ filters: { name }
2069
+ });
2070
+ return realms[0] || null;
2071
+ },
2072
+ /**
2073
+ * Validates realm name format.
2074
+ *
2075
+ * @param {string} name - Name to validate
2076
+ * @returns {boolean} True if valid
2077
+ * @private
2078
+ */
2079
+ isValidName(name) {
2080
+ return REALM_NAME_PATTERN.test(name);
2081
+ },
2082
+ /**
2083
+ * Normalizes a server URL by removing trailing slashes.
2084
+ *
2085
+ * @param {string} url - URL to normalize
2086
+ * @returns {string} Normalized URL
2087
+ * @private
2088
+ */
2089
+ normalizeServerUrl(url) {
2090
+ return url.replace(/\/$/, "");
2091
+ },
2092
+ /**
2093
+ * Creates a new realm configuration.
2094
+ *
2095
+ * @param {RealmConfigData} data - Realm configuration data
2096
+ * @returns {Promise<RealmConfig>} Created realm configuration
2097
+ * @throws {Error} If validation fails or name already exists
2098
+ *
2099
+ * @example
2100
+ * const realm = await realmService.create({
2101
+ * name: 'production-users',
2102
+ * displayName: 'Production Users',
2103
+ * serverUrl: 'https://keycloak.example.com',
2104
+ * realmName: 'production',
2105
+ * clientId: 'strapi-admin',
2106
+ * clientSecret: 'secret-value'
2107
+ * });
2108
+ */
2109
+ async create(data) {
2110
+ const { name, displayName, serverUrl, realmName, clientId, clientSecret } = data;
2111
+ if (!name || !displayName || !serverUrl || !realmName || !clientId) {
2112
+ throw createSanitizedError(
2113
+ "Missing required fields",
2114
+ ERROR_MESSAGES.MISSING_REQUIRED_FIELDS
2115
+ );
2116
+ }
2117
+ if (!this.isValidName(name)) {
2118
+ throw createSanitizedError(
2119
+ `Invalid name format: ${name}`,
2120
+ ERROR_MESSAGES.INVALID_NAME_FORMAT
2121
+ );
2122
+ }
2123
+ const existing = await this.findByName(name);
2124
+ if (existing) {
2125
+ throw createSanitizedError(
2126
+ `Realm with name ${name} already exists`,
2127
+ ERROR_MESSAGES.REALM_NAME_EXISTS
2128
+ );
2129
+ }
2130
+ return strapi.documents(CONTENT_TYPES.REALM_CONFIG).create({
2131
+ data: {
2132
+ name,
2133
+ displayName,
2134
+ serverUrl: this.normalizeServerUrl(serverUrl),
2135
+ realmName,
2136
+ clientId,
2137
+ clientSecret: clientSecret || null,
2138
+ enabled: data.enabled !== false,
2139
+ color: data.color || DEFAULT_REALM_COLOR
2140
+ }
2141
+ });
2142
+ },
2143
+ /**
2144
+ * Updates an existing realm configuration.
2145
+ *
2146
+ * @param {string} documentId - Strapi document ID
2147
+ * @param {Partial<RealmConfigData>} data - Fields to update
2148
+ * @returns {Promise<RealmConfig>} Updated realm configuration
2149
+ * @throws {Error} If validation fails or realm not found
2150
+ *
2151
+ * @example
2152
+ * const updated = await realmService.update(documentId, {
2153
+ * displayName: 'New Display Name',
2154
+ * enabled: false
2155
+ * });
2156
+ */
2157
+ async update(documentId, data) {
2158
+ const existing = await this.findOne(documentId);
2159
+ if (data.name && data.name !== existing.name) {
2160
+ if (!this.isValidName(data.name)) {
2161
+ throw createSanitizedError(
2162
+ `Invalid name format: ${data.name}`,
2163
+ ERROR_MESSAGES.INVALID_NAME_FORMAT
2164
+ );
2165
+ }
2166
+ const duplicate = await this.findByName(data.name);
2167
+ if (duplicate && duplicate.documentId !== documentId) {
2168
+ throw createSanitizedError(
2169
+ `Realm with name ${data.name} already exists`,
2170
+ ERROR_MESSAGES.REALM_NAME_EXISTS
2171
+ );
2172
+ }
2173
+ }
2174
+ const updateData = { ...data };
2175
+ if (updateData.serverUrl) {
2176
+ updateData.serverUrl = this.normalizeServerUrl(updateData.serverUrl);
2177
+ }
2178
+ const connectionFieldsChanged = updateData.serverUrl || updateData.realmName || updateData.clientId || updateData.clientSecret;
2179
+ if (connectionFieldsChanged) {
2180
+ this.keycloakClient.clearTokenCache(existing);
2181
+ }
2182
+ return strapi.documents(CONTENT_TYPES.REALM_CONFIG).update({
2183
+ documentId,
2184
+ data: updateData
2185
+ });
2186
+ },
2187
+ /**
2188
+ * Deletes a realm configuration and all associated admin assignments.
2189
+ *
2190
+ * @param {string} documentId - Strapi document ID
2191
+ * @returns {Promise<{success: boolean}>} Success indicator
2192
+ * @throws {Error} If realm not found
2193
+ *
2194
+ * @example
2195
+ * await realmService.delete(documentId);
2196
+ */
2197
+ async delete(documentId) {
2198
+ const realm = await this.findOne(documentId);
2199
+ this.keycloakClient.clearTokenCache(realm);
2200
+ const assignments = await strapi.documents(CONTENT_TYPES.REALM_ADMIN).findMany({
2201
+ filters: {
2202
+ realmConfig: { documentId }
2203
+ }
2204
+ });
2205
+ for (const assignment of assignments) {
2206
+ await strapi.documents(CONTENT_TYPES.REALM_ADMIN).delete({
2207
+ documentId: assignment.documentId
2208
+ });
2209
+ }
2210
+ await strapi.documents(CONTENT_TYPES.REALM_CONFIG).delete({
2211
+ documentId
2212
+ });
2213
+ return { success: true };
2214
+ },
2215
+ /**
2216
+ * Tests connection to a saved realm configuration.
2217
+ *
2218
+ * @param {string} documentId - Strapi document ID
2219
+ * @returns {Promise<{success: boolean, message?: string, realmDisplayName?: string}>} Test result
2220
+ *
2221
+ * @example
2222
+ * const result = await realmService.testConnection(documentId);
2223
+ * if (result.success) {
2224
+ * console.log(`Connected to ${result.realmDisplayName}`);
2225
+ * } else {
2226
+ * console.error(result.message);
2227
+ * }
2228
+ */
2229
+ async testConnection(documentId) {
2230
+ const realmBasic = await this.findOne(documentId);
2231
+ if (!realmBasic.enabled) {
2232
+ return { success: false, message: ERROR_MESSAGES.REALM_DISABLED };
2233
+ }
2234
+ const realm = await this.findOneWithSecret(documentId);
2235
+ return this.keycloakClient.testConnection(realm);
2236
+ },
2237
+ /**
2238
+ * Tests connection with raw configuration data (before saving).
2239
+ * Useful for validating credentials during realm creation/editing.
2240
+ *
2241
+ * @param {RealmConfigData} config - Raw realm configuration
2242
+ * @returns {Promise<{success: boolean, message?: string, realmDisplayName?: string}>} Test result
2243
+ *
2244
+ * @example
2245
+ * const result = await realmService.testConnectionRaw({
2246
+ * serverUrl: 'https://keycloak.example.com',
2247
+ * realmName: 'my-realm',
2248
+ * clientId: 'strapi-admin',
2249
+ * clientSecret: 'secret'
2250
+ * });
2251
+ */
2252
+ async testConnectionRaw(config2) {
2253
+ return this.keycloakClient.testConnection(config2);
2254
+ }
2255
+ });
2256
+ const userService = ({ strapi }) => ({
2257
+ /**
2258
+ * Gets the realm service instance.
2259
+ * @returns {Object} Realm service
2260
+ * @private
2261
+ */
2262
+ get realmService() {
2263
+ return strapi.plugin(PLUGIN_ID).service(SERVICES.REALM);
2264
+ },
2265
+ /**
2266
+ * Gets the Keycloak client service instance.
2267
+ * @returns {Object} Keycloak client service
2268
+ * @private
2269
+ */
2270
+ get keycloakClient() {
2271
+ return strapi.plugin(PLUGIN_ID).service(SERVICES.KEYCLOAK_CLIENT);
2272
+ },
2273
+ /**
2274
+ * Gets the permission service instance.
2275
+ * @returns {Object} Permission service
2276
+ * @private
2277
+ */
2278
+ get permissionService() {
2279
+ return strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
2280
+ },
2281
+ /**
2282
+ * Gets the audit log service instance.
2283
+ * @returns {Object} Audit log service
2284
+ * @private
2285
+ */
2286
+ get auditLogService() {
2287
+ return strapi.plugin(PLUGIN_ID).service(SERVICES.AUDIT_LOG);
2288
+ },
2289
+ /**
2290
+ * Validates user permission and returns realm config with credentials.
2291
+ * This is the primary permission gate for all user operations.
2292
+ *
2293
+ * @param {string} realmId - Realm document ID
2294
+ * @param {StrapiUser} user - Strapi user requesting access
2295
+ * @param {keyof RealmPermissions} permission - Required permission
2296
+ * @returns {Promise<Object>} Realm config with clientSecret for API calls
2297
+ * @throws {Error} If realm disabled or user lacks permission
2298
+ * @private
2299
+ *
2300
+ * @example
2301
+ * const realm = await this.getRealmWithPermission(realmId, user, 'canCreate');
2302
+ * // Now safe to call Keycloak APIs
2303
+ */
2304
+ async getRealmWithPermission(realmId, user, permission) {
2305
+ const realmBasic = await this.realmService.findOne(realmId);
2306
+ if (!realmBasic.enabled) {
2307
+ throw createSanitizedError("Realm disabled", ERROR_MESSAGES.REALM_DISABLED);
2308
+ }
2309
+ const hasPermission = await this.permissionService.canAccessRealm(user, realmId, permission);
2310
+ if (!hasPermission) {
2311
+ throw createSanitizedError(
2312
+ `User ${user.id} lacks ${permission} for realm ${realmId}`,
2313
+ ERROR_MESSAGES.REALM_ACCESS_DENIED
2314
+ );
2315
+ }
2316
+ return this.realmService.findOneWithSecret(realmId);
2317
+ },
2318
+ /**
2319
+ * Lists users from a Keycloak realm with pagination.
2320
+ *
2321
+ * @param {string} realmId - Realm document ID
2322
+ * @param {Object} [options={}] - Query options
2323
+ * @param {string} [options.search=''] - Search query
2324
+ * @param {number} [options.page=1] - Page number (1-indexed)
2325
+ * @param {number} [options.pageSize=25] - Items per page
2326
+ * @param {StrapiUser} strapiUser - User making the request
2327
+ * @returns {Promise<PaginatedUsersResult>} Paginated users
2328
+ *
2329
+ * @example
2330
+ * const { users, pagination } = await userService.listUsers(
2331
+ * realmId,
2332
+ * { search: 'john', page: 1, pageSize: 10 },
2333
+ * ctx.state.user
2334
+ * );
2335
+ */
2336
+ async listUsers(realmId, { search = "", page = 1, pageSize = PAGINATION.PAGE_SIZE } = {}, strapiUser) {
2337
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canRead");
2338
+ const first = (page - 1) * pageSize;
2339
+ const [users, total] = await Promise.all([
2340
+ this.keycloakClient.getUsers(realm, { search, first, max: pageSize }),
2341
+ this.keycloakClient.countUsers(realm, { search })
2342
+ ]);
2343
+ return {
2344
+ users,
2345
+ pagination: {
2346
+ page,
2347
+ pageSize,
2348
+ total,
2349
+ pageCount: Math.ceil(total / pageSize)
2350
+ }
2351
+ };
2352
+ },
2353
+ /**
2354
+ * Retrieves a single user by Keycloak ID.
2355
+ *
2356
+ * @param {string} realmId - Realm document ID
2357
+ * @param {string} keycloakUserId - Keycloak user ID
2358
+ * @param {StrapiUser} strapiUser - User making the request
2359
+ * @returns {Promise<KeycloakUser>} User details
2360
+ *
2361
+ * @example
2362
+ * const user = await userService.getUser(realmId, 'kc-user-123', ctx.state.user);
2363
+ */
2364
+ async getUser(realmId, keycloakUserId, strapiUser) {
2365
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canRead");
2366
+ return this.keycloakClient.getUserById(realm, keycloakUserId);
2367
+ },
2368
+ /**
2369
+ * Creates a new user in Keycloak.
2370
+ *
2371
+ * @param {string} realmId - Realm document ID
2372
+ * @param {Object} userData - User data
2373
+ * @param {string} userData.username - Required username
2374
+ * @param {string} [userData.email] - Email address
2375
+ * @param {string} [userData.firstName] - First name
2376
+ * @param {string} [userData.lastName] - Last name
2377
+ * @param {boolean} [userData.enabled=true] - Account enabled
2378
+ * @param {StrapiUser} strapiUser - User making the request
2379
+ * @returns {Promise<KeycloakUser>} Created user
2380
+ *
2381
+ * @example
2382
+ * const user = await userService.createUser(
2383
+ * realmId,
2384
+ * { username: 'newuser', email: 'new@example.com' },
2385
+ * ctx.state.user
2386
+ * );
2387
+ */
2388
+ async createUser(realmId, userData, strapiUser) {
2389
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canCreate");
2390
+ const result = await this.keycloakClient.createUser(realm, userData);
2391
+ await this.auditLogService.log({
2392
+ realmName: realm.name,
2393
+ realmDisplayName: realm.displayName,
2394
+ action: AUDIT_ACTIONS.CREATE_USER,
2395
+ keycloakUserId: result.userId,
2396
+ keycloakUsername: userData.username,
2397
+ details: {
2398
+ email: userData.email,
2399
+ firstName: userData.firstName,
2400
+ lastName: userData.lastName
2401
+ },
2402
+ user: strapiUser
2403
+ });
2404
+ if (result.userId) {
2405
+ return this.keycloakClient.getUserById(realm, result.userId);
2406
+ }
2407
+ return result;
2408
+ },
2409
+ /**
2410
+ * Updates an existing user's attributes.
2411
+ *
2412
+ * @param {string} realmId - Realm document ID
2413
+ * @param {string} keycloakUserId - Keycloak user ID
2414
+ * @param {Object} userData - Fields to update
2415
+ * @param {StrapiUser} strapiUser - User making the request
2416
+ * @returns {Promise<KeycloakUser>} Updated user
2417
+ *
2418
+ * @example
2419
+ * const user = await userService.updateUser(
2420
+ * realmId,
2421
+ * 'kc-user-123',
2422
+ * { firstName: 'Updated', lastName: 'Name' },
2423
+ * ctx.state.user
2424
+ * );
2425
+ */
2426
+ async updateUser(realmId, keycloakUserId, userData, strapiUser) {
2427
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canUpdate");
2428
+ const currentUser = await this.keycloakClient.getUserById(realm, keycloakUserId);
2429
+ await this.keycloakClient.updateUser(realm, keycloakUserId, userData);
2430
+ await this.auditLogService.log({
2431
+ realmName: realm.name,
2432
+ realmDisplayName: realm.displayName,
2433
+ action: AUDIT_ACTIONS.UPDATE_USER,
2434
+ keycloakUserId,
2435
+ keycloakUsername: currentUser.username,
2436
+ details: { changes: userData },
2437
+ user: strapiUser
2438
+ });
2439
+ return this.keycloakClient.getUserById(realm, keycloakUserId);
2440
+ },
2441
+ /**
2442
+ * Permanently deletes a user from Keycloak.
2443
+ *
2444
+ * @param {string} realmId - Realm document ID
2445
+ * @param {string} keycloakUserId - Keycloak user ID
2446
+ * @param {StrapiUser} strapiUser - User making the request
2447
+ * @returns {Promise<{success: boolean}>} Success indicator
2448
+ *
2449
+ * @example
2450
+ * await userService.deleteUser(realmId, 'kc-user-123', ctx.state.user);
2451
+ */
2452
+ async deleteUser(realmId, keycloakUserId, strapiUser) {
2453
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canDelete");
2454
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
2455
+ await this.keycloakClient.deleteUser(realm, keycloakUserId);
2456
+ await this.auditLogService.log({
2457
+ realmName: realm.name,
2458
+ realmDisplayName: realm.displayName,
2459
+ action: AUDIT_ACTIONS.DELETE_USER,
2460
+ keycloakUserId,
2461
+ keycloakUsername: user.username,
2462
+ details: { email: user.email },
2463
+ user: strapiUser
2464
+ });
2465
+ return { success: true };
2466
+ },
2467
+ /**
2468
+ * Resets a user's password.
2469
+ * Requires canResetPassword permission (separate from canUpdate for compliance).
2470
+ *
2471
+ * @param {string} realmId - Realm document ID
2472
+ * @param {string} keycloakUserId - Keycloak user ID
2473
+ * @param {string} password - New password
2474
+ * @param {boolean} temporary - Whether password must be changed on next login
2475
+ * @param {StrapiUser} strapiUser - User making the request
2476
+ * @returns {Promise<{success: boolean}>} Success indicator
2477
+ *
2478
+ * @example
2479
+ * // Set temporary password
2480
+ * await userService.resetPassword(realmId, userId, 'TempPass123!', true, user);
2481
+ */
2482
+ async resetPassword(realmId, keycloakUserId, password, temporary, strapiUser) {
2483
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canResetPassword");
2484
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
2485
+ await this.keycloakClient.resetPassword(realm, keycloakUserId, password, temporary);
2486
+ await this.auditLogService.log({
2487
+ realmName: realm.name,
2488
+ realmDisplayName: realm.displayName,
2489
+ action: AUDIT_ACTIONS.RESET_PASSWORD,
2490
+ keycloakUserId,
2491
+ keycloakUsername: user.username,
2492
+ details: { temporary },
2493
+ user: strapiUser
2494
+ });
2495
+ return { success: true };
2496
+ },
2497
+ /**
2498
+ * Enables a user account.
2499
+ *
2500
+ * @param {string} realmId - Realm document ID
2501
+ * @param {string} keycloakUserId - Keycloak user ID
2502
+ * @param {StrapiUser} strapiUser - User making the request
2503
+ * @returns {Promise<{success: boolean}>} Success indicator
2504
+ */
2505
+ async enableUser(realmId, keycloakUserId, strapiUser) {
2506
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canUpdate");
2507
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
2508
+ await this.keycloakClient.enableUser(realm, keycloakUserId);
2509
+ await this.auditLogService.log({
2510
+ realmName: realm.name,
2511
+ realmDisplayName: realm.displayName,
2512
+ action: AUDIT_ACTIONS.ENABLE_USER,
2513
+ keycloakUserId,
2514
+ keycloakUsername: user.username,
2515
+ user: strapiUser
2516
+ });
2517
+ return { success: true };
2518
+ },
2519
+ /**
2520
+ * Disables a user account.
2521
+ *
2522
+ * @param {string} realmId - Realm document ID
2523
+ * @param {string} keycloakUserId - Keycloak user ID
2524
+ * @param {StrapiUser} strapiUser - User making the request
2525
+ * @returns {Promise<{success: boolean}>} Success indicator
2526
+ */
2527
+ async disableUser(realmId, keycloakUserId, strapiUser) {
2528
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canUpdate");
2529
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
2530
+ await this.keycloakClient.disableUser(realm, keycloakUserId);
2531
+ await this.auditLogService.log({
2532
+ realmName: realm.name,
2533
+ realmDisplayName: realm.displayName,
2534
+ action: AUDIT_ACTIONS.DISABLE_USER,
2535
+ keycloakUserId,
2536
+ keycloakUsername: user.username,
2537
+ user: strapiUser
2538
+ });
2539
+ return { success: true };
2540
+ },
2541
+ /**
2542
+ * Sends an email verification link to the user.
2543
+ *
2544
+ * @param {string} realmId - Realm document ID
2545
+ * @param {string} keycloakUserId - Keycloak user ID
2546
+ * @param {StrapiUser} strapiUser - User making the request
2547
+ * @returns {Promise<{success: boolean}>} Success indicator
2548
+ */
2549
+ async sendVerificationEmail(realmId, keycloakUserId, strapiUser) {
2550
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canUpdate");
2551
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
2552
+ await this.keycloakClient.sendVerificationEmail(realm, keycloakUserId);
2553
+ await this.auditLogService.log({
2554
+ realmName: realm.name,
2555
+ realmDisplayName: realm.displayName,
2556
+ action: AUDIT_ACTIONS.SEND_VERIFY_EMAIL,
2557
+ keycloakUserId,
2558
+ keycloakUsername: user.username,
2559
+ user: strapiUser
2560
+ });
2561
+ return { success: true };
2562
+ },
2563
+ /**
2564
+ * Sends a password reset email to the user.
2565
+ * Requires canResetPassword permission.
2566
+ *
2567
+ * @param {string} realmId - Realm document ID
2568
+ * @param {string} keycloakUserId - Keycloak user ID
2569
+ * @param {StrapiUser} strapiUser - User making the request
2570
+ * @returns {Promise<{success: boolean}>} Success indicator
2571
+ */
2572
+ async sendResetPasswordEmail(realmId, keycloakUserId, strapiUser) {
2573
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canResetPassword");
2574
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
2575
+ await this.keycloakClient.sendResetPasswordEmail(realm, keycloakUserId);
2576
+ await this.auditLogService.log({
2577
+ realmName: realm.name,
2578
+ realmDisplayName: realm.displayName,
2579
+ action: AUDIT_ACTIONS.SEND_RESET_PASSWORD_EMAIL,
2580
+ keycloakUserId,
2581
+ keycloakUsername: user.username,
2582
+ user: strapiUser
2583
+ });
2584
+ return { success: true };
2585
+ },
2586
+ /**
2587
+ * Retrieves all realm roles.
2588
+ *
2589
+ * @param {string} realmId - Realm document ID
2590
+ * @param {StrapiUser} strapiUser - User making the request
2591
+ * @returns {Promise<KeycloakRole[]>} Array of realm roles
2592
+ */
2593
+ async getRoles(realmId, strapiUser) {
2594
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canRead");
2595
+ return this.keycloakClient.getRealmRoles(realm);
2596
+ },
2597
+ /**
2598
+ * Gets roles assigned to a user.
2599
+ *
2600
+ * @param {string} realmId - Realm document ID
2601
+ * @param {string} keycloakUserId - Keycloak user ID
2602
+ * @param {StrapiUser} strapiUser - User making the request
2603
+ * @returns {Promise<KeycloakRole[]>} User's assigned roles
2604
+ */
2605
+ async getUserRoles(realmId, keycloakUserId, strapiUser) {
2606
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canRead");
2607
+ return this.keycloakClient.getUserRoles(realm, keycloakUserId);
2608
+ },
2609
+ /**
2610
+ * Assigns roles to a user.
2611
+ *
2612
+ * @param {string} realmId - Realm document ID
2613
+ * @param {string} keycloakUserId - Keycloak user ID
2614
+ * @param {KeycloakRole[]} roles - Roles to assign
2615
+ * @param {StrapiUser} strapiUser - User making the request
2616
+ * @returns {Promise<{success: boolean}>} Success indicator
2617
+ */
2618
+ async assignRoles(realmId, keycloakUserId, roles, strapiUser) {
2619
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canManageRoles");
2620
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
2621
+ await this.keycloakClient.assignRoles(realm, keycloakUserId, roles);
2622
+ await this.auditLogService.log({
2623
+ realmName: realm.name,
2624
+ realmDisplayName: realm.displayName,
2625
+ action: AUDIT_ACTIONS.ASSIGN_ROLE,
2626
+ keycloakUserId,
2627
+ keycloakUsername: user.username,
2628
+ details: { roles: roles.map((r) => r.name) },
2629
+ user: strapiUser
2630
+ });
2631
+ return { success: true };
2632
+ },
2633
+ /**
2634
+ * Removes roles from a user.
2635
+ *
2636
+ * @param {string} realmId - Realm document ID
2637
+ * @param {string} keycloakUserId - Keycloak user ID
2638
+ * @param {KeycloakRole[]} roles - Roles to remove
2639
+ * @param {StrapiUser} strapiUser - User making the request
2640
+ * @returns {Promise<{success: boolean}>} Success indicator
2641
+ */
2642
+ async removeRoles(realmId, keycloakUserId, roles, strapiUser) {
2643
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canManageRoles");
2644
+ const user = await this.keycloakClient.getUserById(realm, keycloakUserId);
2645
+ await this.keycloakClient.removeRoles(realm, keycloakUserId, roles);
2646
+ await this.auditLogService.log({
2647
+ realmName: realm.name,
2648
+ realmDisplayName: realm.displayName,
2649
+ action: AUDIT_ACTIONS.REMOVE_ROLE,
2650
+ keycloakUserId,
2651
+ keycloakUsername: user.username,
2652
+ details: { roles: roles.map((r) => r.name) },
2653
+ user: strapiUser
2654
+ });
2655
+ return { success: true };
2656
+ },
2657
+ /**
2658
+ * Bulk imports users from an array of user data.
2659
+ * Processes users sequentially to handle errors gracefully.
2660
+ *
2661
+ * @param {string} realmId - Realm document ID
2662
+ * @param {Object[]} users - Array of user data objects
2663
+ * @param {StrapiUser} strapiUser - User making the request
2664
+ * @returns {Promise<BulkImportResult>} Import results with success/failed arrays
2665
+ *
2666
+ * @example
2667
+ * const result = await userService.bulkImport(realmId, [
2668
+ * { username: 'user1', email: 'user1@example.com' },
2669
+ * { username: 'user2', email: 'user2@example.com' }
2670
+ * ], ctx.state.user);
2671
+ * console.log(`Created ${result.success.length}, Failed ${result.failed.length}`);
2672
+ */
2673
+ async bulkImport(realmId, users, strapiUser) {
2674
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canCreate");
2675
+ const results = {
2676
+ success: [],
2677
+ failed: []
2678
+ };
2679
+ for (const userData of users) {
2680
+ try {
2681
+ const result = await this.keycloakClient.createUser(realm, userData);
2682
+ results.success.push({
2683
+ username: userData.username,
2684
+ email: userData.email,
2685
+ userId: result.userId
2686
+ });
2687
+ } catch (err) {
2688
+ results.failed.push({
2689
+ username: userData.username,
2690
+ email: userData.email,
2691
+ error: err.sanitizedMessage || err.message
2692
+ });
2693
+ }
2694
+ }
2695
+ await this.auditLogService.log({
2696
+ realmName: realm.name,
2697
+ realmDisplayName: realm.displayName,
2698
+ action: AUDIT_ACTIONS.BULK_IMPORT,
2699
+ details: {
2700
+ totalAttempted: users.length,
2701
+ successCount: results.success.length,
2702
+ failedCount: results.failed.length
2703
+ },
2704
+ user: strapiUser
2705
+ });
2706
+ return results;
2707
+ },
2708
+ /**
2709
+ * Exports all users from a realm.
2710
+ * Fetches users in batches for performance.
2711
+ *
2712
+ * @param {string} realmId - Realm document ID
2713
+ * @param {StrapiUser} strapiUser - User making the request
2714
+ * @returns {Promise<KeycloakUser[]>} All users in the realm
2715
+ *
2716
+ * @example
2717
+ * const users = await userService.exportUsers(realmId, ctx.state.user);
2718
+ * // Convert to CSV or JSON for download
2719
+ */
2720
+ async exportUsers(realmId, strapiUser) {
2721
+ const realm = await this.getRealmWithPermission(realmId, strapiUser, "canRead");
2722
+ const allUsers = [];
2723
+ let first = 0;
2724
+ const max = PAGINATION.EXPORT_BATCH_SIZE;
2725
+ let hasMore = true;
2726
+ while (hasMore) {
2727
+ const users = await this.keycloakClient.getUsers(realm, {
2728
+ first,
2729
+ max,
2730
+ briefRepresentation: false
2731
+ });
2732
+ allUsers.push(...users);
2733
+ first += max;
2734
+ hasMore = users.length === max;
2735
+ }
2736
+ return allUsers;
2737
+ }
2738
+ });
2739
+ const services = {
2740
+ "keycloak-client": keycloakClientService,
2741
+ "audit-log": auditLogService,
2742
+ permission: permissionService,
2743
+ realm: realmService,
2744
+ user: userService
2745
+ };
2746
+ const isAuthenticated = (policyContext) => {
2747
+ return Boolean(policyContext.state.user);
2748
+ };
2749
+ const canAccessRealm = async (policyContext, config2, { strapi }) => {
2750
+ const { realmId, id } = policyContext.params;
2751
+ const user = policyContext.state.user;
2752
+ const targetRealmId = realmId || id;
2753
+ if (!user || !targetRealmId) {
2754
+ return false;
2755
+ }
2756
+ const permissionService2 = strapi.plugin(PLUGIN_ID).service(SERVICES.PERMISSION);
2757
+ if (permissionService2.isSuperAdmin(user)) {
2758
+ return true;
2759
+ }
2760
+ const permission = config2?.permission || "canRead";
2761
+ return permissionService2.canAccessRealm(user, targetRealmId, permission);
2762
+ };
2763
+ const policies = {
2764
+ "is-authenticated": isAuthenticated,
2765
+ "can-access-realm": canAccessRealm
2766
+ };
2767
+ const admin = [
2768
+ // ==================== REALM MANAGEMENT ====================
2769
+ // Get all realms (filtered by user access)
2770
+ {
2771
+ method: "GET",
2772
+ path: "/realms",
2773
+ handler: "realm.find",
2774
+ config: { policies: [] }
2775
+ },
2776
+ // Test connection with raw config (before saving)
2777
+ {
2778
+ method: "POST",
2779
+ path: "/realms/test-connection",
2780
+ handler: "realm.testConnectionRaw",
2781
+ config: { policies: [] }
2782
+ },
2783
+ // Get a single realm
2784
+ {
2785
+ method: "GET",
2786
+ path: "/realms/:id",
2787
+ handler: "realm.findOne",
2788
+ config: { policies: [] }
2789
+ },
2790
+ // Create a new realm
2791
+ {
2792
+ method: "POST",
2793
+ path: "/realms",
2794
+ handler: "realm.create",
2795
+ config: { policies: [] }
2796
+ },
2797
+ // Update a realm
2798
+ {
2799
+ method: "PUT",
2800
+ path: "/realms/:id",
2801
+ handler: "realm.update",
2802
+ config: { policies: [] }
2803
+ },
2804
+ // Delete a realm
2805
+ {
2806
+ method: "DELETE",
2807
+ path: "/realms/:id",
2808
+ handler: "realm.delete",
2809
+ config: { policies: [] }
2810
+ },
2811
+ // Test realm connection
2812
+ {
2813
+ method: "POST",
2814
+ path: "/realms/:id/test",
2815
+ handler: "realm.testConnection",
2816
+ config: { policies: [] }
2817
+ },
2818
+ // ==================== REALM ADMIN ASSIGNMENTS ====================
2819
+ // Get admins for a realm
2820
+ {
2821
+ method: "GET",
2822
+ path: "/realms/:id/admins",
2823
+ handler: "realm.getAdmins",
2824
+ config: { policies: [] }
2825
+ },
2826
+ // Add admin to a realm
2827
+ {
2828
+ method: "POST",
2829
+ path: "/realms/:id/admins",
2830
+ handler: "realm.addAdmin",
2831
+ config: { policies: [] }
2832
+ },
2833
+ // Update admin permissions
2834
+ {
2835
+ method: "PUT",
2836
+ path: "/realms/:id/admins/:adminId",
2837
+ handler: "realm.updateAdmin",
2838
+ config: { policies: [] }
2839
+ },
2840
+ // Remove admin from realm
2841
+ {
2842
+ method: "DELETE",
2843
+ path: "/realms/:id/admins/:adminId",
2844
+ handler: "realm.removeAdmin",
2845
+ config: { policies: [] }
2846
+ },
2847
+ // ==================== KEYCLOAK USERS ====================
2848
+ // List users in a realm
2849
+ {
2850
+ method: "GET",
2851
+ path: "/realms/:realmId/users",
2852
+ handler: "user.find",
2853
+ config: { policies: [] }
2854
+ },
2855
+ // Export users
2856
+ {
2857
+ method: "GET",
2858
+ path: "/realms/:realmId/users/export",
2859
+ handler: "user.exportUsers",
2860
+ config: { policies: [] }
2861
+ },
2862
+ // Bulk import users
2863
+ {
2864
+ method: "POST",
2865
+ path: "/realms/:realmId/users/import",
2866
+ handler: "user.bulkImport",
2867
+ config: { policies: [] }
2868
+ },
2869
+ // Get a single user
2870
+ {
2871
+ method: "GET",
2872
+ path: "/realms/:realmId/users/:id",
2873
+ handler: "user.findOne",
2874
+ config: { policies: [] }
2875
+ },
2876
+ // Create a user
2877
+ {
2878
+ method: "POST",
2879
+ path: "/realms/:realmId/users",
2880
+ handler: "user.create",
2881
+ config: { policies: [] }
2882
+ },
2883
+ // Update a user
2884
+ {
2885
+ method: "PUT",
2886
+ path: "/realms/:realmId/users/:id",
2887
+ handler: "user.update",
2888
+ config: { policies: [] }
2889
+ },
2890
+ // Delete a user
2891
+ {
2892
+ method: "DELETE",
2893
+ path: "/realms/:realmId/users/:id",
2894
+ handler: "user.delete",
2895
+ config: { policies: [] }
2896
+ },
2897
+ // ==================== USER ACTIONS ====================
2898
+ // Reset password
2899
+ {
2900
+ method: "POST",
2901
+ path: "/realms/:realmId/users/:id/reset-password",
2902
+ handler: "user.resetPassword",
2903
+ config: { policies: [] }
2904
+ },
2905
+ // Enable user
2906
+ {
2907
+ method: "POST",
2908
+ path: "/realms/:realmId/users/:id/enable",
2909
+ handler: "user.enable",
2910
+ config: { policies: [] }
2911
+ },
2912
+ // Disable user
2913
+ {
2914
+ method: "POST",
2915
+ path: "/realms/:realmId/users/:id/disable",
2916
+ handler: "user.disable",
2917
+ config: { policies: [] }
2918
+ },
2919
+ // Send verification email
2920
+ {
2921
+ method: "POST",
2922
+ path: "/realms/:realmId/users/:id/send-verify-email",
2923
+ handler: "user.sendVerifyEmail",
2924
+ config: { policies: [] }
2925
+ },
2926
+ // Send password reset email
2927
+ {
2928
+ method: "POST",
2929
+ path: "/realms/:realmId/users/:id/send-reset-password-email",
2930
+ handler: "user.sendResetPasswordEmail",
2931
+ config: { policies: [] }
2932
+ },
2933
+ // ==================== ROLES ====================
2934
+ // Get realm roles
2935
+ {
2936
+ method: "GET",
2937
+ path: "/realms/:realmId/roles",
2938
+ handler: "user.getRoles",
2939
+ config: { policies: [] }
2940
+ },
2941
+ // Get user's roles
2942
+ {
2943
+ method: "GET",
2944
+ path: "/realms/:realmId/users/:id/roles",
2945
+ handler: "user.getUserRoles",
2946
+ config: { policies: [] }
2947
+ },
2948
+ // Assign roles to user
2949
+ {
2950
+ method: "POST",
2951
+ path: "/realms/:realmId/users/:id/roles",
2952
+ handler: "user.assignRoles",
2953
+ config: { policies: [] }
2954
+ },
2955
+ // Remove roles from user
2956
+ {
2957
+ method: "DELETE",
2958
+ path: "/realms/:realmId/users/:id/roles",
2959
+ handler: "user.removeRoles",
2960
+ config: { policies: [] }
2961
+ },
2962
+ // ==================== AUDIT LOGS ====================
2963
+ // Get all audit logs
2964
+ {
2965
+ method: "GET",
2966
+ path: "/audit-logs",
2967
+ handler: "audit.find",
2968
+ config: { policies: [] }
2969
+ },
2970
+ // Get audit logs by realm
2971
+ {
2972
+ method: "GET",
2973
+ path: "/audit-logs/realm/:realmId",
2974
+ handler: "audit.findByRealm",
2975
+ config: { policies: [] }
2976
+ },
2977
+ // Get audit logs by Keycloak user
2978
+ {
2979
+ method: "GET",
2980
+ path: "/audit-logs/user/:keycloakUserId",
2981
+ handler: "audit.findByUser",
2982
+ config: { policies: [] }
2983
+ }
2984
+ ];
2985
+ const routes = {
2986
+ admin: {
2987
+ type: "admin",
2988
+ routes: admin
2989
+ }
2990
+ };
2991
+ const index = {
2992
+ bootstrap,
2993
+ register,
2994
+ destroy,
2995
+ config,
2996
+ contentTypes,
2997
+ controllers,
2998
+ services,
2999
+ policies,
3000
+ routes
3001
+ };
3002
+ export {
3003
+ index as default
3004
+ };