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,791 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useParams, useNavigate } from 'react-router-dom';
3
+ import { useIntl } from 'react-intl';
4
+ import {
5
+ Button,
6
+ Box,
7
+ Grid,
8
+ TextInput,
9
+ Toggle,
10
+ Typography,
11
+ Flex,
12
+ Alert,
13
+ Loader,
14
+ Field,
15
+ Tabs,
16
+ Table,
17
+ Thead,
18
+ Tbody,
19
+ Tr,
20
+ Th,
21
+ Td,
22
+ IconButton,
23
+ Checkbox,
24
+ Dialog,
25
+ Modal,
26
+ SingleSelect,
27
+ SingleSelectOption,
28
+ Badge,
29
+ EmptyStateLayout,
30
+ } from '@strapi/design-system';
31
+ import { Layouts } from '@strapi/strapi/admin';
32
+ import { ArrowLeft, Check, Play, Plus, Trash, Pencil, User } from '@strapi/icons';
33
+
34
+ import { getTrad } from '../../constants';
35
+ import useRealms from '../../hooks/useRealms';
36
+ import useRealmAdmins from '../../hooks/useRealmAdmins';
37
+ import pluginId from '../../pluginId';
38
+
39
+ const PERMISSIONS = [
40
+ { key: 'canRead', label: 'Read', description: 'View users' },
41
+ { key: 'canCreate', label: 'Create', description: 'Add new users' },
42
+ { key: 'canUpdate', label: 'Update', description: 'Edit user details' },
43
+ { key: 'canDelete', label: 'Delete', description: 'Remove users' },
44
+ { key: 'canResetPassword', label: 'Reset Password', description: 'Change user passwords' },
45
+ { key: 'canManageRoles', label: 'Manage Roles', description: 'Assign/remove Keycloak roles' },
46
+ ];
47
+
48
+ const RealmEditPage = () => {
49
+ const { id } = useParams();
50
+ const navigate = useNavigate();
51
+ const { formatMessage } = useIntl();
52
+ const { fetchOne, create, update, testConnectionRaw } = useRealms();
53
+ const {
54
+ admins,
55
+ strapiUsers,
56
+ isLoading: isLoadingAdmins,
57
+ fetchAdmins,
58
+ fetchStrapiUsers,
59
+ addAdmin,
60
+ updateAdmin,
61
+ removeAdmin,
62
+ } = useRealmAdmins(id);
63
+
64
+ const isEditMode = !!id;
65
+
66
+ const [activeTab, setActiveTab] = useState('configuration');
67
+ const [isLoading, setIsLoading] = useState(isEditMode);
68
+ const [isSaving, setIsSaving] = useState(false);
69
+ const [isTesting, setIsTesting] = useState(false);
70
+ const [testResult, setTestResult] = useState(null);
71
+ const [formData, setFormData] = useState({
72
+ name: '',
73
+ displayName: '',
74
+ serverUrl: '',
75
+ realmName: '',
76
+ clientId: '',
77
+ clientSecret: '',
78
+ enabled: true,
79
+ color: '#4945ff',
80
+ });
81
+ const [errors, setErrors] = useState({});
82
+
83
+ // Admin management state
84
+ const [showAddModal, setShowAddModal] = useState(false);
85
+ const [showEditModal, setShowEditModal] = useState(false);
86
+ const [selectedAdmin, setSelectedAdmin] = useState(null);
87
+ const [deleteAdminId, setDeleteAdminId] = useState(null);
88
+ const [newAdminUserId, setNewAdminUserId] = useState('');
89
+ const [adminPermissions, setAdminPermissions] = useState({
90
+ canRead: true,
91
+ canCreate: false,
92
+ canUpdate: false,
93
+ canDelete: false,
94
+ canResetPassword: false,
95
+ canManageRoles: false,
96
+ });
97
+
98
+ useEffect(() => {
99
+ if (isEditMode) {
100
+ fetchOne(id)
101
+ .then((realm) => {
102
+ setFormData({
103
+ name: realm.name || '',
104
+ displayName: realm.displayName || '',
105
+ serverUrl: realm.serverUrl || '',
106
+ realmName: realm.realmName || '',
107
+ clientId: realm.clientId || '',
108
+ clientSecret: '',
109
+ enabled: realm.enabled !== false,
110
+ color: realm.color || '#4945ff',
111
+ });
112
+ })
113
+ .catch(() => {
114
+ navigate(`/settings/${pluginId}`);
115
+ })
116
+ .finally(() => {
117
+ setIsLoading(false);
118
+ });
119
+ }
120
+ }, [id, isEditMode, fetchOne, navigate]);
121
+
122
+ useEffect(() => {
123
+ if (isEditMode && activeTab === 'admins') {
124
+ fetchAdmins();
125
+ fetchStrapiUsers();
126
+ }
127
+ }, [isEditMode, activeTab, fetchAdmins, fetchStrapiUsers]);
128
+
129
+ const handleChange = (field) => (e) => {
130
+ const value = e.target ? e.target.value : e;
131
+ setFormData((prev) => ({ ...prev, [field]: value }));
132
+ setErrors((prev) => ({ ...prev, [field]: null }));
133
+ setTestResult(null);
134
+ };
135
+
136
+ const handleToggle = (field) => () => {
137
+ setFormData((prev) => ({ ...prev, [field]: !prev[field] }));
138
+ };
139
+
140
+ const validate = () => {
141
+ const newErrors = {};
142
+
143
+ if (!formData.name) {
144
+ newErrors.name = 'Name is required';
145
+ } else if (!/^[a-z0-9-]+$/.test(formData.name)) {
146
+ newErrors.name = 'Name must contain only lowercase letters, numbers, and hyphens';
147
+ }
148
+
149
+ if (!formData.displayName) {
150
+ newErrors.displayName = 'Display name is required';
151
+ }
152
+
153
+ if (!formData.serverUrl) {
154
+ newErrors.serverUrl = 'Server URL is required';
155
+ }
156
+
157
+ if (!formData.realmName) {
158
+ newErrors.realmName = 'Realm name is required';
159
+ }
160
+
161
+ if (!formData.clientId) {
162
+ newErrors.clientId = 'Client ID is required';
163
+ }
164
+
165
+ if (!isEditMode && !formData.clientSecret) {
166
+ newErrors.clientSecret = 'Client secret is required for new realms';
167
+ }
168
+
169
+ setErrors(newErrors);
170
+ return Object.keys(newErrors).length === 0;
171
+ };
172
+
173
+ const handleTestConnection = async () => {
174
+ if (!formData.serverUrl || !formData.realmName || !formData.clientId) {
175
+ setTestResult({ success: false, message: 'Please fill in server URL, realm name, and client ID' });
176
+ return;
177
+ }
178
+
179
+ setIsTesting(true);
180
+ setTestResult(null);
181
+
182
+ try {
183
+ const result = await testConnectionRaw({
184
+ serverUrl: formData.serverUrl,
185
+ realmName: formData.realmName,
186
+ clientId: formData.clientId,
187
+ clientSecret: formData.clientSecret || undefined,
188
+ });
189
+ setTestResult(result);
190
+ } catch {
191
+ setTestResult({ success: false, message: 'Connection test failed' });
192
+ } finally {
193
+ setIsTesting(false);
194
+ }
195
+ };
196
+
197
+ const handleSubmit = async () => {
198
+ if (!validate()) return;
199
+
200
+ setIsSaving(true);
201
+
202
+ try {
203
+ const dataToSave = { ...formData };
204
+
205
+ if (!dataToSave.clientSecret) {
206
+ delete dataToSave.clientSecret;
207
+ }
208
+
209
+ if (isEditMode) {
210
+ await update(id, dataToSave);
211
+ } else {
212
+ await create(dataToSave);
213
+ }
214
+
215
+ navigate(`/settings/${pluginId}`);
216
+ } catch {
217
+ // Error handled by hook
218
+ } finally {
219
+ setIsSaving(false);
220
+ }
221
+ };
222
+
223
+ const handleOpenAddModal = () => {
224
+ setNewAdminUserId('');
225
+ setAdminPermissions({
226
+ canRead: true,
227
+ canCreate: false,
228
+ canUpdate: false,
229
+ canDelete: false,
230
+ canResetPassword: false,
231
+ canManageRoles: false,
232
+ });
233
+ setShowAddModal(true);
234
+ };
235
+
236
+ const handleOpenEditModal = (admin) => {
237
+ setSelectedAdmin(admin);
238
+ setAdminPermissions({
239
+ canRead: admin.canRead || false,
240
+ canCreate: admin.canCreate || false,
241
+ canUpdate: admin.canUpdate || false,
242
+ canDelete: admin.canDelete || false,
243
+ canResetPassword: admin.canResetPassword || false,
244
+ canManageRoles: admin.canManageRoles || false,
245
+ });
246
+ setShowEditModal(true);
247
+ };
248
+
249
+ const handleAddAdmin = async () => {
250
+ if (!newAdminUserId) return;
251
+
252
+ const selectedUser = strapiUsers.find((u) => u.id === parseInt(newAdminUserId, 10));
253
+ try {
254
+ await addAdmin(parseInt(newAdminUserId, 10), selectedUser?.email, adminPermissions);
255
+ setShowAddModal(false);
256
+ } catch {
257
+ // Error handled by hook
258
+ }
259
+ };
260
+
261
+ const handleUpdateAdmin = async () => {
262
+ if (!selectedAdmin) return;
263
+
264
+ try {
265
+ await updateAdmin(selectedAdmin.strapiUserId, adminPermissions);
266
+ setShowEditModal(false);
267
+ setSelectedAdmin(null);
268
+ } catch {
269
+ // Error handled by hook
270
+ }
271
+ };
272
+
273
+ const handleDeleteAdmin = async () => {
274
+ if (!deleteAdminId) return;
275
+
276
+ try {
277
+ await removeAdmin(deleteAdminId);
278
+ setDeleteAdminId(null);
279
+ } catch {
280
+ // Error handled by hook
281
+ }
282
+ };
283
+
284
+ const handlePermissionChange = (key) => () => {
285
+ setAdminPermissions((prev) => ({ ...prev, [key]: !prev[key] }));
286
+ };
287
+
288
+ // Filter out users already assigned as admins
289
+ const availableUsers = strapiUsers.filter(
290
+ (user) => !admins.some((admin) => admin.strapiUserId === user.id)
291
+ );
292
+
293
+ if (isLoading) {
294
+ return (
295
+ <Layouts.Root>
296
+ <Layouts.Content>
297
+ <Flex justifyContent="center" padding={8}>
298
+ <Loader />
299
+ </Flex>
300
+ </Layouts.Content>
301
+ </Layouts.Root>
302
+ );
303
+ }
304
+
305
+ return (
306
+ <Layouts.Root>
307
+ <Layouts.Header
308
+ title={isEditMode ? 'Edit Realm' : 'Create Realm'}
309
+ subtitle={isEditMode && formData.displayName}
310
+ navigationAction={
311
+ <Button
312
+ startIcon={<ArrowLeft />}
313
+ variant="ghost"
314
+ onClick={() => navigate(`/settings/${pluginId}`)}
315
+ >
316
+ {formatMessage({ id: getTrad('common.back'), defaultMessage: 'Back' })}
317
+ </Button>
318
+ }
319
+ primaryAction={
320
+ <Flex gap={2}>
321
+ {activeTab === 'configuration' && (
322
+ <>
323
+ <Button
324
+ variant="secondary"
325
+ startIcon={<Play />}
326
+ onClick={handleTestConnection}
327
+ loading={isTesting}
328
+ >
329
+ {formatMessage({ id: getTrad('realm.testConnection'), defaultMessage: 'Test Connection' })}
330
+ </Button>
331
+ <Button startIcon={<Check />} onClick={handleSubmit} loading={isSaving}>
332
+ {formatMessage({ id: getTrad('common.save'), defaultMessage: 'Save' })}
333
+ </Button>
334
+ </>
335
+ )}
336
+ {activeTab === 'admins' && (
337
+ <Button startIcon={<Plus />} onClick={handleOpenAddModal}>
338
+ Add Admin
339
+ </Button>
340
+ )}
341
+ </Flex>
342
+ }
343
+ />
344
+
345
+ <Layouts.Content>
346
+ {isEditMode ? (
347
+ <Tabs.Root value={activeTab} onValueChange={setActiveTab}>
348
+ <Tabs.List>
349
+ <Tabs.Trigger value="configuration">Configuration</Tabs.Trigger>
350
+ <Tabs.Trigger value="admins">Admins</Tabs.Trigger>
351
+ </Tabs.List>
352
+
353
+ <Box marginTop={4}>
354
+ <Tabs.Content value="configuration">
355
+ <ConfigurationTab
356
+ formData={formData}
357
+ errors={errors}
358
+ testResult={testResult}
359
+ isEditMode={isEditMode}
360
+ handleChange={handleChange}
361
+ handleToggle={handleToggle}
362
+ setTestResult={setTestResult}
363
+ formatMessage={formatMessage}
364
+ />
365
+ </Tabs.Content>
366
+
367
+ <Tabs.Content value="admins">
368
+ <AdminsTab
369
+ admins={admins}
370
+ isLoading={isLoadingAdmins}
371
+ onEdit={handleOpenEditModal}
372
+ onDelete={setDeleteAdminId}
373
+ />
374
+ </Tabs.Content>
375
+ </Box>
376
+ </Tabs.Root>
377
+ ) : (
378
+ <ConfigurationTab
379
+ formData={formData}
380
+ errors={errors}
381
+ testResult={testResult}
382
+ isEditMode={isEditMode}
383
+ handleChange={handleChange}
384
+ handleToggle={handleToggle}
385
+ setTestResult={setTestResult}
386
+ formatMessage={formatMessage}
387
+ />
388
+ )}
389
+ </Layouts.Content>
390
+
391
+ {/* Add Admin Modal */}
392
+ <Modal.Root open={showAddModal} onOpenChange={() => setShowAddModal(false)}>
393
+ <Modal.Content>
394
+ <Modal.Header>
395
+ <Modal.Title>Add Realm Admin</Modal.Title>
396
+ </Modal.Header>
397
+ <Modal.Body>
398
+ <Flex direction="column" gap={4}>
399
+ <Field.Root>
400
+ <Field.Label required>Select User</Field.Label>
401
+ <SingleSelect
402
+ value={newAdminUserId}
403
+ onChange={setNewAdminUserId}
404
+ placeholder="Select a Strapi user..."
405
+ >
406
+ {availableUsers.map((user) => (
407
+ <SingleSelectOption key={user.id} value={String(user.id)}>
408
+ {user.firstname} {user.lastname} ({user.email})
409
+ </SingleSelectOption>
410
+ ))}
411
+ </SingleSelect>
412
+ </Field.Root>
413
+
414
+ <Box>
415
+ <Typography variant="pi" fontWeight="bold">
416
+ Permissions
417
+ </Typography>
418
+ <Box marginTop={2}>
419
+ {PERMISSIONS.map((perm) => (
420
+ <Flex key={perm.key} alignItems="center" gap={2} marginBottom={2}>
421
+ <Checkbox
422
+ checked={adminPermissions[perm.key]}
423
+ onCheckedChange={handlePermissionChange(perm.key)}
424
+ />
425
+ <Box>
426
+ <Typography fontWeight="semiBold">{perm.label}</Typography>
427
+ <Typography variant="pi" textColor="neutral600">
428
+ {perm.description}
429
+ </Typography>
430
+ </Box>
431
+ </Flex>
432
+ ))}
433
+ </Box>
434
+ </Box>
435
+ </Flex>
436
+ </Modal.Body>
437
+ <Modal.Footer>
438
+ <Modal.Close>
439
+ <Button variant="tertiary">Cancel</Button>
440
+ </Modal.Close>
441
+ <Button onClick={handleAddAdmin} disabled={!newAdminUserId}>
442
+ Add Admin
443
+ </Button>
444
+ </Modal.Footer>
445
+ </Modal.Content>
446
+ </Modal.Root>
447
+
448
+ {/* Edit Admin Modal */}
449
+ <Modal.Root open={showEditModal} onOpenChange={() => setShowEditModal(false)}>
450
+ <Modal.Content>
451
+ <Modal.Header>
452
+ <Modal.Title>Edit Admin Permissions</Modal.Title>
453
+ </Modal.Header>
454
+ <Modal.Body>
455
+ <Flex direction="column" gap={4}>
456
+ {selectedAdmin && (
457
+ <Box background="neutral100" padding={3} hasRadius>
458
+ <Typography fontWeight="bold">{selectedAdmin.strapiUserEmail}</Typography>
459
+ </Box>
460
+ )}
461
+
462
+ <Box>
463
+ <Typography variant="pi" fontWeight="bold">
464
+ Permissions
465
+ </Typography>
466
+ <Box marginTop={2}>
467
+ {PERMISSIONS.map((perm) => (
468
+ <Flex key={perm.key} alignItems="center" gap={2} marginBottom={2}>
469
+ <Checkbox
470
+ checked={adminPermissions[perm.key]}
471
+ onCheckedChange={handlePermissionChange(perm.key)}
472
+ />
473
+ <Box>
474
+ <Typography fontWeight="semiBold">{perm.label}</Typography>
475
+ <Typography variant="pi" textColor="neutral600">
476
+ {perm.description}
477
+ </Typography>
478
+ </Box>
479
+ </Flex>
480
+ ))}
481
+ </Box>
482
+ </Box>
483
+ </Flex>
484
+ </Modal.Body>
485
+ <Modal.Footer>
486
+ <Modal.Close>
487
+ <Button variant="tertiary">Cancel</Button>
488
+ </Modal.Close>
489
+ <Button onClick={handleUpdateAdmin}>Save Permissions</Button>
490
+ </Modal.Footer>
491
+ </Modal.Content>
492
+ </Modal.Root>
493
+
494
+ {/* Delete Admin Dialog */}
495
+ <Dialog.Root open={!!deleteAdminId} onOpenChange={() => setDeleteAdminId(null)}>
496
+ <Dialog.Content>
497
+ <Dialog.Header>Remove Admin</Dialog.Header>
498
+ <Dialog.Body>
499
+ Are you sure you want to remove this admin from the realm? They will lose all permissions.
500
+ </Dialog.Body>
501
+ <Dialog.Footer>
502
+ <Dialog.Cancel>
503
+ <Button variant="tertiary">Cancel</Button>
504
+ </Dialog.Cancel>
505
+ <Dialog.Action>
506
+ <Button variant="danger-light" onClick={handleDeleteAdmin}>
507
+ Remove
508
+ </Button>
509
+ </Dialog.Action>
510
+ </Dialog.Footer>
511
+ </Dialog.Content>
512
+ </Dialog.Root>
513
+ </Layouts.Root>
514
+ );
515
+ };
516
+
517
+ // Configuration Tab Component
518
+ const ConfigurationTab = ({
519
+ formData,
520
+ errors,
521
+ testResult,
522
+ isEditMode,
523
+ handleChange,
524
+ handleToggle,
525
+ setTestResult,
526
+ formatMessage,
527
+ }) => (
528
+ <Box background="neutral0" padding={6} shadow="filterShadow" hasRadius>
529
+ {testResult && (
530
+ <Box marginBottom={4}>
531
+ <Alert
532
+ variant={testResult.success ? 'success' : 'danger'}
533
+ title={testResult.success ? 'Connection Successful' : 'Connection Failed'}
534
+ onClose={() => setTestResult(null)}
535
+ closeLabel="Close"
536
+ >
537
+ {testResult.success
538
+ ? `Connected to realm: ${testResult.realmDisplayName || formData.realmName}`
539
+ : testResult.message}
540
+ </Alert>
541
+ </Box>
542
+ )}
543
+
544
+ <Grid.Root gap={4}>
545
+ <Grid.Item col={6} s={12}>
546
+ <Field.Root error={errors.name}>
547
+ <Field.Label required>
548
+ {formatMessage({ id: getTrad('realm.name'), defaultMessage: 'Name' })}
549
+ </Field.Label>
550
+ <TextInput
551
+ name="name"
552
+ value={formData.name}
553
+ onChange={handleChange('name')}
554
+ disabled={isEditMode}
555
+ />
556
+ <Field.Hint>
557
+ {formatMessage({
558
+ id: getTrad('realm.name.hint'),
559
+ defaultMessage: 'Unique identifier (lowercase, numbers, hyphens only)',
560
+ })}
561
+ </Field.Hint>
562
+ <Field.Error />
563
+ </Field.Root>
564
+ </Grid.Item>
565
+
566
+ <Grid.Item col={6} s={12}>
567
+ <Field.Root error={errors.displayName}>
568
+ <Field.Label required>
569
+ {formatMessage({ id: getTrad('realm.displayName'), defaultMessage: 'Display Name' })}
570
+ </Field.Label>
571
+ <TextInput
572
+ name="displayName"
573
+ value={formData.displayName}
574
+ onChange={handleChange('displayName')}
575
+ />
576
+ <Field.Error />
577
+ </Field.Root>
578
+ </Grid.Item>
579
+
580
+ <Grid.Item col={12}>
581
+ <Field.Root error={errors.serverUrl}>
582
+ <Field.Label required>
583
+ {formatMessage({ id: getTrad('realm.serverUrl'), defaultMessage: 'Server URL' })}
584
+ </Field.Label>
585
+ <TextInput
586
+ name="serverUrl"
587
+ value={formData.serverUrl}
588
+ onChange={handleChange('serverUrl')}
589
+ />
590
+ <Field.Hint>
591
+ {formatMessage({
592
+ id: getTrad('realm.serverUrl.hint'),
593
+ defaultMessage: 'e.g., https://keycloak.example.com',
594
+ })}
595
+ </Field.Hint>
596
+ <Field.Error />
597
+ </Field.Root>
598
+ </Grid.Item>
599
+
600
+ <Grid.Item col={6} s={12}>
601
+ <Field.Root error={errors.realmName}>
602
+ <Field.Label required>
603
+ {formatMessage({ id: getTrad('realm.realmName'), defaultMessage: 'Realm Name' })}
604
+ </Field.Label>
605
+ <TextInput
606
+ name="realmName"
607
+ value={formData.realmName}
608
+ onChange={handleChange('realmName')}
609
+ />
610
+ <Field.Hint>
611
+ {formatMessage({
612
+ id: getTrad('realm.realmName.hint'),
613
+ defaultMessage: 'The Keycloak realm name',
614
+ })}
615
+ </Field.Hint>
616
+ <Field.Error />
617
+ </Field.Root>
618
+ </Grid.Item>
619
+
620
+ <Grid.Item col={6} s={12}>
621
+ <Field.Root error={errors.clientId}>
622
+ <Field.Label required>
623
+ {formatMessage({ id: getTrad('realm.clientId'), defaultMessage: 'Client ID' })}
624
+ </Field.Label>
625
+ <TextInput
626
+ name="clientId"
627
+ value={formData.clientId}
628
+ onChange={handleChange('clientId')}
629
+ />
630
+ <Field.Hint>
631
+ {formatMessage({
632
+ id: getTrad('realm.clientId.hint'),
633
+ defaultMessage: 'Service account client ID with admin permissions',
634
+ })}
635
+ </Field.Hint>
636
+ <Field.Error />
637
+ </Field.Root>
638
+ </Grid.Item>
639
+
640
+ <Grid.Item col={12}>
641
+ <Field.Root error={errors.clientSecret}>
642
+ <Field.Label required={!isEditMode}>
643
+ {formatMessage({ id: getTrad('realm.clientSecret'), defaultMessage: 'Client Secret' })}
644
+ </Field.Label>
645
+ <TextInput
646
+ name="clientSecret"
647
+ type="password"
648
+ value={formData.clientSecret}
649
+ onChange={handleChange('clientSecret')}
650
+ placeholder={isEditMode ? 'Leave empty to keep current secret' : ''}
651
+ />
652
+ <Field.Error />
653
+ </Field.Root>
654
+ </Grid.Item>
655
+
656
+ <Grid.Item col={6} s={12}>
657
+ <Field.Root>
658
+ <Field.Label>
659
+ {formatMessage({ id: getTrad('realm.color'), defaultMessage: 'Color' })}
660
+ </Field.Label>
661
+ <TextInput
662
+ name="color"
663
+ type="color"
664
+ value={formData.color}
665
+ onChange={handleChange('color')}
666
+ />
667
+ </Field.Root>
668
+ </Grid.Item>
669
+
670
+ <Grid.Item col={6} s={12}>
671
+ <Flex direction="column" gap={1}>
672
+ <Typography variant="pi" fontWeight="bold">
673
+ {formatMessage({ id: getTrad('realm.enabled'), defaultMessage: 'Enabled' })}
674
+ </Typography>
675
+ <Toggle
676
+ checked={formData.enabled}
677
+ onChange={handleToggle('enabled')}
678
+ onLabel="On"
679
+ offLabel="Off"
680
+ />
681
+ </Flex>
682
+ </Grid.Item>
683
+ </Grid.Root>
684
+ </Box>
685
+ );
686
+
687
+ // Admins Tab Component
688
+ const AdminsTab = ({ admins, isLoading, onEdit, onDelete }) => {
689
+ if (isLoading) {
690
+ return (
691
+ <Flex justifyContent="center" padding={8}>
692
+ <Loader />
693
+ </Flex>
694
+ );
695
+ }
696
+
697
+ if (admins.length === 0) {
698
+ return (
699
+ <Box background="neutral0" padding={6} shadow="filterShadow" hasRadius>
700
+ <EmptyStateLayout
701
+ icon={<User width="6rem" height="6rem" />}
702
+ content="No admins assigned to this realm yet. Add an admin to allow them to manage users."
703
+ />
704
+ </Box>
705
+ );
706
+ }
707
+
708
+ return (
709
+ <Box background="neutral0" padding={6} shadow="filterShadow" hasRadius>
710
+ <Table colCount={8} rowCount={admins.length + 1}>
711
+ <Thead>
712
+ <Tr>
713
+ <Th>
714
+ <Typography variant="sigma">User</Typography>
715
+ </Th>
716
+ <Th>
717
+ <Typography variant="sigma">Read</Typography>
718
+ </Th>
719
+ <Th>
720
+ <Typography variant="sigma">Create</Typography>
721
+ </Th>
722
+ <Th>
723
+ <Typography variant="sigma">Update</Typography>
724
+ </Th>
725
+ <Th>
726
+ <Typography variant="sigma">Delete</Typography>
727
+ </Th>
728
+ <Th>
729
+ <Typography variant="sigma">Password</Typography>
730
+ </Th>
731
+ <Th>
732
+ <Typography variant="sigma">Roles</Typography>
733
+ </Th>
734
+ <Th>
735
+ <Typography variant="sigma">Actions</Typography>
736
+ </Th>
737
+ </Tr>
738
+ </Thead>
739
+ <Tbody>
740
+ {admins.map((admin) => (
741
+ <Tr key={admin.documentId}>
742
+ <Td>
743
+ <Typography fontWeight="bold">{admin.strapiUserEmail}</Typography>
744
+ </Td>
745
+ <Td>
746
+ <PermissionBadge enabled={admin.canRead} />
747
+ </Td>
748
+ <Td>
749
+ <PermissionBadge enabled={admin.canCreate} />
750
+ </Td>
751
+ <Td>
752
+ <PermissionBadge enabled={admin.canUpdate} />
753
+ </Td>
754
+ <Td>
755
+ <PermissionBadge enabled={admin.canDelete} />
756
+ </Td>
757
+ <Td>
758
+ <PermissionBadge enabled={admin.canResetPassword} />
759
+ </Td>
760
+ <Td>
761
+ <PermissionBadge enabled={admin.canManageRoles} />
762
+ </Td>
763
+ <Td>
764
+ <Flex gap={1}>
765
+ <IconButton withTooltip={false} label="Edit" onClick={() => onEdit(admin)}>
766
+ <Pencil />
767
+ </IconButton>
768
+ <IconButton
769
+ withTooltip={false}
770
+ label="Remove"
771
+ onClick={() => onDelete(admin.strapiUserId)}
772
+ >
773
+ <Trash />
774
+ </IconButton>
775
+ </Flex>
776
+ </Td>
777
+ </Tr>
778
+ ))}
779
+ </Tbody>
780
+ </Table>
781
+ </Box>
782
+ );
783
+ };
784
+
785
+ const PermissionBadge = ({ enabled }) => (
786
+ <Badge backgroundColor={enabled ? 'success100' : 'neutral150'}>
787
+ {enabled ? 'Yes' : 'No'}
788
+ </Badge>
789
+ );
790
+
791
+ export default RealmEditPage;