@intranefr/superbackend 1.5.0 → 1.5.2

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 (198) hide show
  1. package/.env.example +15 -0
  2. package/README.md +11 -0
  3. package/analysis-only.skill +0 -0
  4. package/index.js +23 -0
  5. package/package.json +8 -2
  6. package/src/admin/endpointRegistry.js +120 -0
  7. package/src/controllers/admin.controller.js +90 -6
  8. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  9. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  10. package/src/controllers/adminCache.controller.js +342 -0
  11. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  12. package/src/controllers/adminCrons.controller.js +388 -0
  13. package/src/controllers/adminDbBrowser.controller.js +124 -0
  14. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  15. package/src/controllers/adminExperiments.controller.js +200 -0
  16. package/src/controllers/adminHeadless.controller.js +9 -2
  17. package/src/controllers/adminHealthChecks.controller.js +570 -0
  18. package/src/controllers/adminI18n.controller.js +51 -29
  19. package/src/controllers/adminLlm.controller.js +126 -2
  20. package/src/controllers/adminPages.controller.js +720 -0
  21. package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
  22. package/src/controllers/adminProxy.controller.js +113 -0
  23. package/src/controllers/adminRateLimits.controller.js +138 -0
  24. package/src/controllers/adminRbac.controller.js +803 -0
  25. package/src/controllers/adminScripts.controller.js +126 -4
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/blogAdmin.controller.js +279 -0
  28. package/src/controllers/blogAiAdmin.controller.js +224 -0
  29. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  30. package/src/controllers/blogInternal.controller.js +26 -0
  31. package/src/controllers/blogPublic.controller.js +89 -0
  32. package/src/controllers/experiments.controller.js +85 -0
  33. package/src/controllers/fileManager.controller.js +190 -0
  34. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  35. package/src/controllers/healthChecksPublic.controller.js +196 -0
  36. package/src/controllers/internalExperiments.controller.js +17 -0
  37. package/src/controllers/metrics.controller.js +64 -4
  38. package/src/controllers/orgAdmin.controller.js +80 -0
  39. package/src/helpers/mongooseHelper.js +258 -0
  40. package/src/helpers/scriptBase.js +230 -0
  41. package/src/helpers/scriptRunner.js +335 -0
  42. package/src/middleware/rbac.js +62 -0
  43. package/src/middleware.js +810 -48
  44. package/src/models/BlockDefinition.js +27 -0
  45. package/src/models/BlogAutomationLock.js +14 -0
  46. package/src/models/BlogAutomationRun.js +39 -0
  47. package/src/models/BlogPost.js +42 -0
  48. package/src/models/CacheEntry.js +26 -0
  49. package/src/models/ConsoleEntry.js +32 -0
  50. package/src/models/ConsoleLog.js +23 -0
  51. package/src/models/ContextBlockDefinition.js +33 -0
  52. package/src/models/CronExecution.js +47 -0
  53. package/src/models/CronJob.js +70 -0
  54. package/src/models/Experiment.js +75 -0
  55. package/src/models/ExperimentAssignment.js +23 -0
  56. package/src/models/ExperimentEvent.js +26 -0
  57. package/src/models/ExperimentMetricBucket.js +30 -0
  58. package/src/models/ExternalDbConnection.js +49 -0
  59. package/src/models/FileEntry.js +22 -0
  60. package/src/models/GlobalSetting.js +1 -2
  61. package/src/models/HealthAutoHealAttempt.js +57 -0
  62. package/src/models/HealthCheck.js +132 -0
  63. package/src/models/HealthCheckRun.js +51 -0
  64. package/src/models/HealthIncident.js +49 -0
  65. package/src/models/Page.js +95 -0
  66. package/src/models/PageCollection.js +42 -0
  67. package/src/models/ProxyEntry.js +66 -0
  68. package/src/models/RateLimitCounter.js +19 -0
  69. package/src/models/RateLimitMetricBucket.js +20 -0
  70. package/src/models/RbacGrant.js +25 -0
  71. package/src/models/RbacGroup.js +16 -0
  72. package/src/models/RbacGroupMember.js +13 -0
  73. package/src/models/RbacGroupRole.js +13 -0
  74. package/src/models/RbacRole.js +25 -0
  75. package/src/models/RbacUserRole.js +13 -0
  76. package/src/models/ScriptDefinition.js +1 -0
  77. package/src/models/Webhook.js +2 -0
  78. package/src/routes/admin.routes.js +2 -0
  79. package/src/routes/adminBlog.routes.js +21 -0
  80. package/src/routes/adminBlogAi.routes.js +16 -0
  81. package/src/routes/adminBlogAutomation.routes.js +27 -0
  82. package/src/routes/adminCache.routes.js +20 -0
  83. package/src/routes/adminConsoleManager.routes.js +302 -0
  84. package/src/routes/adminCrons.routes.js +25 -0
  85. package/src/routes/adminDbBrowser.routes.js +65 -0
  86. package/src/routes/adminEjsVirtual.routes.js +2 -1
  87. package/src/routes/adminExperiments.routes.js +29 -0
  88. package/src/routes/adminHeadless.routes.js +2 -1
  89. package/src/routes/adminHealthChecks.routes.js +28 -0
  90. package/src/routes/adminI18n.routes.js +4 -3
  91. package/src/routes/adminLlm.routes.js +4 -2
  92. package/src/routes/adminPages.routes.js +55 -0
  93. package/src/routes/adminProxy.routes.js +15 -0
  94. package/src/routes/adminRateLimits.routes.js +17 -0
  95. package/src/routes/adminRbac.routes.js +38 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminUiComponents.routes.js +2 -1
  98. package/src/routes/blogInternal.routes.js +14 -0
  99. package/src/routes/blogPublic.routes.js +9 -0
  100. package/src/routes/experiments.routes.js +30 -0
  101. package/src/routes/fileManager.routes.js +62 -0
  102. package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
  103. package/src/routes/healthChecksPublic.routes.js +9 -0
  104. package/src/routes/internalExperiments.routes.js +15 -0
  105. package/src/routes/log.routes.js +43 -60
  106. package/src/routes/metrics.routes.js +4 -2
  107. package/src/routes/orgAdmin.routes.js +1 -0
  108. package/src/routes/pages.routes.js +123 -0
  109. package/src/routes/proxy.routes.js +46 -0
  110. package/src/routes/rbac.routes.js +47 -0
  111. package/src/routes/webhook.routes.js +2 -1
  112. package/src/routes/workflows.routes.js +4 -0
  113. package/src/services/blockDefinitionsAi.service.js +247 -0
  114. package/src/services/blog.service.js +99 -0
  115. package/src/services/blogAutomation.service.js +978 -0
  116. package/src/services/blogCronsBootstrap.service.js +185 -0
  117. package/src/services/blogPublishing.service.js +58 -0
  118. package/src/services/cacheLayer.service.js +696 -0
  119. package/src/services/consoleManager.service.js +738 -0
  120. package/src/services/consoleOverride.service.js +7 -1
  121. package/src/services/cronScheduler.service.js +350 -0
  122. package/src/services/dbBrowser.service.js +536 -0
  123. package/src/services/ejsVirtual.service.js +102 -32
  124. package/src/services/experiments.service.js +273 -0
  125. package/src/services/experimentsAggregation.service.js +308 -0
  126. package/src/services/experimentsCronsBootstrap.service.js +118 -0
  127. package/src/services/experimentsRetention.service.js +43 -0
  128. package/src/services/experimentsWs.service.js +134 -0
  129. package/src/services/fileManager.service.js +475 -0
  130. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  131. package/src/services/globalSettings.service.js +15 -0
  132. package/src/services/healthChecks.service.js +650 -0
  133. package/src/services/healthChecksBootstrap.service.js +109 -0
  134. package/src/services/healthChecksScheduler.service.js +106 -0
  135. package/src/services/jsonConfigs.service.js +2 -2
  136. package/src/services/llmDefaults.service.js +190 -0
  137. package/src/services/migrationAssets/s3.js +2 -2
  138. package/src/services/pages.service.js +602 -0
  139. package/src/services/pagesContext.service.js +331 -0
  140. package/src/services/pagesContextBlocksAi.service.js +349 -0
  141. package/src/services/proxy.service.js +535 -0
  142. package/src/services/rateLimiter.service.js +623 -0
  143. package/src/services/rbac.service.js +212 -0
  144. package/src/services/scriptsRunner.service.js +215 -15
  145. package/src/services/uiComponentsAi.service.js +6 -19
  146. package/src/services/workflow.service.js +23 -8
  147. package/src/utils/orgRoles.js +14 -0
  148. package/src/utils/rbac/engine.js +60 -0
  149. package/src/utils/rbac/rightsRegistry.js +33 -0
  150. package/views/admin-blog-automation.ejs +877 -0
  151. package/views/admin-blog-edit.ejs +542 -0
  152. package/views/admin-blog.ejs +399 -0
  153. package/views/admin-cache.ejs +681 -0
  154. package/views/admin-console-manager.ejs +680 -0
  155. package/views/admin-crons.ejs +645 -0
  156. package/views/admin-dashboard.ejs +28 -8
  157. package/views/admin-db-browser.ejs +445 -0
  158. package/views/admin-ejs-virtual.ejs +16 -10
  159. package/views/admin-experiments.ejs +91 -0
  160. package/views/admin-file-manager.ejs +942 -0
  161. package/views/admin-health-checks.ejs +725 -0
  162. package/views/admin-i18n.ejs +59 -5
  163. package/views/admin-llm.ejs +99 -1
  164. package/views/admin-organizations.ejs +163 -1
  165. package/views/admin-pages.ejs +2424 -0
  166. package/views/admin-proxy.ejs +491 -0
  167. package/views/admin-rate-limiter.ejs +625 -0
  168. package/views/admin-rbac.ejs +1331 -0
  169. package/views/admin-scripts.ejs +597 -3
  170. package/views/admin-seo-config.ejs +61 -7
  171. package/views/admin-ui-components.ejs +57 -25
  172. package/views/admin-workflows.ejs +7 -7
  173. package/views/file-manager.ejs +866 -0
  174. package/views/pages/blocks/contact.ejs +27 -0
  175. package/views/pages/blocks/cta.ejs +18 -0
  176. package/views/pages/blocks/faq.ejs +20 -0
  177. package/views/pages/blocks/features.ejs +19 -0
  178. package/views/pages/blocks/hero.ejs +13 -0
  179. package/views/pages/blocks/html.ejs +5 -0
  180. package/views/pages/blocks/image.ejs +14 -0
  181. package/views/pages/blocks/testimonials.ejs +26 -0
  182. package/views/pages/blocks/text.ejs +10 -0
  183. package/views/pages/layouts/default.ejs +51 -0
  184. package/views/pages/layouts/minimal.ejs +42 -0
  185. package/views/pages/layouts/sidebar.ejs +54 -0
  186. package/views/pages/partials/footer.ejs +13 -0
  187. package/views/pages/partials/header.ejs +12 -0
  188. package/views/pages/partials/sidebar.ejs +8 -0
  189. package/views/pages/runtime/page.ejs +10 -0
  190. package/views/pages/templates/article.ejs +20 -0
  191. package/views/pages/templates/default.ejs +12 -0
  192. package/views/pages/templates/landing.ejs +14 -0
  193. package/views/pages/templates/listing.ejs +15 -0
  194. package/views/partials/admin-image-upload-modal.ejs +221 -0
  195. package/views/partials/dashboard/nav-items.ejs +12 -0
  196. package/views/partials/dashboard/palette.ejs +5 -3
  197. package/views/partials/llm-provider-model-picker.ejs +183 -0
  198. package/src/routes/llmUi.routes.js +0 -26
@@ -0,0 +1,1331 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Admin RBAC</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
9
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css">
10
+ </head>
11
+ <body class="bg-gray-50">
12
+ <div id="app" class="max-w-7xl mx-auto px-6 py-6" v-cloak>
13
+ <div class="flex items-center justify-between mb-6">
14
+ <div>
15
+ <h1 class="text-2xl font-semibold text-gray-900">RBAC</h1>
16
+ <div class="text-sm text-gray-500">Manage roles, groups, grants and test effective rights</div>
17
+ </div>
18
+ <div class="flex items-center gap-2">
19
+ <button @click="loadAll" class="px-3 py-2 rounded bg-gray-600 text-white text-sm hover:bg-gray-700">
20
+ <i class="ti ti-refresh mr-1"></i> Refresh
21
+ </button>
22
+ </div>
23
+ </div>
24
+
25
+ <div class="bg-white border border-gray-200 rounded-lg mb-6">
26
+ <div class="px-4 py-3 border-b border-gray-200 flex items-center gap-2">
27
+ <button
28
+ @click="activeTab='rights'"
29
+ class="px-3 py-2 rounded text-sm"
30
+ :class="activeTab==='rights' ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
31
+ >Rights</button>
32
+ <button
33
+ @click="activeTab='groups'"
34
+ class="px-3 py-2 rounded text-sm"
35
+ :class="activeTab==='groups' ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
36
+ >Groups</button>
37
+ <button
38
+ @click="activeTab='roles'"
39
+ class="px-3 py-2 rounded text-sm"
40
+ :class="activeTab==='roles' ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
41
+ >Roles</button>
42
+ <button
43
+ @click="activeTab='docs'"
44
+ class="px-3 py-2 rounded text-sm"
45
+ :class="activeTab==='docs' ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
46
+ >Documentation</button>
47
+ </div>
48
+ </div>
49
+
50
+ <div v-if="activeTab==='rights'" class="grid grid-cols-12 gap-6">
51
+ <div class="col-span-5 space-y-6">
52
+ <div class="bg-white border border-gray-200 rounded-lg">
53
+ <div class="p-4 border-b border-gray-200 flex items-center justify-between">
54
+ <div class="text-sm font-semibold text-gray-800">Test rights</div>
55
+ </div>
56
+ <div class="p-4 space-y-4">
57
+ <div>
58
+ <label class="text-xs font-semibold text-gray-600">User</label>
59
+ <input v-model="test.userQuery" @input="debouncedSearchUsers" class="mt-1 w-full border rounded px-3 py-2" placeholder="type email or name" />
60
+ <div v-if="userResults.length" class="border rounded mt-2 max-h-48 overflow-auto">
61
+ <button
62
+ v-for="u in userResults"
63
+ :key="u.id"
64
+ @click="selectUser(u)"
65
+ class="w-full text-left px-3 py-2 hover:bg-gray-50 text-sm"
66
+ >
67
+ <div class="font-mono text-xs">{{ u.email }}</div>
68
+ <div class="text-xs text-gray-500">{{ u.name || '-' }}</div>
69
+ </button>
70
+ </div>
71
+ <div v-if="test.user" class="text-xs text-gray-500 mt-2">Selected: <span class="font-mono">{{ test.user.email }}</span></div>
72
+ </div>
73
+
74
+ <div v-if="test.user && orgs.length > 1">
75
+ <label class="text-xs font-semibold text-gray-600">Organization</label>
76
+ <select v-model="test.orgId" class="mt-1 w-full border rounded px-3 py-2">
77
+ <option value="" disabled>Select an org</option>
78
+ <option v-for="o in orgs" :key="o.id" :value="o.id">{{ o.name }} ({{ o.slug }})</option>
79
+ </select>
80
+ </div>
81
+ <div v-else-if="test.user && orgs.length === 1" class="text-xs text-gray-500">
82
+ Org: <span class="font-mono">{{ orgs[0].name }}</span>
83
+ </div>
84
+
85
+ <div>
86
+ <label class="text-xs font-semibold text-gray-600">Right</label>
87
+ <input
88
+ v-model="test.right"
89
+ @input="filterTestRights"
90
+ @focus="filterTestRights"
91
+ class="mt-1 w-full border rounded px-3 py-2 font-mono"
92
+ placeholder="e.g. backoffice:dashboard:access"
93
+ />
94
+ <div v-if="testRightResults.length" class="border rounded mt-2 max-h-48 overflow-auto">
95
+ <button
96
+ v-for="r in testRightResults"
97
+ :key="r"
98
+ @click="selectTestRight(r)"
99
+ class="w-full text-left px-3 py-2 hover:bg-gray-50 text-xs font-mono"
100
+ >
101
+ {{ r }}
102
+ </button>
103
+ </div>
104
+ </div>
105
+
106
+ <div class="flex items-center gap-2">
107
+ <button @click="runTest" class="px-3 py-2 rounded bg-blue-600 text-white text-sm hover:bg-blue-700">
108
+ <i class="ti ti-check mr-1"></i> Test
109
+ </button>
110
+ <div v-if="testStatus" class="text-sm text-gray-600">{{ testStatus }}</div>
111
+ </div>
112
+
113
+ <div v-if="testResult" class="border rounded p-3 bg-gray-50 text-xs">
114
+ <div class="font-semibold" :class="testResult.allowed ? 'text-green-700' : 'text-red-700'">
115
+ {{ testResult.allowed ? 'ALLOWED' : 'DENIED' }}
116
+ </div>
117
+ <div class="text-gray-600 mt-1">Reason: <span class="font-mono">{{ testResult.reason }}</span></div>
118
+ <div v-if="testResult.decisionLayer" class="text-gray-600 mt-1">Decision layer: <span class="font-mono">{{ testResult.decisionLayer }}</span></div>
119
+
120
+ <div v-if="testResult.context" class="mt-3">
121
+ <div class="font-semibold text-gray-700 mb-1">Context</div>
122
+ <div class="bg-white border rounded p-2 space-y-1">
123
+ <div class="text-gray-600">Groups: <span class="font-mono">{{ (testResult.context.groups || []).map(g => g.id).join(', ') || '-' }}</span></div>
124
+ <div class="text-gray-600">Roles: <span class="font-mono">{{ (testResult.context.roles || []).map(r => r.roleId + (r.source==='group' ? '@' + r.groupId : '')).join(', ') || '-' }}</span></div>
125
+ </div>
126
+ </div>
127
+
128
+ <div v-if="testResult.explain && testResult.explain.length" class="mt-3">
129
+ <div class="font-semibold text-gray-700 mb-1">Matched grants</div>
130
+ <div class="space-y-1">
131
+ <div v-for="e in testResult.explain" :key="e.id" class="bg-white border rounded p-2">
132
+ <div class="font-mono">{{ e.effect }} {{ e.right }}</div>
133
+ <div class="text-gray-500">{{ e.source }} | {{ e.scopeType }} {{ e.scopeId || '' }}</div>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </div>
140
+
141
+ <div class="bg-white border border-gray-200 rounded-lg">
142
+ <div class="p-4 border-b border-gray-200 flex items-center justify-between">
143
+ <div class="text-sm font-semibold text-gray-800">Create grant</div>
144
+ </div>
145
+ <div class="p-4 space-y-4">
146
+ <div class="grid grid-cols-2 gap-4">
147
+ <div>
148
+ <label class="text-xs font-semibold text-gray-600">Subject type</label>
149
+ <select v-model="grant.subjectType" @change="onGrantSubjectTypeChange" class="mt-1 w-full border rounded px-3 py-2">
150
+ <option value="user">user</option>
151
+ <option value="role">role</option>
152
+ <option value="group">group</option>
153
+ <option value="org">org</option>
154
+ </select>
155
+ </div>
156
+ <div>
157
+ <label class="text-xs font-semibold text-gray-600">Effect</label>
158
+ <select v-model="grant.effect" class="mt-1 w-full border rounded px-3 py-2">
159
+ <option value="allow">allow</option>
160
+ <option value="deny">deny</option>
161
+ </select>
162
+ </div>
163
+ </div>
164
+
165
+ <div>
166
+ <label class="text-xs font-semibold text-gray-600">Subject ID</label>
167
+
168
+ <div v-if="grant.subjectType === 'user'">
169
+ <input
170
+ v-model="grant.userQuery"
171
+ @input="debouncedSearchGrantUsers"
172
+ class="mt-1 w-full border rounded px-3 py-2"
173
+ placeholder="type email or name"
174
+ />
175
+ <div v-if="grantUserResults.length" class="border rounded mt-2 max-h-48 overflow-auto">
176
+ <button
177
+ v-for="u in grantUserResults"
178
+ :key="u.id"
179
+ @click="selectGrantUser(u)"
180
+ class="w-full text-left px-3 py-2 hover:bg-gray-50 text-sm"
181
+ >
182
+ <div class="font-mono text-xs">{{ u.email }}</div>
183
+ <div class="text-xs text-gray-500">{{ u.name || '-' }}</div>
184
+ </button>
185
+ </div>
186
+ <div v-if="grant.subjectId" class="text-xs text-gray-500 mt-2">Selected: <span class="font-mono">{{ grant.subjectId }}</span></div>
187
+ </div>
188
+
189
+ <div v-else-if="grant.subjectType === 'role'">
190
+ <select v-model="grant.subjectId" class="mt-1 w-full border rounded px-3 py-2">
191
+ <option value="">Select role</option>
192
+ <option v-for="r in roles" :key="r.id" :value="r.id">{{ r.key }} ({{ r.isGlobal ? 'global' : 'org' }})</option>
193
+ </select>
194
+ </div>
195
+
196
+ <div v-else-if="grant.subjectType === 'group'">
197
+ <select v-model="grant.subjectId" class="mt-1 w-full border rounded px-3 py-2">
198
+ <option value="">Select group</option>
199
+ <option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }} ({{ g.isGlobal ? 'global' : 'org' }})</option>
200
+ </select>
201
+ </div>
202
+
203
+ <div v-else-if="grant.subjectType === 'org'">
204
+ <select v-model="grant.subjectId" class="mt-1 w-full border rounded px-3 py-2">
205
+ <option value="">Select org</option>
206
+ <option v-for="o in organizations" :key="o._id" :value="o._id">{{ o.name }} ({{ o.slug }})</option>
207
+ </select>
208
+ </div>
209
+ </div>
210
+
211
+ <div class="grid grid-cols-2 gap-4">
212
+ <div>
213
+ <label class="text-xs font-semibold text-gray-600">Scope</label>
214
+ <select v-model="grant.scopeType" @change="onGrantScopeTypeChange" class="mt-1 w-full border rounded px-3 py-2">
215
+ <option value="global">global</option>
216
+ <option value="org">org</option>
217
+ </select>
218
+ </div>
219
+ <div>
220
+ <label class="text-xs font-semibold text-gray-600">Scope orgId</label>
221
+ <select v-model="grant.scopeId" :disabled="grant.scopeType !== 'org'" class="mt-1 w-full border rounded px-3 py-2">
222
+ <option value="">Select org</option>
223
+ <option v-for="o in organizations" :key="o._id" :value="o._id">{{ o.name }} ({{ o.slug }})</option>
224
+ </select>
225
+ </div>
226
+ </div>
227
+
228
+ <div>
229
+ <label class="text-xs font-semibold text-gray-600">Right</label>
230
+ <input
231
+ v-model="grant.right"
232
+ @input="filterGrantRights"
233
+ @focus="filterGrantRights"
234
+ class="mt-1 w-full border rounded px-3 py-2 font-mono"
235
+ />
236
+ <div v-if="grantRightResults.length" class="border rounded mt-2 max-h-48 overflow-auto">
237
+ <button
238
+ v-for="r in grantRightResults"
239
+ :key="r"
240
+ @click="selectGrantRight(r)"
241
+ class="w-full text-left px-3 py-2 hover:bg-gray-50 text-xs font-mono"
242
+ >
243
+ {{ r }}
244
+ </button>
245
+ </div>
246
+ </div>
247
+
248
+ <div class="flex items-center gap-2">
249
+ <button @click="createGrant" class="px-3 py-2 rounded bg-green-600 text-white text-sm hover:bg-green-700">
250
+ <i class="ti ti-plus mr-1"></i> Create
251
+ </button>
252
+ <div v-if="grantStatus" class="text-sm text-gray-600">{{ grantStatus }}</div>
253
+ </div>
254
+ </div>
255
+ </div>
256
+
257
+ <div class="bg-white border border-gray-200 rounded-lg">
258
+ <div class="p-4 border-b border-gray-200">
259
+ <div class="text-sm font-semibold text-gray-800">Group members</div>
260
+ </div>
261
+ <div class="p-4 space-y-4">
262
+ <div>
263
+ <label class="text-xs font-semibold text-gray-600">Group</label>
264
+ <select v-model="selectedGroupId" @change="onSelectedGroupChange" class="mt-1 w-full border rounded px-3 py-2">
265
+ <option value="">Select group</option>
266
+ <option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }} ({{ g.isGlobal ? 'global' : 'org' }})</option>
267
+ </select>
268
+ <div v-if="selectedGroup && !selectedGroup.isGlobal" class="text-xs text-gray-500 mt-2">
269
+ Org-scoped group: user search is limited to members of <span class="font-mono">{{ selectedGroup.orgId }}</span>
270
+ </div>
271
+ </div>
272
+
273
+ <div>
274
+ <label class="text-xs font-semibold text-gray-600">Add users (search)</label>
275
+ <input
276
+ v-model="memberAdd.userQuery"
277
+ @input="debouncedSearchMemberUsers"
278
+ class="mt-1 w-full border rounded px-3 py-2"
279
+ :disabled="!selectedGroupId"
280
+ placeholder="type email or name"
281
+ />
282
+ <div v-if="memberAdd.userResults.length" class="border rounded mt-2 max-h-48 overflow-auto">
283
+ <button
284
+ v-for="u in memberAdd.userResults"
285
+ :key="u.id"
286
+ @click="addMemberCandidate(u)"
287
+ class="w-full text-left px-3 py-2 hover:bg-gray-50 text-sm"
288
+ >
289
+ <div class="font-mono text-xs">{{ u.email }}</div>
290
+ <div class="text-xs text-gray-500">{{ u.name || '-' }}</div>
291
+ </button>
292
+ </div>
293
+ </div>
294
+
295
+ <div v-if="memberAdd.selectedUsers.length" class="border rounded p-3 bg-gray-50">
296
+ <div class="text-xs font-semibold text-gray-700 mb-2">Selected users to add ({{ memberAdd.selectedUsers.length }})</div>
297
+ <div class="space-y-1 max-h-40 overflow-auto">
298
+ <div v-for="u in memberAdd.selectedUsers" :key="u.id" class="flex items-center justify-between bg-white border rounded px-2 py-1">
299
+ <div>
300
+ <div class="font-mono text-xs">{{ u.email }}</div>
301
+ <div class="text-xs text-gray-500">{{ u.id }}</div>
302
+ </div>
303
+ <button @click="removeMemberCandidate(u.id)" class="text-xs text-red-600 hover:underline">Remove</button>
304
+ </div>
305
+ </div>
306
+ <div class="flex items-center gap-2 mt-3">
307
+ <button
308
+ @click="bulkAddMembers"
309
+ :disabled="!selectedGroupId || memberAdd.selectedUsers.length === 0"
310
+ class="px-3 py-2 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-50"
311
+ >
312
+ Add to group
313
+ </button>
314
+ <div v-if="memberAdd.status" class="text-sm text-gray-600">{{ memberAdd.status }}</div>
315
+ </div>
316
+ </div>
317
+
318
+ <div class="border-t pt-4">
319
+ <div class="flex items-center justify-between mb-2">
320
+ <div class="text-xs font-semibold text-gray-700">Current members</div>
321
+ <button @click="loadSelectedGroupMembers" :disabled="!selectedGroupId" class="text-xs text-gray-700 hover:underline">Reload</button>
322
+ </div>
323
+
324
+ <div v-if="selectedGroupMembers.length" class="space-y-2">
325
+ <div class="flex items-center justify-between">
326
+ <label class="text-xs text-gray-600">
327
+ <input type="checkbox" v-model="memberRemove.selectAll" @change="toggleSelectAllMembers" class="mr-2" />
328
+ Select all
329
+ </label>
330
+ <button
331
+ @click="bulkRemoveMembers"
332
+ :disabled="memberRemove.selectedMemberIds.length === 0"
333
+ class="text-xs text-red-600 hover:underline disabled:opacity-50"
334
+ >
335
+ Remove selected ({{ memberRemove.selectedMemberIds.length }})
336
+ </button>
337
+ </div>
338
+
339
+ <div v-for="m in selectedGroupMembers" :key="m.id" class="border rounded p-3">
340
+ <div class="flex items-center justify-between">
341
+ <label class="flex items-center gap-2">
342
+ <input type="checkbox" :value="m.id" v-model="memberRemove.selectedMemberIds" @change="syncSelectAllMembers" />
343
+ <div>
344
+ <div class="font-mono text-xs">{{ m.email || '-' }}</div>
345
+ <div class="text-xs text-gray-500">{{ m.userId }}</div>
346
+ </div>
347
+ </label>
348
+ <button @click="removeSingleMember(m.id)" class="text-xs text-red-600 hover:underline">Remove</button>
349
+ </div>
350
+ </div>
351
+
352
+ <div v-if="memberRemove.status" class="text-sm text-gray-600">{{ memberRemove.status }}</div>
353
+ </div>
354
+ <div v-else class="text-sm text-gray-500">No members.</div>
355
+ </div>
356
+ </div>
357
+ </div>
358
+
359
+ <div class="bg-white border border-gray-200 rounded-lg">
360
+ <div class="p-4 border-b border-gray-200 flex items-center justify-between">
361
+ <div class="text-sm font-semibold text-gray-800">Grants</div>
362
+ <button @click="loadGrants" class="px-3 py-2 rounded bg-gray-600 text-white text-sm hover:bg-gray-700">Reload</button>
363
+ </div>
364
+ <div class="p-4">
365
+ <div v-if="grants.length" class="space-y-2">
366
+ <div v-for="g in grants" :key="g.id" class="border rounded p-3">
367
+ <div class="flex items-center justify-between">
368
+ <div class="font-mono text-xs">{{ g.effect }} {{ g.right }}</div>
369
+ <button @click="deleteGrant(g.id)" class="text-xs text-red-600 hover:underline">Delete</button>
370
+ </div>
371
+ <div class="text-xs text-gray-500 mt-1">
372
+ subject: {{ g.subjectType }} {{ g.subjectId }} | scope: {{ g.scopeType }} {{ g.scopeId || '' }}
373
+ </div>
374
+ </div>
375
+ </div>
376
+ <div v-else class="text-sm text-gray-500">No grants yet.</div>
377
+ </div>
378
+ </div>
379
+ </div>
380
+ </div>
381
+
382
+ <div v-else-if="activeTab==='groups'" class="grid grid-cols-12 gap-6">
383
+ <div class="col-span-5 space-y-6">
384
+ <div class="bg-white border border-gray-200 rounded-lg">
385
+ <div class="p-4 border-b border-gray-200">
386
+ <div class="text-sm font-semibold text-gray-800">Create group</div>
387
+ </div>
388
+ <div class="p-4 space-y-4">
389
+ <div>
390
+ <label class="text-xs font-semibold text-gray-600">Name</label>
391
+ <input v-model="groupForm.name" class="mt-1 w-full border rounded px-3 py-2" />
392
+ </div>
393
+ <div>
394
+ <label class="text-xs font-semibold text-gray-600">Description</label>
395
+ <input v-model="groupForm.description" class="mt-1 w-full border rounded px-3 py-2" />
396
+ </div>
397
+ <div class="grid grid-cols-2 gap-4">
398
+ <div>
399
+ <label class="text-xs font-semibold text-gray-600">Scope</label>
400
+ <select v-model="groupForm.isGlobal" class="mt-1 w-full border rounded px-3 py-2">
401
+ <option :value="true">global</option>
402
+ <option :value="false">org</option>
403
+ </select>
404
+ </div>
405
+ <div>
406
+ <label class="text-xs font-semibold text-gray-600">Org</label>
407
+ <select v-model="groupForm.orgId" :disabled="groupForm.isGlobal" class="mt-1 w-full border rounded px-3 py-2">
408
+ <option value="">Select org</option>
409
+ <option v-for="o in organizations" :key="o._id" :value="o._id">{{ o.name }} ({{ o.slug }})</option>
410
+ </select>
411
+ </div>
412
+ </div>
413
+ <div class="flex items-center gap-2">
414
+ <button @click="createGroup" class="px-3 py-2 rounded bg-green-600 text-white text-sm hover:bg-green-700">
415
+ <i class="ti ti-plus mr-1"></i> Create
416
+ </button>
417
+ <div v-if="groupStatus" class="text-sm text-gray-600">{{ groupStatus }}</div>
418
+ </div>
419
+ </div>
420
+ </div>
421
+
422
+ <div class="bg-white border border-gray-200 rounded-lg">
423
+ <div class="p-4 border-b border-gray-200">
424
+ <div class="text-sm font-semibold text-gray-800">Group roles</div>
425
+ </div>
426
+ <div class="p-4 space-y-4">
427
+ <div>
428
+ <label class="text-xs font-semibold text-gray-600">Group</label>
429
+ <select v-model="selectedGroupId" @change="onSelectedGroupChange" class="mt-1 w-full border rounded px-3 py-2">
430
+ <option value="">Select group</option>
431
+ <option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }} ({{ g.isGlobal ? 'global' : 'org' }})</option>
432
+ </select>
433
+ </div>
434
+ <div class="grid grid-cols-2 gap-4">
435
+ <div>
436
+ <label class="text-xs font-semibold text-gray-600">Role</label>
437
+ <select v-model="groupRoleForm.roleId" class="mt-1 w-full border rounded px-3 py-2">
438
+ <option value="">Select role</option>
439
+ <option v-for="r in roles" :key="r.id" :value="r.id">{{ r.key }} ({{ r.isGlobal ? 'global' : 'org' }})</option>
440
+ </select>
441
+ </div>
442
+ <div class="flex items-end">
443
+ <button @click="addRoleToGroup" :disabled="!selectedGroupId || !groupRoleForm.roleId" class="w-full px-3 py-2 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-50">
444
+ Assign
445
+ </button>
446
+ </div>
447
+ </div>
448
+ <div v-if="selectedGroupRoles.length" class="space-y-2">
449
+ <div v-for="gr in selectedGroupRoles" :key="gr.id" class="border rounded p-3">
450
+ <div class="flex items-center justify-between">
451
+ <div class="font-mono text-xs">{{ gr.key }} ({{ gr.isGlobal ? 'global' : 'org' }})</div>
452
+ <button @click="removeRoleFromGroup(gr.id)" class="text-xs text-red-600 hover:underline">Remove</button>
453
+ </div>
454
+ <div class="text-xs text-gray-500 mt-1">roleId: {{ gr.roleId }}</div>
455
+ </div>
456
+ </div>
457
+ <div v-else class="text-sm text-gray-500">No roles assigned.</div>
458
+ </div>
459
+ </div>
460
+
461
+ <div class="bg-white border border-gray-200 rounded-lg">
462
+ <div class="p-4 border-b border-gray-200">
463
+ <div class="text-sm font-semibold text-gray-800">Group members</div>
464
+ </div>
465
+ <div class="p-4 space-y-4">
466
+ <div>
467
+ <div v-if="selectedGroup && !selectedGroup.isGlobal" class="text-xs text-gray-500">
468
+ Org-scoped group: user search is limited to members of <span class="font-mono">{{ selectedGroup.orgId }}</span>
469
+ </div>
470
+ </div>
471
+
472
+ <div>
473
+ <label class="text-xs font-semibold text-gray-600">Add users (search)</label>
474
+ <input
475
+ v-model="memberAdd.userQuery"
476
+ @input="debouncedSearchMemberUsers"
477
+ class="mt-1 w-full border rounded px-3 py-2"
478
+ :disabled="!selectedGroupId"
479
+ placeholder="type email or name"
480
+ />
481
+ <div v-if="memberAdd.userResults.length" class="border rounded mt-2 max-h-48 overflow-auto">
482
+ <button
483
+ v-for="u in memberAdd.userResults"
484
+ :key="u.id"
485
+ @click="addMemberCandidate(u)"
486
+ class="w-full text-left px-3 py-2 hover:bg-gray-50 text-sm"
487
+ >
488
+ <div class="font-mono text-xs">{{ u.email }}</div>
489
+ <div class="text-xs text-gray-500">{{ u.name || '-' }}</div>
490
+ </button>
491
+ </div>
492
+ </div>
493
+
494
+ <div v-if="memberAdd.selectedUsers.length" class="border rounded p-3 bg-gray-50">
495
+ <div class="text-xs font-semibold text-gray-700 mb-2">Selected users to add ({{ memberAdd.selectedUsers.length }})</div>
496
+ <div class="space-y-1 max-h-40 overflow-auto">
497
+ <div v-for="u in memberAdd.selectedUsers" :key="u.id" class="flex items-center justify-between bg-white border rounded px-2 py-1">
498
+ <div>
499
+ <div class="font-mono text-xs">{{ u.email }}</div>
500
+ <div class="text-xs text-gray-500">{{ u.id }}</div>
501
+ </div>
502
+ <button @click="removeMemberCandidate(u.id)" class="text-xs text-red-600 hover:underline">Remove</button>
503
+ </div>
504
+ </div>
505
+ <div class="flex items-center gap-2 mt-3">
506
+ <button
507
+ @click="bulkAddMembers"
508
+ :disabled="!selectedGroupId || memberAdd.selectedUsers.length === 0"
509
+ class="px-3 py-2 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-50"
510
+ >
511
+ Add to group
512
+ </button>
513
+ <div v-if="memberAdd.status" class="text-sm text-gray-600">{{ memberAdd.status }}</div>
514
+ </div>
515
+ </div>
516
+
517
+ <div class="border-t pt-4">
518
+ <div class="flex items-center justify-between mb-2">
519
+ <div class="text-xs font-semibold text-gray-700">Current members</div>
520
+ <button @click="loadSelectedGroupMembers" :disabled="!selectedGroupId" class="text-xs text-gray-700 hover:underline">Reload</button>
521
+ </div>
522
+
523
+ <div v-if="selectedGroupMembers.length" class="space-y-2">
524
+ <div class="flex items-center justify-between">
525
+ <label class="text-xs text-gray-600">
526
+ <input type="checkbox" v-model="memberRemove.selectAll" @change="toggleSelectAllMembers" class="mr-2" />
527
+ Select all
528
+ </label>
529
+ <button
530
+ @click="bulkRemoveMembers"
531
+ :disabled="memberRemove.selectedMemberIds.length === 0"
532
+ class="text-xs text-red-600 hover:underline disabled:opacity-50"
533
+ >
534
+ Remove selected ({{ memberRemove.selectedMemberIds.length }})
535
+ </button>
536
+ </div>
537
+
538
+ <div v-for="m in selectedGroupMembers" :key="m.id" class="border rounded p-3">
539
+ <div class="flex items-center justify-between">
540
+ <label class="flex items-center gap-2">
541
+ <input type="checkbox" :value="m.id" v-model="memberRemove.selectedMemberIds" @change="syncSelectAllMembers" />
542
+ <div>
543
+ <div class="font-mono text-xs">{{ m.email || '-' }}</div>
544
+ <div class="text-xs text-gray-500">{{ m.userId }}</div>
545
+ </div>
546
+ </label>
547
+ <button @click="removeSingleMember(m.id)" class="text-xs text-red-600 hover:underline">Remove</button>
548
+ </div>
549
+ </div>
550
+
551
+ <div v-if="memberRemove.status" class="text-sm text-gray-600">{{ memberRemove.status }}</div>
552
+ </div>
553
+ <div v-else class="text-sm text-gray-500">No members.</div>
554
+ </div>
555
+ </div>
556
+ </div>
557
+ </div>
558
+
559
+ <div class="col-span-7">
560
+ <div class="bg-white border border-gray-200 rounded-lg">
561
+ <div class="p-4 border-b border-gray-200 flex items-center justify-between">
562
+ <div class="text-sm font-semibold text-gray-800">Groups</div>
563
+ <button @click="loadGroups" class="px-3 py-2 rounded bg-gray-600 text-white text-sm hover:bg-gray-700">Reload</button>
564
+ </div>
565
+ <div class="p-4">
566
+ <div v-if="groups.length" class="space-y-2">
567
+ <div v-for="g in groups" :key="g.id" class="border rounded p-3">
568
+ <div class="flex items-center justify-between">
569
+ <div class="text-sm font-semibold text-gray-800">{{ g.name }}</div>
570
+ <div class="text-xs text-gray-500">{{ g.isGlobal ? 'global' : ('org ' + (g.orgId || '')) }}</div>
571
+ </div>
572
+ <div class="text-xs text-gray-500 mt-1">{{ g.description || '-' }}</div>
573
+ <div class="text-xs text-gray-400 mt-1">id: <span class="font-mono">{{ g.id }}</span></div>
574
+ </div>
575
+ </div>
576
+ <div v-else class="text-sm text-gray-500">No groups yet.</div>
577
+ </div>
578
+ </div>
579
+ </div>
580
+ </div>
581
+
582
+ <div v-else-if="activeTab==='roles'" class="grid grid-cols-12 gap-6">
583
+ <div class="col-span-5 space-y-6">
584
+ <div class="bg-white border border-gray-200 rounded-lg">
585
+ <div class="p-4 border-b border-gray-200">
586
+ <div class="text-sm font-semibold text-gray-800">Create role</div>
587
+ </div>
588
+ <div class="p-4 space-y-4">
589
+ <div>
590
+ <label class="text-xs font-semibold text-gray-600">Code</label>
591
+ <input v-model="roleForm.key" class="mt-1 w-full border rounded px-3 py-2 font-mono" placeholder="unique code" />
592
+ </div>
593
+ <div>
594
+ <label class="text-xs font-semibold text-gray-600">Name</label>
595
+ <input v-model="roleForm.name" class="mt-1 w-full border rounded px-3 py-2" />
596
+ </div>
597
+ <div>
598
+ <label class="text-xs font-semibold text-gray-600">Description</label>
599
+ <input v-model="roleForm.description" class="mt-1 w-full border rounded px-3 py-2" />
600
+ </div>
601
+ <div class="grid grid-cols-2 gap-4">
602
+ <div>
603
+ <label class="text-xs font-semibold text-gray-600">Scope</label>
604
+ <select v-model="roleForm.isGlobal" class="mt-1 w-full border rounded px-3 py-2">
605
+ <option :value="true">global</option>
606
+ <option :value="false">org</option>
607
+ </select>
608
+ </div>
609
+ <div>
610
+ <label class="text-xs font-semibold text-gray-600">Org</label>
611
+ <select v-model="roleForm.orgId" :disabled="roleForm.isGlobal" class="mt-1 w-full border rounded px-3 py-2">
612
+ <option value="">Select org</option>
613
+ <option v-for="o in organizations" :key="o._id" :value="o._id">{{ o.name }} ({{ o.slug }})</option>
614
+ </select>
615
+ </div>
616
+ </div>
617
+ <div class="flex items-center gap-2">
618
+ <button @click="createRole" class="px-3 py-2 rounded bg-green-600 text-white text-sm hover:bg-green-700">
619
+ <i class="ti ti-plus mr-1"></i> Create
620
+ </button>
621
+ <div v-if="roleStatus" class="text-sm text-gray-600">{{ roleStatus }}</div>
622
+ </div>
623
+ </div>
624
+ </div>
625
+ </div>
626
+
627
+ <div class="col-span-7">
628
+ <div class="bg-white border border-gray-200 rounded-lg">
629
+ <div class="p-4 border-b border-gray-200 flex items-center justify-between">
630
+ <div class="text-sm font-semibold text-gray-800">Roles</div>
631
+ <button @click="loadRoles" class="px-3 py-2 rounded bg-gray-600 text-white text-sm hover:bg-gray-700">Reload</button>
632
+ </div>
633
+ <div class="p-4">
634
+ <div v-if="roles.length" class="space-y-2">
635
+ <div v-for="r in roles" :key="r.id" class="border rounded p-3">
636
+ <div class="flex items-center justify-between">
637
+ <div class="font-mono text-xs">{{ r.key }}</div>
638
+ <div class="text-xs text-gray-500">{{ r.isGlobal ? 'global' : ('org ' + (r.orgId || '')) }}</div>
639
+ </div>
640
+ <div class="text-sm text-gray-800 mt-1">{{ r.name }}</div>
641
+ <div class="text-xs text-gray-500 mt-1">{{ r.description || '-' }}</div>
642
+ <div class="text-xs text-gray-400 mt-1">id: <span class="font-mono">{{ r.id }}</span></div>
643
+ </div>
644
+ </div>
645
+ <div v-else class="text-sm text-gray-500">No roles yet.</div>
646
+ </div>
647
+ </div>
648
+ </div>
649
+ </div>
650
+
651
+ <div v-if="activeTab==='docs'" class="space-y-6">
652
+ <!-- Section 1: Programmatic Rights Checking -->
653
+ <section class="bg-white border border-gray-200 rounded-lg">
654
+ <div class="p-4 border-b border-gray-200">
655
+ <h3 class="text-lg font-semibold text-gray-800">Programmatic Rights Checking (Middleware Mode)</h3>
656
+ <p class="text-sm text-gray-600 mt-1">Use the RBAC service directly in your code when running SuperBackend in middleware mode.</p>
657
+ </div>
658
+ <div class="p-4 space-y-4">
659
+ <div>
660
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">Accessing the Service</h4>
661
+ <div class="bg-gray-50 rounded p-3 overflow-x-auto">
662
+ <pre class="text-xs"><code>const rbac = globalThis.superbackend.services.rbac;</code></pre>
663
+ </div>
664
+ </div>
665
+ <div>
666
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">Basic Rights Check</h4>
667
+ <div class="bg-gray-50 rounded p-3 overflow-x-auto">
668
+ <pre class="text-xs"><code>const result = await rbac.checkRight(userId, orgId, 'backoffice:dashboard:access');
669
+
670
+ if (result.allowed) {
671
+ // User has permission - proceed
672
+ console.log('Access granted');
673
+ } else {
674
+ // Permission denied
675
+ console.log('Access denied by:', result.decisionLayer);
676
+ // result.decisionLayer can be: 'user', 'role', 'group', 'org', or 'global'
677
+ }</code></pre>
678
+ </div>
679
+ </div>
680
+ <div>
681
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">Express Middleware Example</h4>
682
+ <div class="bg-gray-50 rounded p-3 overflow-x-auto">
683
+ <pre class="text-xs"><code>const requireRight = (right) => async (req, res, next) => {
684
+ const rbac = globalThis.superbackend.services.rbac;
685
+ const result = await rbac.checkRight(req.user.id, req.org.id, right);
686
+
687
+ if (result.allowed) return next();
688
+ res.status(403).json({ error: 'Insufficient permissions' });
689
+ };
690
+
691
+ // Usage
692
+ app.get('/admin/dashboard',
693
+ authenticate, // your auth middleware
694
+ requireRight('backoffice:dashboard:access'),
695
+ dashboardHandler
696
+ );</code></pre>
697
+ </div>
698
+ </div>
699
+ </div>
700
+ </section>
701
+
702
+ <!-- Section 2: HTTP API Rights Checking -->
703
+ <section class="bg-white border border-gray-200 rounded-lg">
704
+ <div class="p-4 border-b border-gray-200">
705
+ <h3 class="text-lg font-semibold text-gray-800">HTTP API Rights Checking</h3>
706
+ <p class="text-sm text-gray-600 mt-1">Check rights via REST API from external services or frontend applications.</p>
707
+ </div>
708
+ <div class="p-4 space-y-4">
709
+ <div>
710
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">Endpoint</h4>
711
+ <div class="bg-gray-50 rounded p-3 overflow-x-auto">
712
+ <pre class="text-xs"><code>POST /api/rbac/check
713
+ Content-Type: application/json
714
+ Authorization: Bearer &lt;JWT&gt; or Basic Auth</code></pre>
715
+ </div>
716
+ </div>
717
+ <div>
718
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">Request Body</h4>
719
+ <div class="bg-gray-50 rounded p-3 overflow-x-auto">
720
+ <pre class="text-xs"><code>{
721
+ "userId": "507f1f77bcf86cd799439011",
722
+ "orgId": "507f1f77bcf86cd799439012",
723
+ "right": "backoffice:dashboard:access"
724
+ }</code></pre>
725
+ </div>
726
+ </div>
727
+ <div>
728
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">Response</h4>
729
+ <div class="bg-gray-50 rounded p-3 overflow-x-auto">
730
+ <pre class="text-xs"><code>{
731
+ "allowed": true,
732
+ "decisionLayer": "role",
733
+ "context": {
734
+ "userRoles": ["admin", "editor"],
735
+ "groupRoles": ["viewer"],
736
+ "userGrants": [],
737
+ "roleGrants": [
738
+ { "right": "backoffice:dashboard:access", "effect": "allow" }
739
+ ],
740
+ "groupGrants": [],
741
+ "orgGrants": [],
742
+ "globalGrants": []
743
+ }
744
+ }</code></pre>
745
+ </div>
746
+ </div>
747
+ <div>
748
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">curl Example</h4>
749
+ <div class="bg-gray-50 rounded p-3 overflow-x-auto">
750
+ <pre class="text-xs"><code>curl -X POST http://localhost:3000/api/rbac/check \
751
+ -H "Authorization: Bearer YOUR_JWT" \
752
+ -H "Content-Type: application/json" \
753
+ -d '{
754
+ "userId": "507f1f77bcf86cd799439011",
755
+ "orgId": "507f1f77bcf86cd799439012",
756
+ "right": "backoffice:dashboard:access"
757
+ }'</code></pre>
758
+ </div>
759
+ </div>
760
+ <div>
761
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">JavaScript Fetch Example</h4>
762
+ <div class="bg-gray-50 rounded p-3 overflow-x-auto">
763
+ <pre class="text-xs"><code>const response = await fetch('/api/rbac/check', {
764
+ method: 'POST',
765
+ headers: {
766
+ 'Content-Type': 'application/json',
767
+ 'Authorization': `Bearer ${token}`
768
+ },
769
+ body: JSON.stringify({
770
+ userId: '507f1f77bcf86cd799439011',
771
+ orgId: '507f1f77bcf86cd799439012',
772
+ right: 'backoffice:dashboard:access'
773
+ })
774
+ });
775
+
776
+ const result = await response.json();
777
+ if (result.allowed) {
778
+ // Proceed with action
779
+ }</code></pre>
780
+ </div>
781
+ </div>
782
+ </div>
783
+ </section>
784
+
785
+ <!-- Section 3: Common Patterns -->
786
+ <section class="bg-white border border-gray-200 rounded-lg">
787
+ <div class="p-4 border-b border-gray-200">
788
+ <h3 class="text-lg font-semibold text-gray-800">Common Integration Patterns</h3>
789
+ </div>
790
+ <div class="p-4 space-y-4">
791
+ <div>
792
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">Wildcard Rights</h4>
793
+ <div class="bg-gray-50 rounded p-3 overflow-x-auto">
794
+ <pre class="text-xs"><code>// Check for wildcard rights
795
+ const result = await rbac.checkRight(userId, orgId, 'backoffice:*');
796
+ // Matches: backoffice:dashboard:access, backoffice:users:read, etc.</code></pre>
797
+ </div>
798
+ </div>
799
+ <div>
800
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">Multiple Rights Check</h4>
801
+ <div class="bg-gray-50 rounded p-3 overflow-x-auto">
802
+ <pre class="text-xs"><code>// Check multiple rights (AND logic)
803
+ const rights = ['backoffice:dashboard:access', 'backoffice:users:read'];
804
+ const results = await Promise.all(
805
+ rights.map(right => rbac.checkRight(userId, orgId, right))
806
+ );
807
+
808
+ const hasAll = results.every(r => r.allowed);
809
+ const hasAny = results.some(r => r.allowed);</code></pre>
810
+ </div>
811
+ </div>
812
+ <div>
813
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">Global vs Org-Scoped Rights</h4>
814
+ <div class="bg-gray-50 rounded p-3 overflow-x-auto">
815
+ <pre class="text-xs"><code>// Global rights (orgId can be null/undefined)
816
+ await rbac.checkRight(userId, null, 'system:admin');
817
+
818
+ // Org-specific rights
819
+ await rbac.checkRight(userId, orgId, 'org:settings:edit');</code></pre>
820
+ </div>
821
+ </div>
822
+ </div>
823
+ </section>
824
+
825
+ <!-- Section 4: Troubleshooting -->
826
+ <section class="bg-white border border-gray-200 rounded-lg">
827
+ <div class="p-4 border-b border-gray-200">
828
+ <h3 class="text-lg font-semibold text-gray-800">Troubleshooting & Best Practices</h3>
829
+ </div>
830
+ <div class="p-4 space-y-4">
831
+ <div>
832
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">Common Errors</h4>
833
+ <ul class="text-sm text-gray-600 space-y-1 list-disc list-inside">
834
+ <li><strong>Invalid userId:</strong> Ensure the user exists in your database</li>
835
+ <li><strong>Invalid orgId:</strong> For org-scoped rights, orgId must be valid</li>
836
+ <li><strong>Malformed right:</strong> Rights should follow the pattern <code>domain:action:resource</code></li>
837
+ <li><strong>Authentication:</strong> API calls require valid JWT or Basic Auth</li>
838
+ </ul>
839
+ </div>
840
+ <div>
841
+ <h4 class="text-sm font-semibold text-gray-700 mb-2">Performance Tips</h4>
842
+ <ul class="text-sm text-gray-600 space-y-1 list-disc list-inside">
843
+ <li>Cache results for frequently checked rights</li>
844
+ <li>Use wildcard rights to reduce grant count</li>
845
+ <li>Batch multiple rights checks when possible</li>
846
+ <li>Consider decisionLayer for audit logging</li>
847
+ </ul>
848
+ </div>
849
+ <div class="bg-blue-50 border border-blue-200 rounded p-3">
850
+ <p class="text-sm text-blue-800">
851
+ <strong>Tip:</strong> Use the <code>decisionLayer</code> property to understand why access was granted/denied. This is useful for audit trails and debugging.
852
+ </p>
853
+ </div>
854
+ </div>
855
+ </section>
856
+ </div>
857
+ </div>
858
+
859
+ <style>
860
+ [v-cloak] { display: none; }
861
+ </style>
862
+
863
+ <script>
864
+ window.BASE_URL = '<%= baseUrl %>';
865
+
866
+ const { createApp } = Vue;
867
+
868
+ createApp({
869
+ data() {
870
+ return {
871
+ activeTab: 'rights',
872
+ rights: [],
873
+ grants: [],
874
+ roles: [],
875
+ groups: [],
876
+ organizations: [],
877
+ selectedGroupId: '',
878
+ selectedGroupRoles: [],
879
+ selectedGroupMembers: [],
880
+ selectedGroup: null,
881
+ userResults: [],
882
+ grantUserResults: [],
883
+ testRightResults: [],
884
+ grantRightResults: [],
885
+ orgs: [],
886
+ testStatus: '',
887
+ testResult: null,
888
+ grantStatus: '',
889
+ roleStatus: '',
890
+ groupStatus: '',
891
+ test: {
892
+ userQuery: '',
893
+ user: null,
894
+ orgId: '',
895
+ right: '',
896
+ },
897
+ roleForm: {
898
+ key: '',
899
+ name: '',
900
+ description: '',
901
+ isGlobal: true,
902
+ orgId: '',
903
+ },
904
+ groupForm: {
905
+ name: '',
906
+ description: '',
907
+ isGlobal: true,
908
+ orgId: '',
909
+ },
910
+ groupRoleForm: {
911
+ roleId: '',
912
+ },
913
+ memberAdd: {
914
+ userQuery: '',
915
+ userResults: [],
916
+ selectedUsers: [],
917
+ status: '',
918
+ },
919
+ memberRemove: {
920
+ selectAll: false,
921
+ selectedMemberIds: [],
922
+ status: '',
923
+ },
924
+ grant: {
925
+ subjectType: 'user',
926
+ subjectId: '',
927
+ userQuery: '',
928
+ scopeType: 'org',
929
+ scopeId: '',
930
+ right: '',
931
+ effect: 'allow',
932
+ },
933
+ _userSearchTimer: null,
934
+ _grantUserSearchTimer: null,
935
+ _memberUserSearchTimer: null,
936
+ };
937
+ },
938
+ methods: {
939
+ async api(path, opts) {
940
+ const base = window.BASE_URL || '';
941
+ const res = await fetch(base + path, {
942
+ credentials: 'same-origin',
943
+ headers: { 'Content-Type': 'application/json' },
944
+ ...opts,
945
+ });
946
+ const json = await res.json().catch(() => ({}));
947
+ if (!res.ok) throw new Error(json.error || 'Request failed');
948
+ return json;
949
+ },
950
+ debouncedSearchUsers() {
951
+ clearTimeout(this._userSearchTimer);
952
+ this._userSearchTimer = setTimeout(() => this.searchUsers(), 250);
953
+ },
954
+ async searchUsers() {
955
+ const q = (this.test.userQuery || '').trim();
956
+ if (!q) {
957
+ this.userResults = [];
958
+ return;
959
+ }
960
+ const qs = new URLSearchParams();
961
+ qs.set('q', q);
962
+ const data = await this.api('/api/admin/rbac/users?' + qs.toString(), { method: 'GET' });
963
+ this.userResults = data.users || [];
964
+ },
965
+
966
+ filterTestRights() {
967
+ const q = String(this.test.right || '').trim().toLowerCase();
968
+ if (!q) {
969
+ this.testRightResults = (this.rights || []).slice(0, 50);
970
+ return;
971
+ }
972
+ this.testRightResults = (this.rights || []).filter((r) => String(r).toLowerCase().includes(q)).slice(0, 50);
973
+ },
974
+ selectTestRight(r) {
975
+ this.test.right = r;
976
+ this.testRightResults = [];
977
+ },
978
+
979
+ filterGrantRights() {
980
+ const q = String(this.grant.right || '').trim().toLowerCase();
981
+ if (!q) {
982
+ this.grantRightResults = (this.rights || []).slice(0, 50);
983
+ return;
984
+ }
985
+ this.grantRightResults = (this.rights || []).filter((r) => String(r).toLowerCase().includes(q)).slice(0, 50);
986
+ },
987
+ selectGrantRight(r) {
988
+ this.grant.right = r;
989
+ this.grantRightResults = [];
990
+ },
991
+
992
+ debouncedSearchGrantUsers() {
993
+ clearTimeout(this._grantUserSearchTimer);
994
+ this._grantUserSearchTimer = setTimeout(() => this.searchGrantUsers(), 250);
995
+ },
996
+ async searchGrantUsers() {
997
+ const q = (this.grant.userQuery || '').trim();
998
+ if (!q) {
999
+ this.grantUserResults = [];
1000
+ return;
1001
+ }
1002
+ const qs = new URLSearchParams();
1003
+ qs.set('q', q);
1004
+ const data = await this.api('/api/admin/rbac/users?' + qs.toString(), { method: 'GET' });
1005
+ this.grantUserResults = data.users || [];
1006
+ },
1007
+ async selectGrantUser(u) {
1008
+ this.grant.subjectId = u.id;
1009
+ this.grant.userQuery = u.email;
1010
+ this.grantUserResults = [];
1011
+ },
1012
+ onGrantSubjectTypeChange() {
1013
+ this.grant.subjectId = '';
1014
+ this.grant.userQuery = '';
1015
+ this.grantUserResults = [];
1016
+ },
1017
+ onGrantScopeTypeChange() {
1018
+ if (this.grant.scopeType !== 'org') {
1019
+ this.grant.scopeId = '';
1020
+ }
1021
+ },
1022
+ async selectUser(u) {
1023
+ this.test.user = u;
1024
+ this.userResults = [];
1025
+ this.test.userQuery = u.email;
1026
+ this.testResult = null;
1027
+ this.testStatus = '';
1028
+
1029
+ const data = await this.api('/api/admin/rbac/users/' + u.id + '/orgs', { method: 'GET' });
1030
+ this.orgs = data.orgs || [];
1031
+ if (this.orgs.length === 1) {
1032
+ this.test.orgId = this.orgs[0].id;
1033
+ } else {
1034
+ this.test.orgId = '';
1035
+ }
1036
+ },
1037
+ async runTest() {
1038
+ this.testStatus = '';
1039
+ this.testResult = null;
1040
+ try {
1041
+ if (!this.test.user) throw new Error('Select a user');
1042
+ if (!this.test.orgId) throw new Error('Select an org');
1043
+ if (!this.test.right) throw new Error('Enter a right');
1044
+
1045
+ const data = await this.api('/api/admin/rbac/test', {
1046
+ method: 'POST',
1047
+ body: JSON.stringify({
1048
+ userId: this.test.user.id,
1049
+ orgId: this.test.orgId,
1050
+ right: this.test.right,
1051
+ }),
1052
+ });
1053
+ this.testResult = data;
1054
+ } catch (e) {
1055
+ this.testStatus = e.message || 'Failed.';
1056
+ }
1057
+ },
1058
+ async loadRights() {
1059
+ // Derive unique rights from existing grants
1060
+ const uniqueRights = new Set();
1061
+ (this.grants || []).forEach(g => {
1062
+ if (g.right) uniqueRights.add(g.right);
1063
+ });
1064
+ this.rights = Array.from(uniqueRights).sort();
1065
+ },
1066
+ async loadGrants() {
1067
+ const data = await this.api('/api/admin/rbac/grants', { method: 'GET' });
1068
+ this.grants = data.grants || [];
1069
+ },
1070
+ async loadOrganizations() {
1071
+ const qs = new URLSearchParams();
1072
+ qs.set('limit', '500');
1073
+ const data = await this.api('/api/admin/orgs?' + qs.toString(), { method: 'GET' });
1074
+ this.organizations = data.orgs || [];
1075
+ },
1076
+ async loadRoles() {
1077
+ const data = await this.api('/api/admin/rbac/roles', { method: 'GET' });
1078
+ this.roles = data.roles || [];
1079
+ },
1080
+ async loadGroups() {
1081
+ const data = await this.api('/api/admin/rbac/groups', { method: 'GET' });
1082
+ this.groups = data.groups || [];
1083
+ },
1084
+ async loadSelectedGroupMembers() {
1085
+ this.selectedGroupMembers = [];
1086
+ this.memberRemove.selectAll = false;
1087
+ this.memberRemove.selectedMemberIds = [];
1088
+ if (!this.selectedGroupId) return;
1089
+ const data = await this.api('/api/admin/rbac/groups/' + this.selectedGroupId + '/members', { method: 'GET' });
1090
+ this.selectedGroupMembers = data.members || [];
1091
+ },
1092
+ async onSelectedGroupChange() {
1093
+ this.selectedGroup = (this.groups || []).find((g) => String(g.id) === String(this.selectedGroupId)) || null;
1094
+ this.memberAdd.userQuery = '';
1095
+ this.memberAdd.userResults = [];
1096
+ this.memberAdd.selectedUsers = [];
1097
+ this.memberAdd.status = '';
1098
+ this.memberRemove.status = '';
1099
+ await Promise.all([
1100
+ this.loadSelectedGroupRoles(),
1101
+ this.loadSelectedGroupMembers(),
1102
+ ]);
1103
+ },
1104
+ debouncedSearchMemberUsers() {
1105
+ clearTimeout(this._memberUserSearchTimer);
1106
+ this._memberUserSearchTimer = setTimeout(() => this.searchMemberUsers(), 250);
1107
+ },
1108
+ async searchMemberUsers() {
1109
+ const q = (this.memberAdd.userQuery || '').trim();
1110
+ if (!q) {
1111
+ this.memberAdd.userResults = [];
1112
+ return;
1113
+ }
1114
+ if (!this.selectedGroupId) {
1115
+ this.memberAdd.userResults = [];
1116
+ return;
1117
+ }
1118
+
1119
+ const qs = new URLSearchParams();
1120
+ qs.set('q', q);
1121
+ if (this.selectedGroup && this.selectedGroup.isGlobal === false && this.selectedGroup.orgId) {
1122
+ qs.set('orgId', String(this.selectedGroup.orgId));
1123
+ }
1124
+ const data = await this.api('/api/admin/rbac/users?' + qs.toString(), { method: 'GET' });
1125
+ this.memberAdd.userResults = data.users || [];
1126
+ },
1127
+ addMemberCandidate(u) {
1128
+ if (!u || !u.id) return;
1129
+ const exists = (this.memberAdd.selectedUsers || []).some((x) => String(x.id) === String(u.id));
1130
+ if (!exists) this.memberAdd.selectedUsers.push({ id: u.id, email: u.email, name: u.name || '' });
1131
+ this.memberAdd.userResults = [];
1132
+ this.memberAdd.userQuery = '';
1133
+ },
1134
+ removeMemberCandidate(userId) {
1135
+ this.memberAdd.selectedUsers = (this.memberAdd.selectedUsers || []).filter((u) => String(u.id) !== String(userId));
1136
+ },
1137
+ toggleSelectAllMembers() {
1138
+ if (this.memberRemove.selectAll) {
1139
+ this.memberRemove.selectedMemberIds = (this.selectedGroupMembers || []).map((m) => m.id);
1140
+ } else {
1141
+ this.memberRemove.selectedMemberIds = [];
1142
+ }
1143
+ },
1144
+ syncSelectAllMembers() {
1145
+ const total = (this.selectedGroupMembers || []).length;
1146
+ const selected = (this.memberRemove.selectedMemberIds || []).length;
1147
+ this.memberRemove.selectAll = total > 0 && selected === total;
1148
+ },
1149
+ async bulkAddMembers() {
1150
+ this.memberAdd.status = '';
1151
+ try {
1152
+ const userIds = (this.memberAdd.selectedUsers || []).map((u) => u.id);
1153
+ if (!this.selectedGroupId) throw new Error('Select a group');
1154
+ if (!userIds.length) throw new Error('Select users');
1155
+ const data = await this.api('/api/admin/rbac/groups/' + this.selectedGroupId + '/members/bulk', {
1156
+ method: 'POST',
1157
+ body: JSON.stringify({ userIds }),
1158
+ });
1159
+ this.memberAdd.status = `Added (${data.insertedCount || 0}).`;
1160
+ this.memberAdd.selectedUsers = [];
1161
+ await this.loadSelectedGroupMembers();
1162
+ } catch (e) {
1163
+ this.memberAdd.status = e.message || 'Failed.';
1164
+ }
1165
+ },
1166
+ async removeSingleMember(memberId) {
1167
+ if (!this.selectedGroupId || !memberId) return;
1168
+ try {
1169
+ await this.api('/api/admin/rbac/groups/' + this.selectedGroupId + '/members/' + memberId, { method: 'DELETE' });
1170
+ await this.loadSelectedGroupMembers();
1171
+ } catch (e) {
1172
+ this.memberRemove.status = e.message || 'Failed.';
1173
+ }
1174
+ },
1175
+ async bulkRemoveMembers() {
1176
+ this.memberRemove.status = '';
1177
+ try {
1178
+ if (!this.selectedGroupId) throw new Error('Select a group');
1179
+ const memberIds = (this.memberRemove.selectedMemberIds || []).slice();
1180
+ if (!memberIds.length) throw new Error('Select members');
1181
+ const data = await this.api('/api/admin/rbac/groups/' + this.selectedGroupId + '/members/bulk-remove', {
1182
+ method: 'POST',
1183
+ body: JSON.stringify({ memberIds }),
1184
+ });
1185
+ this.memberRemove.status = `Removed (${data.deletedCount || 0}).`;
1186
+ this.memberRemove.selectedMemberIds = [];
1187
+ this.memberRemove.selectAll = false;
1188
+ await this.loadSelectedGroupMembers();
1189
+ } catch (e) {
1190
+ this.memberRemove.status = e.message || 'Failed.';
1191
+ }
1192
+ },
1193
+ async createRole() {
1194
+ this.roleStatus = '';
1195
+ try {
1196
+ if (!this.roleForm.key) throw new Error('code is required');
1197
+ if (!this.roleForm.name) throw new Error('name is required');
1198
+ if (!this.roleForm.isGlobal && !this.roleForm.orgId) throw new Error('org is required for org-scoped roles');
1199
+
1200
+ await this.api('/api/admin/rbac/roles', {
1201
+ method: 'POST',
1202
+ body: JSON.stringify({
1203
+ key: this.roleForm.key,
1204
+ name: this.roleForm.name,
1205
+ description: this.roleForm.description,
1206
+ isGlobal: this.roleForm.isGlobal,
1207
+ orgId: this.roleForm.isGlobal ? undefined : this.roleForm.orgId,
1208
+ }),
1209
+ });
1210
+
1211
+ this.roleStatus = 'Created.';
1212
+ this.roleForm.key = '';
1213
+ this.roleForm.name = '';
1214
+ this.roleForm.description = '';
1215
+ this.roleForm.isGlobal = true;
1216
+ this.roleForm.orgId = '';
1217
+ await this.loadRoles();
1218
+ } catch (e) {
1219
+ this.roleStatus = e.message || 'Failed.';
1220
+ }
1221
+ },
1222
+ async createGroup() {
1223
+ this.groupStatus = '';
1224
+ try {
1225
+ if (!this.groupForm.name) throw new Error('name is required');
1226
+ if (!this.groupForm.isGlobal && !this.groupForm.orgId) throw new Error('org is required for org-scoped groups');
1227
+
1228
+ await this.api('/api/admin/rbac/groups', {
1229
+ method: 'POST',
1230
+ body: JSON.stringify({
1231
+ name: this.groupForm.name,
1232
+ description: this.groupForm.description,
1233
+ isGlobal: this.groupForm.isGlobal,
1234
+ orgId: this.groupForm.isGlobal ? undefined : this.groupForm.orgId,
1235
+ }),
1236
+ });
1237
+
1238
+ this.groupStatus = 'Created.';
1239
+ this.groupForm.name = '';
1240
+ this.groupForm.description = '';
1241
+ this.groupForm.isGlobal = true;
1242
+ this.groupForm.orgId = '';
1243
+ await this.loadGroups();
1244
+ } catch (e) {
1245
+ this.groupStatus = e.message || 'Failed.';
1246
+ }
1247
+ },
1248
+ async loadSelectedGroupRoles() {
1249
+ this.selectedGroupRoles = [];
1250
+ this.groupRoleForm.roleId = '';
1251
+ if (!this.selectedGroupId) return;
1252
+ const data = await this.api('/api/admin/rbac/groups/' + this.selectedGroupId + '/roles', { method: 'GET' });
1253
+ this.selectedGroupRoles = data.roles || [];
1254
+ },
1255
+ async addRoleToGroup() {
1256
+ if (!this.selectedGroupId || !this.groupRoleForm.roleId) return;
1257
+ try {
1258
+ await this.api('/api/admin/rbac/groups/' + this.selectedGroupId + '/roles', {
1259
+ method: 'POST',
1260
+ body: JSON.stringify({ roleId: this.groupRoleForm.roleId }),
1261
+ });
1262
+ await this.loadSelectedGroupRoles();
1263
+ } catch (e) {
1264
+ this.groupStatus = e.message || 'Failed.';
1265
+ }
1266
+ },
1267
+ async removeRoleFromGroup(groupRoleId) {
1268
+ if (!this.selectedGroupId || !groupRoleId) return;
1269
+ try {
1270
+ await this.api('/api/admin/rbac/groups/' + this.selectedGroupId + '/roles/' + groupRoleId, { method: 'DELETE' });
1271
+ await this.loadSelectedGroupRoles();
1272
+ } catch (e) {
1273
+ this.groupStatus = e.message || 'Failed.';
1274
+ }
1275
+ },
1276
+ async createGrant() {
1277
+ this.grantStatus = '';
1278
+ try {
1279
+ if (!this.grant.subjectId) throw new Error('Subject ID is required');
1280
+ if (!this.grant.right) throw new Error('Right is required');
1281
+
1282
+ await this.api('/api/admin/rbac/grants', {
1283
+ method: 'POST',
1284
+ body: JSON.stringify({
1285
+ subjectId: this.grant.subjectId,
1286
+ subjectType: this.grant.subjectType,
1287
+ right: this.grant.right,
1288
+ effect: this.grant.effect,
1289
+ scopeType: this.grant.scopeType,
1290
+ scopeId: this.grant.scopeId,
1291
+ }),
1292
+ });
1293
+ this.grantStatus = 'Grant created.';
1294
+ this.grant = { subjectType: 'user', subjectId: '', right: '', effect: 'allow', scopeType: null, scopeId: '' };
1295
+ await this.loadGrants();
1296
+ await this.loadRights();
1297
+ } catch (e) {
1298
+ this.grantStatus = e.message || 'Failed.';
1299
+ }
1300
+ },
1301
+ async deleteGrant(id) {
1302
+ try {
1303
+ await this.api('/api/admin/rbac/grants/' + id, { method: 'DELETE' });
1304
+ await this.loadGrants();
1305
+ await this.loadRights();
1306
+ } catch (e) {
1307
+ // ignore
1308
+ }
1309
+ },
1310
+ async loadAll() {
1311
+ await Promise.all([
1312
+ this.loadRights(),
1313
+ this.loadGrants(),
1314
+ this.loadOrganizations(),
1315
+ this.loadRoles(),
1316
+ this.loadGroups(),
1317
+ ]);
1318
+ this.selectedGroup = (this.groups || []).find((g) => String(g.id) === String(this.selectedGroupId)) || null;
1319
+ },
1320
+ },
1321
+ async mounted() {
1322
+ await this.loadGrants();
1323
+ await this.loadRights();
1324
+ await this.loadOrganizations();
1325
+ await this.loadRoles();
1326
+ await this.loadGroups();
1327
+ },
1328
+ }).mount('#app');
1329
+ </script>
1330
+ </body>
1331
+ </html>