@intranefr/superbackend 1.4.3

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 (188) hide show
  1. package/.commiat +4 -0
  2. package/.env.example +47 -0
  3. package/README.md +110 -0
  4. package/index.js +94 -0
  5. package/package.json +67 -0
  6. package/public/css/styles.css +139 -0
  7. package/public/js/animations.js +41 -0
  8. package/sdk/error-tracking/browser/package.json +16 -0
  9. package/sdk/error-tracking/browser/src/core.js +270 -0
  10. package/sdk/error-tracking/browser/src/embed.js +18 -0
  11. package/sdk/error-tracking/browser/src/index.js +1 -0
  12. package/server.js +5 -0
  13. package/src/admin/endpointRegistry.js +300 -0
  14. package/src/controllers/admin.controller.js +321 -0
  15. package/src/controllers/adminAssets.controller.js +530 -0
  16. package/src/controllers/adminAssetsStorage.controller.js +260 -0
  17. package/src/controllers/adminEjsVirtual.controller.js +354 -0
  18. package/src/controllers/adminFeatureFlags.controller.js +155 -0
  19. package/src/controllers/adminHeadless.controller.js +1071 -0
  20. package/src/controllers/adminI18n.controller.js +604 -0
  21. package/src/controllers/adminJsonConfigs.controller.js +97 -0
  22. package/src/controllers/adminLlm.controller.js +273 -0
  23. package/src/controllers/adminMigration.controller.js +257 -0
  24. package/src/controllers/adminSeoConfig.controller.js +515 -0
  25. package/src/controllers/adminStats.controller.js +121 -0
  26. package/src/controllers/adminUploadNamespaces.controller.js +208 -0
  27. package/src/controllers/assets.controller.js +248 -0
  28. package/src/controllers/auth.controller.js +93 -0
  29. package/src/controllers/billing.controller.js +223 -0
  30. package/src/controllers/featureFlags.controller.js +35 -0
  31. package/src/controllers/forms.controller.js +217 -0
  32. package/src/controllers/globalSettings.controller.js +252 -0
  33. package/src/controllers/headlessCrud.controller.js +126 -0
  34. package/src/controllers/i18n.controller.js +12 -0
  35. package/src/controllers/invite.controller.js +249 -0
  36. package/src/controllers/jsonConfigs.controller.js +19 -0
  37. package/src/controllers/metrics.controller.js +149 -0
  38. package/src/controllers/notificationAdmin.controller.js +264 -0
  39. package/src/controllers/notifications.controller.js +131 -0
  40. package/src/controllers/org.controller.js +357 -0
  41. package/src/controllers/orgAdmin.controller.js +491 -0
  42. package/src/controllers/stripeAdmin.controller.js +410 -0
  43. package/src/controllers/user.controller.js +361 -0
  44. package/src/controllers/userAdmin.controller.js +277 -0
  45. package/src/controllers/waitingList.controller.js +167 -0
  46. package/src/controllers/webhook.controller.js +200 -0
  47. package/src/middleware/auth.js +66 -0
  48. package/src/middleware/errorCapture.js +170 -0
  49. package/src/middleware/headlessApiTokenAuth.js +57 -0
  50. package/src/middleware/org.js +108 -0
  51. package/src/middleware.js +901 -0
  52. package/src/models/ActionEvent.js +31 -0
  53. package/src/models/ActivityLog.js +41 -0
  54. package/src/models/Asset.js +84 -0
  55. package/src/models/AuditEvent.js +93 -0
  56. package/src/models/EmailLog.js +28 -0
  57. package/src/models/ErrorAggregate.js +72 -0
  58. package/src/models/FormSubmission.js +41 -0
  59. package/src/models/GlobalSetting.js +38 -0
  60. package/src/models/HeadlessApiToken.js +24 -0
  61. package/src/models/HeadlessModelDefinition.js +41 -0
  62. package/src/models/I18nEntry.js +77 -0
  63. package/src/models/I18nLocale.js +33 -0
  64. package/src/models/Invite.js +70 -0
  65. package/src/models/JsonConfig.js +46 -0
  66. package/src/models/Notification.js +60 -0
  67. package/src/models/Organization.js +57 -0
  68. package/src/models/OrganizationMember.js +43 -0
  69. package/src/models/StripeCatalogItem.js +77 -0
  70. package/src/models/StripeWebhookEvent.js +57 -0
  71. package/src/models/User.js +89 -0
  72. package/src/models/VirtualEjsFile.js +60 -0
  73. package/src/models/VirtualEjsFileVersion.js +43 -0
  74. package/src/models/VirtualEjsGroupChange.js +32 -0
  75. package/src/models/WaitingList.js +41 -0
  76. package/src/models/Webhook.js +63 -0
  77. package/src/models/Workflow.js +29 -0
  78. package/src/models/WorkflowExecution.js +12 -0
  79. package/src/routes/admin.routes.js +26 -0
  80. package/src/routes/adminAssets.routes.js +28 -0
  81. package/src/routes/adminAssetsStorage.routes.js +13 -0
  82. package/src/routes/adminAudit.routes.js +196 -0
  83. package/src/routes/adminEjsVirtual.routes.js +17 -0
  84. package/src/routes/adminErrors.routes.js +164 -0
  85. package/src/routes/adminFeatureFlags.routes.js +12 -0
  86. package/src/routes/adminHeadless.routes.js +38 -0
  87. package/src/routes/adminI18n.routes.js +22 -0
  88. package/src/routes/adminJsonConfigs.routes.js +15 -0
  89. package/src/routes/adminLlm.routes.js +12 -0
  90. package/src/routes/adminMigration.routes.js +81 -0
  91. package/src/routes/adminSeoConfig.routes.js +20 -0
  92. package/src/routes/adminUploadNamespaces.routes.js +13 -0
  93. package/src/routes/assets.routes.js +21 -0
  94. package/src/routes/auth.routes.js +12 -0
  95. package/src/routes/billing.routes.js +11 -0
  96. package/src/routes/errorTracking.routes.js +31 -0
  97. package/src/routes/featureFlags.routes.js +9 -0
  98. package/src/routes/forms.routes.js +9 -0
  99. package/src/routes/formsAdmin.routes.js +13 -0
  100. package/src/routes/globalSettings.routes.js +18 -0
  101. package/src/routes/headless.routes.js +15 -0
  102. package/src/routes/i18n.routes.js +8 -0
  103. package/src/routes/invite.routes.js +9 -0
  104. package/src/routes/jsonConfigs.routes.js +8 -0
  105. package/src/routes/log.routes.js +111 -0
  106. package/src/routes/metrics.routes.js +9 -0
  107. package/src/routes/notificationAdmin.routes.js +15 -0
  108. package/src/routes/notifications.routes.js +12 -0
  109. package/src/routes/org.routes.js +31 -0
  110. package/src/routes/orgAdmin.routes.js +20 -0
  111. package/src/routes/publicAssets.routes.js +7 -0
  112. package/src/routes/stripeAdmin.routes.js +20 -0
  113. package/src/routes/user.routes.js +22 -0
  114. package/src/routes/userAdmin.routes.js +15 -0
  115. package/src/routes/waitingList.routes.js +13 -0
  116. package/src/routes/waitingListAdmin.routes.js +9 -0
  117. package/src/routes/webhook.routes.js +32 -0
  118. package/src/routes/workflowWebhook.routes.js +54 -0
  119. package/src/routes/workflows.routes.js +110 -0
  120. package/src/services/assets.service.js +110 -0
  121. package/src/services/audit.service.js +62 -0
  122. package/src/services/auditLogger.js +165 -0
  123. package/src/services/ejsVirtual.service.js +614 -0
  124. package/src/services/email.service.js +351 -0
  125. package/src/services/errorLogger.js +221 -0
  126. package/src/services/featureFlags.service.js +202 -0
  127. package/src/services/forms.service.js +214 -0
  128. package/src/services/globalSettings.service.js +49 -0
  129. package/src/services/headlessApiTokens.service.js +158 -0
  130. package/src/services/headlessCrypto.service.js +31 -0
  131. package/src/services/headlessModels.service.js +356 -0
  132. package/src/services/i18n.service.js +314 -0
  133. package/src/services/i18nInferredKeys.service.js +337 -0
  134. package/src/services/jsonConfigs.service.js +392 -0
  135. package/src/services/llm.service.js +749 -0
  136. package/src/services/migration.service.js +581 -0
  137. package/src/services/migrationAssets/fsLocal.js +58 -0
  138. package/src/services/migrationAssets/index.js +134 -0
  139. package/src/services/migrationAssets/s3.js +75 -0
  140. package/src/services/migrationAssets/sftp.js +92 -0
  141. package/src/services/notification.service.js +212 -0
  142. package/src/services/objectStorage.service.js +514 -0
  143. package/src/services/seoConfig.service.js +402 -0
  144. package/src/services/storage.js +150 -0
  145. package/src/services/stripe.service.js +185 -0
  146. package/src/services/stripeHelper.service.js +264 -0
  147. package/src/services/uploadNamespaces.service.js +326 -0
  148. package/src/services/webhook.service.js +157 -0
  149. package/src/services/workflow.service.js +271 -0
  150. package/src/utils/asyncHandler.js +5 -0
  151. package/src/utils/encryption.js +80 -0
  152. package/src/utils/jwt.js +40 -0
  153. package/src/utils/orgRoles.js +156 -0
  154. package/src/utils/validation.js +26 -0
  155. package/src/utils/webhookRetry.js +93 -0
  156. package/views/admin-assets.ejs +444 -0
  157. package/views/admin-audit.ejs +283 -0
  158. package/views/admin-coolify-deploy.ejs +207 -0
  159. package/views/admin-dashboard-home.ejs +291 -0
  160. package/views/admin-dashboard.ejs +397 -0
  161. package/views/admin-ejs-virtual.ejs +280 -0
  162. package/views/admin-errors.ejs +368 -0
  163. package/views/admin-feature-flags.ejs +390 -0
  164. package/views/admin-forms.ejs +526 -0
  165. package/views/admin-global-settings.ejs +436 -0
  166. package/views/admin-headless.ejs +2020 -0
  167. package/views/admin-i18n-locales.ejs +221 -0
  168. package/views/admin-i18n.ejs +728 -0
  169. package/views/admin-json-configs.ejs +410 -0
  170. package/views/admin-llm.ejs +884 -0
  171. package/views/admin-metrics.ejs +274 -0
  172. package/views/admin-migration.ejs +814 -0
  173. package/views/admin-notifications.ejs +430 -0
  174. package/views/admin-organizations.ejs +984 -0
  175. package/views/admin-seo-config.ejs +673 -0
  176. package/views/admin-stripe-pricing.ejs +558 -0
  177. package/views/admin-test.ejs +342 -0
  178. package/views/admin-users.ejs +452 -0
  179. package/views/admin-waiting-list.ejs +547 -0
  180. package/views/admin-webhooks.ejs +329 -0
  181. package/views/admin-workflows.ejs +310 -0
  182. package/views/partials/admin-assets-script.ejs +2022 -0
  183. package/views/partials/admin-test-sidebar.ejs +14 -0
  184. package/views/partials/dashboard/nav-items.ejs +66 -0
  185. package/views/partials/dashboard/palette.ejs +63 -0
  186. package/views/partials/dashboard/sidebar.ejs +21 -0
  187. package/views/partials/dashboard/tab-bar.ejs +26 -0
  188. package/views/partials/footer.ejs +3 -0
@@ -0,0 +1,984 @@
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>Organizations Admin</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <style>
9
+ .toast {
10
+ animation: slideIn 0.3s ease-out;
11
+ }
12
+ @keyframes slideIn {
13
+ from { transform: translateX(100%); opacity: 0; }
14
+ to { transform: translateX(0); opacity: 1; }
15
+ }
16
+ .fade-out {
17
+ animation: fadeOut 0.3s ease-out forwards;
18
+ }
19
+ @keyframes fadeOut {
20
+ from { opacity: 1; }
21
+ to { opacity: 0; }
22
+ }
23
+ </style>
24
+ </head>
25
+ <body class="bg-gray-100">
26
+ <div class="min-h-screen">
27
+ <!-- Header -->
28
+ <div class="bg-white shadow">
29
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
30
+ <div class="flex justify-between items-center">
31
+ <div>
32
+ <h1 class="text-2xl font-bold text-gray-900">Organizations</h1>
33
+ <p class="text-sm text-gray-600 mt-1">Browse orgs, members and invites</p>
34
+ </div>
35
+ <div class="flex items-center gap-4">
36
+ </div>
37
+ </div>
38
+ </div>
39
+ </div>
40
+
41
+ <!-- Main Content -->
42
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
43
+ <div class="grid grid-cols-1 lg:grid-cols-5 gap-6">
44
+ <!-- Left: Orgs list -->
45
+ <div class="lg:col-span-2">
46
+ <div class="bg-white rounded-lg shadow p-6">
47
+ <div class="flex items-center justify-between">
48
+ <div>
49
+ <h2 class="text-lg font-semibold text-gray-900">Organizations</h2>
50
+ <p id="orgs-subtitle" class="text-sm text-gray-600 mt-1">-</p>
51
+ </div>
52
+ <button id="btn-orgs-refresh" class="bg-gray-100 text-gray-800 px-3 py-2 rounded hover:bg-gray-200">Refresh</button>
53
+ </div>
54
+
55
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-4">
56
+ <div>
57
+ <label class="block text-sm font-medium text-gray-700 mb-1">Search</label>
58
+ <input id="orgs-q" type="text" class="w-full border rounded px-3 py-2" placeholder="name or slug">
59
+ </div>
60
+ <div>
61
+ <label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
62
+ <select id="orgs-status" class="w-full border rounded px-3 py-2">
63
+ <option value="">All</option>
64
+ <option value="active">active</option>
65
+ <option value="disabled">disabled</option>
66
+ </select>
67
+ </div>
68
+ <div>
69
+ <label class="block text-sm font-medium text-gray-700 mb-1">Limit</label>
70
+ <input id="orgs-limit" type="number" min="1" max="500" class="w-full border rounded px-3 py-2" value="25">
71
+ </div>
72
+ <div>
73
+ <label class="block text-sm font-medium text-gray-700 mb-1">Owner userId</label>
74
+ <input id="orgs-owner" type="text" class="w-full border rounded px-3 py-2" placeholder="optional">
75
+ </div>
76
+ </div>
77
+
78
+ <div class="flex gap-2 mt-4">
79
+ <button id="btn-orgs-apply" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Apply</button>
80
+ <button id="btn-orgs-reset" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200">Reset</button>
81
+ <div class="flex-1"></div>
82
+ <button id="btn-orgs-prev" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200">Prev</button>
83
+ <button id="btn-orgs-next" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200">Next</button>
84
+ </div>
85
+
86
+ <div class="overflow-x-auto mt-4">
87
+ <table class="min-w-full divide-y divide-gray-200">
88
+ <thead class="bg-gray-50">
89
+ <tr>
90
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
91
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Slug</th>
92
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
93
+ </tr>
94
+ </thead>
95
+ <tbody id="orgs-tbody" class="bg-white divide-y divide-gray-200"></tbody>
96
+ </table>
97
+ </div>
98
+ </div>
99
+ </div>
100
+
101
+ <!-- Right: Selected org -->
102
+ <div class="lg:col-span-3">
103
+ <div class="bg-white rounded-lg shadow p-6">
104
+ <div class="flex items-start justify-between gap-4">
105
+ <div>
106
+ <h2 class="text-lg font-semibold text-gray-900">Selected Organization</h2>
107
+ <p id="selected-org-subtitle" class="text-sm text-gray-600 mt-1">Select an org from the list</p>
108
+ </div>
109
+ <button id="btn-selected-refresh" class="bg-gray-100 text-gray-800 px-3 py-2 rounded hover:bg-gray-200" disabled>Refresh</button>
110
+ </div>
111
+
112
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
113
+ <div class="bg-gray-50 rounded-lg border p-4">
114
+ <p class="text-sm text-gray-500">Members (active)</p>
115
+ <p id="kpi-members" class="text-2xl font-bold text-gray-900 mt-1">-</p>
116
+ </div>
117
+ <div class="bg-gray-50 rounded-lg border p-4">
118
+ <p class="text-sm text-gray-500">Invites (pending)</p>
119
+ <p id="kpi-invites" class="text-2xl font-bold text-gray-900 mt-1">-</p>
120
+ </div>
121
+ <div class="bg-gray-50 rounded-lg border p-4">
122
+ <p class="text-sm text-gray-500">Org status</p>
123
+ <p id="kpi-org-status" class="text-sm font-medium text-gray-900 mt-2">-</p>
124
+ </div>
125
+ </div>
126
+
127
+ <div class="border-b border-gray-200 mt-6">
128
+ <nav class="-mb-px flex space-x-6" aria-label="Tabs">
129
+ <button id="tab-members" class="tab-btn border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">Members</button>
130
+ <button id="tab-invites" class="tab-btn border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">Invites</button>
131
+ </nav>
132
+ </div>
133
+
134
+ <div id="panel-members" class="mt-6">
135
+ <div class="flex items-center justify-between">
136
+ <div>
137
+ <h3 class="text-md font-semibold text-gray-900">Members</h3>
138
+ <p id="members-subtitle" class="text-sm text-gray-600 mt-1">-</p>
139
+ </div>
140
+ <button id="btn-members-refresh" class="bg-gray-100 text-gray-800 px-3 py-2 rounded hover:bg-gray-200" disabled>Refresh</button>
141
+ </div>
142
+
143
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
144
+ <div>
145
+ <label class="block text-sm font-medium text-gray-700 mb-1">Role</label>
146
+ <select id="members-role" class="w-full border rounded px-3 py-2">
147
+ <option value="">All</option>
148
+ <option value="owner">owner</option>
149
+ <option value="admin">admin</option>
150
+ <option value="member">member</option>
151
+ <option value="viewer">viewer</option>
152
+ </select>
153
+ </div>
154
+ <div>
155
+ <label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
156
+ <select id="members-status" class="w-full border rounded px-3 py-2">
157
+ <option value="">All</option>
158
+ <option value="active">active</option>
159
+ <option value="removed">removed</option>
160
+ </select>
161
+ </div>
162
+ <div>
163
+ <label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
164
+ <input id="members-email" type="text" class="w-full border rounded px-3 py-2" placeholder="exact match">
165
+ </div>
166
+ </div>
167
+
168
+ <div class="flex gap-2 mt-4">
169
+ <button id="btn-members-apply" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600" disabled>Apply</button>
170
+ <button id="btn-members-reset" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200" disabled>Reset</button>
171
+ <div class="flex-1"></div>
172
+ <button id="btn-members-prev" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200" disabled>Prev</button>
173
+ <button id="btn-members-next" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200" disabled>Next</button>
174
+ </div>
175
+
176
+ <div class="overflow-x-auto mt-4">
177
+ <table class="min-w-full divide-y divide-gray-200">
178
+ <thead class="bg-gray-50">
179
+ <tr>
180
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
181
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
182
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
183
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Added</th>
184
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
185
+ </tr>
186
+ </thead>
187
+ <tbody id="members-tbody" class="bg-white divide-y divide-gray-200"></tbody>
188
+ </table>
189
+ </div>
190
+ </div>
191
+
192
+ <div id="panel-invites" class="mt-6 hidden">
193
+ <div class="flex items-center justify-between">
194
+ <div>
195
+ <h3 class="text-md font-semibold text-gray-900">Invites</h3>
196
+ <p id="invites-subtitle" class="text-sm text-gray-600 mt-1">-</p>
197
+ </div>
198
+ <button id="btn-invites-refresh" class="bg-gray-100 text-gray-800 px-3 py-2 rounded hover:bg-gray-200" disabled>Refresh</button>
199
+ </div>
200
+
201
+ <div class="bg-gray-50 rounded-lg border p-4 mt-4">
202
+ <h4 class="font-semibold text-gray-900">Create invite</h4>
203
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-3 mt-3">
204
+ <div>
205
+ <label class="block text-sm font-medium text-gray-700 mb-1">Email *</label>
206
+ <input id="invite-email" type="email" class="w-full border rounded px-3 py-2" placeholder="invitee@example.com">
207
+ </div>
208
+ <div>
209
+ <label class="block text-sm font-medium text-gray-700 mb-1">Role</label>
210
+ <select id="invite-role" class="w-full border rounded px-3 py-2">
211
+ <option value="member">member</option>
212
+ <option value="admin">admin</option>
213
+ <option value="viewer">viewer</option>
214
+ </select>
215
+ </div>
216
+ <div>
217
+ <label class="block text-sm font-medium text-gray-700 mb-1">Expires (days)</label>
218
+ <input id="invite-expires" type="number" min="1" max="365" class="w-full border rounded px-3 py-2" value="7">
219
+ </div>
220
+ </div>
221
+ <div class="mt-3 flex gap-2">
222
+ <button id="btn-invite-create" class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600" disabled>Create Invite</button>
223
+ <button id="btn-invite-clear" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200" disabled>Clear</button>
224
+ </div>
225
+ </div>
226
+
227
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
228
+ <div>
229
+ <label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
230
+ <select id="invites-status" class="w-full border rounded px-3 py-2">
231
+ <option value="">All</option>
232
+ <option value="pending">pending</option>
233
+ <option value="accepted">accepted</option>
234
+ <option value="revoked">revoked</option>
235
+ <option value="expired">expired</option>
236
+ </select>
237
+ </div>
238
+ <div>
239
+ <label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
240
+ <input id="invites-email" type="text" class="w-full border rounded px-3 py-2" placeholder="exact match">
241
+ </div>
242
+ <div>
243
+ <label class="block text-sm font-medium text-gray-700 mb-1">Limit</label>
244
+ <input id="invites-limit" type="number" min="1" max="500" class="w-full border rounded px-3 py-2" value="50">
245
+ </div>
246
+ </div>
247
+
248
+ <div class="flex gap-2 mt-4">
249
+ <button id="btn-invites-apply" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600" disabled>Apply</button>
250
+ <button id="btn-invites-reset" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200" disabled>Reset</button>
251
+ <div class="flex-1"></div>
252
+ <button id="btn-invites-prev" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200" disabled>Prev</button>
253
+ <button id="btn-invites-next" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200" disabled>Next</button>
254
+ </div>
255
+
256
+ <div class="overflow-x-auto mt-4">
257
+ <table class="min-w-full divide-y divide-gray-200">
258
+ <thead class="bg-gray-50">
259
+ <tr>
260
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
261
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
262
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
263
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Expires</th>
264
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
265
+ <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
266
+ </tr>
267
+ </thead>
268
+ <tbody id="invites-tbody" class="bg-white divide-y divide-gray-200"></tbody>
269
+ </table>
270
+ </div>
271
+ </div>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ </div>
276
+ </div>
277
+
278
+ <!-- Toast Container -->
279
+ <div id="toast-container" class="fixed top-4 right-4 space-y-2 z-50"></div>
280
+
281
+ <script>
282
+ const API_BASE = window.location.origin + "<%= baseUrl %>" || window.location.origin;
283
+ const ORGS_ADMIN_PATH = '/api/admin/orgs';
284
+
285
+ function showToast(message, type = 'success') {
286
+ const container = document.getElementById('toast-container');
287
+ const toast = document.createElement('div');
288
+ toast.className = `toast px-6 py-4 rounded-lg shadow-lg text-white ${
289
+ type === 'success' ? 'bg-green-500' : 'bg-red-500'
290
+ }`;
291
+ toast.textContent = message;
292
+ container.appendChild(toast);
293
+
294
+ setTimeout(() => {
295
+ toast.classList.add('fade-out');
296
+ setTimeout(() => toast.remove(), 300);
297
+ }, 3000);
298
+ }
299
+
300
+ function qs(obj) {
301
+ const params = new URLSearchParams();
302
+ Object.entries(obj).forEach(([k, v]) => {
303
+ if (v === undefined || v === null) return;
304
+ const s = String(v).trim();
305
+ if (!s) return;
306
+ params.set(k, s);
307
+ });
308
+ const out = params.toString();
309
+ return out ? `?${out}` : '';
310
+ }
311
+
312
+ function escapeHtml(str) {
313
+ return String(str || '')
314
+ .replaceAll('&', '&amp;')
315
+ .replaceAll('<', '&lt;')
316
+ .replaceAll('>', '&gt;')
317
+ .replaceAll('"', '&quot;')
318
+ .replaceAll("'", '&#39;');
319
+ }
320
+
321
+ function formatDateTime(iso) {
322
+ try {
323
+ return new Date(iso).toLocaleString();
324
+ } catch {
325
+ return String(iso || '');
326
+ }
327
+ }
328
+
329
+ async function copyText(text) {
330
+ try {
331
+ await navigator.clipboard.writeText(text);
332
+ showToast('Copied to clipboard', 'success');
333
+ } catch (e) {
334
+ try {
335
+ const ta = document.createElement('textarea');
336
+ ta.value = text;
337
+ ta.style.position = 'fixed';
338
+ ta.style.left = '-9999px';
339
+ document.body.appendChild(ta);
340
+ ta.focus();
341
+ ta.select();
342
+ document.execCommand('copy');
343
+ ta.remove();
344
+ showToast('Copied to clipboard', 'success');
345
+ } catch (err) {
346
+ showToast('Failed to copy', 'error');
347
+ }
348
+ }
349
+ }
350
+
351
+ function setActiveTab(which) {
352
+ const tabMembers = document.getElementById('tab-members');
353
+ const tabInvites = document.getElementById('tab-invites');
354
+ const panelMembers = document.getElementById('panel-members');
355
+ const panelInvites = document.getElementById('panel-invites');
356
+
357
+ const activeClass = 'border-blue-500 text-blue-600';
358
+ const inactiveClass = 'border-transparent text-gray-500';
359
+
360
+ if (which === 'invites') {
361
+ tabInvites.className = tabInvites.className.replace(inactiveClass, '').trim();
362
+ tabMembers.className = tabMembers.className.replace(activeClass, '').trim();
363
+ tabInvites.className = `tab-btn ${activeClass} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm`;
364
+ tabMembers.className = `tab-btn ${inactiveClass} hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm`;
365
+ panelInvites.classList.remove('hidden');
366
+ panelMembers.classList.add('hidden');
367
+ return;
368
+ }
369
+
370
+ tabMembers.className = tabMembers.className.replace(inactiveClass, '').trim();
371
+ tabInvites.className = tabInvites.className.replace(activeClass, '').trim();
372
+ tabMembers.className = `tab-btn ${activeClass} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm`;
373
+ tabInvites.className = `tab-btn ${inactiveClass} hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm`;
374
+ panelMembers.classList.remove('hidden');
375
+ panelInvites.classList.add('hidden');
376
+ }
377
+
378
+ const state = {
379
+ orgs: { offset: 0, limit: 25, total: 0, selectedOrgId: null },
380
+ members: { offset: 0, limit: 50, total: 0 },
381
+ invites: { offset: 0, limit: 50, total: 0 },
382
+ };
383
+
384
+ async function loadOrgs() {
385
+ const q = document.getElementById('orgs-q')?.value?.trim();
386
+ const status = document.getElementById('orgs-status')?.value?.trim();
387
+ const ownerUserId = document.getElementById('orgs-owner')?.value?.trim();
388
+ const limit = parseInt(document.getElementById('orgs-limit')?.value || '25', 10);
389
+
390
+ const subtitle = document.getElementById('orgs-subtitle');
391
+ const tbody = document.getElementById('orgs-tbody');
392
+ if (subtitle) subtitle.textContent = 'Loading...';
393
+ if (tbody) tbody.innerHTML = '';
394
+
395
+ try {
396
+ const url = `${API_BASE}${ORGS_ADMIN_PATH}${qs({
397
+ q: q || undefined,
398
+ status: status || undefined,
399
+ ownerUserId: ownerUserId || undefined,
400
+ limit,
401
+ offset: state.orgs.offset,
402
+ })}`;
403
+ const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
404
+ const data = await res.json();
405
+
406
+ if (!res.ok) {
407
+ showToast(data?.error || 'Failed to load orgs', 'error');
408
+ if (subtitle) subtitle.textContent = 'Failed to load.';
409
+ return;
410
+ }
411
+
412
+ const orgs = Array.isArray(data?.orgs) ? data.orgs : [];
413
+ const pagination = data?.pagination || {};
414
+ const total = Number.isFinite(pagination.total) ? pagination.total : 0;
415
+ const usedLimit = Number.isFinite(pagination.limit) ? pagination.limit : limit;
416
+ const usedOffset = Number.isFinite(pagination.offset) ? pagination.offset : state.orgs.offset;
417
+
418
+ state.orgs.limit = usedLimit;
419
+ state.orgs.offset = usedOffset;
420
+ state.orgs.total = total;
421
+
422
+ if (subtitle) {
423
+ const from = total === 0 ? 0 : usedOffset + 1;
424
+ const to = Math.min(usedOffset + orgs.length, total);
425
+ subtitle.textContent = `${from}-${to} of ${total}`;
426
+ }
427
+
428
+ const prevBtn = document.getElementById('btn-orgs-prev');
429
+ const nextBtn = document.getElementById('btn-orgs-next');
430
+ if (prevBtn) prevBtn.disabled = usedOffset <= 0;
431
+ if (nextBtn) nextBtn.disabled = usedOffset + usedLimit >= total;
432
+
433
+ if (tbody) {
434
+ if (orgs.length === 0) {
435
+ tbody.innerHTML = `
436
+ <tr>
437
+ <td class="px-4 py-6 text-sm text-gray-600" colspan="3">No organizations found.</td>
438
+ </tr>
439
+ `;
440
+ return;
441
+ }
442
+
443
+ tbody.innerHTML = orgs.map((o) => {
444
+ const isSelected = state.orgs.selectedOrgId && String(o?._id) === String(state.orgs.selectedOrgId);
445
+ const rowClass = isSelected ? 'bg-blue-50' : 'bg-white';
446
+
447
+ return `
448
+ <tr class="${rowClass} hover:bg-gray-50 cursor-pointer" data-org-id="${escapeHtml(o?._id)}">
449
+ <td class="px-4 py-3 text-sm text-gray-900">
450
+ <div class="font-medium">${escapeHtml(o?.name)}</div>
451
+ <div class="text-xs text-gray-500">${escapeHtml(o?.ownerUserId)}</div>
452
+ </td>
453
+ <td class="px-4 py-3 text-sm text-gray-700">${escapeHtml(o?.slug)}</td>
454
+ <td class="px-4 py-3 text-sm text-gray-700">${escapeHtml(o?.status)}</td>
455
+ </tr>
456
+ `;
457
+ }).join('');
458
+
459
+ tbody.querySelectorAll('[data-org-id]').forEach((row) => {
460
+ row.addEventListener('click', async () => {
461
+ const orgId = row.getAttribute('data-org-id');
462
+ if (!orgId) return;
463
+ await selectOrg(orgId);
464
+ });
465
+ });
466
+ }
467
+ } catch (e) {
468
+ showToast(e.message || 'Failed to load orgs', 'error');
469
+ if (subtitle) subtitle.textContent = 'Failed to load.';
470
+ }
471
+ }
472
+
473
+ function setSelectedControlsEnabled(enabled) {
474
+ const ids = [
475
+ 'btn-selected-refresh',
476
+ 'btn-members-refresh',
477
+ 'btn-members-apply',
478
+ 'btn-members-reset',
479
+ 'btn-members-prev',
480
+ 'btn-members-next',
481
+ 'btn-invites-refresh',
482
+ 'btn-invites-apply',
483
+ 'btn-invites-reset',
484
+ 'btn-invites-prev',
485
+ 'btn-invites-next',
486
+ 'btn-invite-create',
487
+ 'btn-invite-clear',
488
+ ];
489
+ ids.forEach((id) => {
490
+ const el = document.getElementById(id);
491
+ if (el) el.disabled = !enabled;
492
+ });
493
+ }
494
+
495
+ async function selectOrg(orgId) {
496
+ state.orgs.selectedOrgId = String(orgId);
497
+ state.members.offset = 0;
498
+ state.invites.offset = 0;
499
+
500
+ setSelectedControlsEnabled(true);
501
+ await Promise.all([loadSelectedOrg(), loadMembers(), loadInvites()]);
502
+ await loadOrgs();
503
+ }
504
+
505
+ async function loadSelectedOrg() {
506
+ const orgId = state.orgs.selectedOrgId;
507
+ const subtitle = document.getElementById('selected-org-subtitle');
508
+ if (!orgId) {
509
+ if (subtitle) subtitle.textContent = 'Select an org from the list';
510
+ return;
511
+ }
512
+
513
+ try {
514
+ const res = await fetch(`${API_BASE}${ORGS_ADMIN_PATH}/${encodeURIComponent(orgId)}`, { headers: { 'Accept': 'application/json' } });
515
+ const data = await res.json();
516
+
517
+ if (!res.ok) {
518
+ showToast(data?.error || 'Failed to load org', 'error');
519
+ return;
520
+ }
521
+
522
+ const org = data?.org;
523
+ const counts = data?.counts || {};
524
+
525
+ if (subtitle) {
526
+ subtitle.innerHTML = `${escapeHtml(org?.name)} <span class="text-xs text-gray-500">(${escapeHtml(org?.slug)})</span>`;
527
+ }
528
+
529
+ const membersCount = counts?.membersActive ?? '-';
530
+ const invitesPending = counts?.invitesPending ?? '-';
531
+
532
+ const kpiMembers = document.getElementById('kpi-members');
533
+ const kpiInvites = document.getElementById('kpi-invites');
534
+ const kpiStatus = document.getElementById('kpi-org-status');
535
+
536
+ if (kpiMembers) kpiMembers.textContent = String(membersCount);
537
+ if (kpiInvites) kpiInvites.textContent = String(invitesPending);
538
+ if (kpiStatus) kpiStatus.textContent = escapeHtml(org?.status);
539
+ } catch (e) {
540
+ showToast(e.message || 'Failed to load org', 'error');
541
+ }
542
+ }
543
+
544
+ async function loadMembers() {
545
+ const orgId = state.orgs.selectedOrgId;
546
+ if (!orgId) return;
547
+
548
+ const role = document.getElementById('members-role')?.value?.trim();
549
+ const status = document.getElementById('members-status')?.value?.trim();
550
+ const email = document.getElementById('members-email')?.value?.trim();
551
+
552
+ const subtitle = document.getElementById('members-subtitle');
553
+ const tbody = document.getElementById('members-tbody');
554
+ if (subtitle) subtitle.textContent = 'Loading...';
555
+ if (tbody) tbody.innerHTML = '';
556
+
557
+ try {
558
+ const url = `${API_BASE}${ORGS_ADMIN_PATH}/${encodeURIComponent(orgId)}/members${qs({
559
+ role: role || undefined,
560
+ status: status || undefined,
561
+ email: email || undefined,
562
+ limit: state.members.limit,
563
+ offset: state.members.offset,
564
+ })}`;
565
+ const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
566
+ const data = await res.json();
567
+
568
+ if (!res.ok) {
569
+ showToast(data?.error || 'Failed to load members', 'error');
570
+ if (subtitle) subtitle.textContent = 'Failed to load.';
571
+ return;
572
+ }
573
+
574
+ const members = Array.isArray(data?.members) ? data.members : [];
575
+ const pagination = data?.pagination || {};
576
+ const total = Number.isFinite(pagination.total) ? pagination.total : 0;
577
+ const usedLimit = Number.isFinite(pagination.limit) ? pagination.limit : state.members.limit;
578
+ const usedOffset = Number.isFinite(pagination.offset) ? pagination.offset : state.members.offset;
579
+
580
+ state.members.limit = usedLimit;
581
+ state.members.offset = usedOffset;
582
+ state.members.total = total;
583
+
584
+ if (subtitle) {
585
+ const from = total === 0 ? 0 : usedOffset + 1;
586
+ const to = Math.min(usedOffset + members.length, total);
587
+ subtitle.textContent = `${from}-${to} of ${total}`;
588
+ }
589
+
590
+ const prevBtn = document.getElementById('btn-members-prev');
591
+ const nextBtn = document.getElementById('btn-members-next');
592
+ if (prevBtn) prevBtn.disabled = usedOffset <= 0;
593
+ if (nextBtn) nextBtn.disabled = usedOffset + usedLimit >= total;
594
+
595
+ if (tbody) {
596
+ if (members.length === 0) {
597
+ tbody.innerHTML = `
598
+ <tr>
599
+ <td class="px-4 py-6 text-sm text-gray-600" colspan="5">No members found.</td>
600
+ </tr>
601
+ `;
602
+ return;
603
+ }
604
+
605
+ tbody.innerHTML = members.map((m) => {
606
+ const userEmail = escapeHtml(m?.user?.email || '');
607
+ const userName = escapeHtml(m?.user?.name || '');
608
+ const roleVal = escapeHtml(m?.role);
609
+ const statusVal = escapeHtml(m?.status);
610
+ const createdAt = escapeHtml(formatDateTime(m?.createdAt));
611
+ const memberId = escapeHtml(m?._id);
612
+
613
+ const roleOptions = ['owner', 'admin', 'member', 'viewer'].map((r) => {
614
+ const selected = String(m?.role) === r ? 'selected' : '';
615
+ return `<option value="${r}" ${selected}>${r}</option>`;
616
+ }).join('');
617
+
618
+ const isOwner = String(m?.role) === 'owner';
619
+
620
+ return `
621
+ <tr>
622
+ <td class="px-4 py-3 text-sm text-gray-900">
623
+ <div class="font-medium">${userEmail}</div>
624
+ <div class="text-xs text-gray-500">${userName}</div>
625
+ <div class="text-xs text-gray-500">memberId: <button class="text-blue-600 hover:text-blue-800" data-copy="${memberId}">copy</button></div>
626
+ </td>
627
+ <td class="px-4 py-3 text-sm text-gray-700">
628
+ <select class="border rounded px-2 py-1" data-member-role="${memberId}" ${isOwner ? 'disabled' : ''}>
629
+ ${roleOptions}
630
+ </select>
631
+ </td>
632
+ <td class="px-4 py-3 text-sm text-gray-700">${statusVal}</td>
633
+ <td class="px-4 py-3 text-sm text-gray-700">${createdAt}</td>
634
+ <td class="px-4 py-3 text-sm text-gray-700 whitespace-nowrap">
635
+ <button class="text-blue-600 hover:text-blue-800 mr-3" data-member-save="${memberId}" ${isOwner ? 'disabled' : ''}>Save</button>
636
+ <button class="text-red-600 hover:text-red-800" data-member-remove="${memberId}" ${isOwner ? 'disabled' : ''}>Remove</button>
637
+ </td>
638
+ </tr>
639
+ `;
640
+ }).join('');
641
+
642
+ tbody.querySelectorAll('[data-copy]').forEach((btn) => {
643
+ btn.addEventListener('click', () => copyText(btn.getAttribute('data-copy') || ''));
644
+ });
645
+
646
+ tbody.querySelectorAll('[data-member-save]').forEach((btn) => {
647
+ btn.addEventListener('click', async () => {
648
+ const memberId = btn.getAttribute('data-member-save');
649
+ const select = tbody.querySelector(`[data-member-role="${CSS.escape(memberId)}"]`);
650
+ const role = select?.value;
651
+ if (!memberId || !role) return;
652
+ if (!confirm(`Update member role to ${role}?`)) return;
653
+
654
+ try {
655
+ const res = await fetch(`${API_BASE}${ORGS_ADMIN_PATH}/${encodeURIComponent(orgId)}/members/${encodeURIComponent(memberId)}`, {
656
+ method: 'PATCH',
657
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
658
+ body: JSON.stringify({ role }),
659
+ });
660
+ const data = await res.json();
661
+ if (!res.ok) {
662
+ showToast(data?.error || 'Failed to update role', 'error');
663
+ return;
664
+ }
665
+ showToast('Member updated', 'success');
666
+ await Promise.all([loadMembers(), loadSelectedOrg()]);
667
+ } catch (e) {
668
+ showToast(e.message || 'Failed to update role', 'error');
669
+ }
670
+ });
671
+ });
672
+
673
+ tbody.querySelectorAll('[data-member-remove]').forEach((btn) => {
674
+ btn.addEventListener('click', async () => {
675
+ const memberId = btn.getAttribute('data-member-remove');
676
+ if (!memberId) return;
677
+ if (!confirm('Remove this member?')) return;
678
+
679
+ try {
680
+ const res = await fetch(`${API_BASE}${ORGS_ADMIN_PATH}/${encodeURIComponent(orgId)}/members/${encodeURIComponent(memberId)}`, {
681
+ method: 'DELETE',
682
+ headers: { 'Accept': 'application/json' },
683
+ });
684
+ const data = await res.json();
685
+ if (!res.ok) {
686
+ showToast(data?.error || 'Failed to remove member', 'error');
687
+ return;
688
+ }
689
+ showToast('Member removed', 'success');
690
+ await Promise.all([loadMembers(), loadSelectedOrg()]);
691
+ } catch (e) {
692
+ showToast(e.message || 'Failed to remove member', 'error');
693
+ }
694
+ });
695
+ });
696
+ }
697
+ } catch (e) {
698
+ showToast(e.message || 'Failed to load members', 'error');
699
+ if (subtitle) subtitle.textContent = 'Failed to load.';
700
+ }
701
+ }
702
+
703
+ async function loadInvites() {
704
+ const orgId = state.orgs.selectedOrgId;
705
+ if (!orgId) return;
706
+
707
+ const status = document.getElementById('invites-status')?.value?.trim();
708
+ const email = document.getElementById('invites-email')?.value?.trim();
709
+ const limit = parseInt(document.getElementById('invites-limit')?.value || '50', 10);
710
+
711
+ state.invites.limit = Number.isFinite(limit) ? limit : state.invites.limit;
712
+
713
+ const subtitle = document.getElementById('invites-subtitle');
714
+ const tbody = document.getElementById('invites-tbody');
715
+ if (subtitle) subtitle.textContent = 'Loading...';
716
+ if (tbody) tbody.innerHTML = '';
717
+
718
+ try {
719
+ const url = `${API_BASE}${ORGS_ADMIN_PATH}/${encodeURIComponent(orgId)}/invites${qs({
720
+ status: status || undefined,
721
+ email: email || undefined,
722
+ limit: state.invites.limit,
723
+ offset: state.invites.offset,
724
+ })}`;
725
+ const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
726
+ const data = await res.json();
727
+
728
+ if (!res.ok) {
729
+ showToast(data?.error || 'Failed to load invites', 'error');
730
+ if (subtitle) subtitle.textContent = 'Failed to load.';
731
+ return;
732
+ }
733
+
734
+ const invites = Array.isArray(data?.invites) ? data.invites : [];
735
+ const pagination = data?.pagination || {};
736
+ const total = Number.isFinite(pagination.total) ? pagination.total : 0;
737
+ const usedLimit = Number.isFinite(pagination.limit) ? pagination.limit : state.invites.limit;
738
+ const usedOffset = Number.isFinite(pagination.offset) ? pagination.offset : state.invites.offset;
739
+
740
+ state.invites.limit = usedLimit;
741
+ state.invites.offset = usedOffset;
742
+ state.invites.total = total;
743
+
744
+ if (subtitle) {
745
+ const from = total === 0 ? 0 : usedOffset + 1;
746
+ const to = Math.min(usedOffset + invites.length, total);
747
+ subtitle.textContent = `${from}-${to} of ${total}`;
748
+ }
749
+
750
+ const prevBtn = document.getElementById('btn-invites-prev');
751
+ const nextBtn = document.getElementById('btn-invites-next');
752
+ if (prevBtn) prevBtn.disabled = usedOffset <= 0;
753
+ if (nextBtn) nextBtn.disabled = usedOffset + usedLimit >= total;
754
+
755
+ if (tbody) {
756
+ if (invites.length === 0) {
757
+ tbody.innerHTML = `
758
+ <tr>
759
+ <td class="px-4 py-6 text-sm text-gray-600" colspan="6">No invites found.</td>
760
+ </tr>
761
+ `;
762
+ return;
763
+ }
764
+
765
+ tbody.innerHTML = invites.map((i) => {
766
+ const inviteId = escapeHtml(i?._id);
767
+ const emailCell = escapeHtml(i?.email);
768
+ const roleCell = escapeHtml(i?.role);
769
+ const statusCell = escapeHtml(i?.status);
770
+ const expiresAt = escapeHtml(formatDateTime(i?.expiresAt));
771
+ const createdAt = escapeHtml(formatDateTime(i?.createdAt));
772
+
773
+ const isPending = String(i?.status) === 'pending';
774
+
775
+ return `
776
+ <tr>
777
+ <td class="px-4 py-3 text-sm text-gray-900">
778
+ <div class="font-medium">${emailCell}</div>
779
+ <div class="text-xs text-gray-500">inviteId: <button class="text-blue-600 hover:text-blue-800" data-copy="${inviteId}">copy</button></div>
780
+ </td>
781
+ <td class="px-4 py-3 text-sm text-gray-700">${roleCell}</td>
782
+ <td class="px-4 py-3 text-sm text-gray-700">${statusCell}</td>
783
+ <td class="px-4 py-3 text-sm text-gray-700">${expiresAt}</td>
784
+ <td class="px-4 py-3 text-sm text-gray-700">${createdAt}</td>
785
+ <td class="px-4 py-3 text-sm text-gray-700 whitespace-nowrap">
786
+ <button class="text-blue-600 hover:text-blue-800 mr-3" data-invite-resend="${inviteId}" ${isPending ? '' : 'disabled'}>Resend</button>
787
+ <button class="text-red-600 hover:text-red-800" data-invite-revoke="${inviteId}" ${isPending ? '' : 'disabled'}>Revoke</button>
788
+ </td>
789
+ </tr>
790
+ `;
791
+ }).join('');
792
+
793
+ tbody.querySelectorAll('[data-copy]').forEach((btn) => {
794
+ btn.addEventListener('click', () => copyText(btn.getAttribute('data-copy') || ''));
795
+ });
796
+
797
+ tbody.querySelectorAll('[data-invite-revoke]').forEach((btn) => {
798
+ btn.addEventListener('click', async () => {
799
+ const inviteId = btn.getAttribute('data-invite-revoke');
800
+ if (!inviteId) return;
801
+ if (!confirm('Revoke this invite?')) return;
802
+
803
+ try {
804
+ const res = await fetch(`${API_BASE}${ORGS_ADMIN_PATH}/${encodeURIComponent(orgId)}/invites/${encodeURIComponent(inviteId)}`, {
805
+ method: 'DELETE',
806
+ headers: { 'Accept': 'application/json' },
807
+ });
808
+ const data = await res.json();
809
+ if (!res.ok) {
810
+ showToast(data?.error || 'Failed to revoke invite', 'error');
811
+ return;
812
+ }
813
+ showToast('Invite revoked', 'success');
814
+ await Promise.all([loadInvites(), loadSelectedOrg()]);
815
+ } catch (e) {
816
+ showToast(e.message || 'Failed to revoke invite', 'error');
817
+ }
818
+ });
819
+ });
820
+
821
+ tbody.querySelectorAll('[data-invite-resend]').forEach((btn) => {
822
+ btn.addEventListener('click', async () => {
823
+ const inviteId = btn.getAttribute('data-invite-resend');
824
+ if (!inviteId) return;
825
+ if (!confirm('Resend this invite? (rotates token)')) return;
826
+
827
+ try {
828
+ const res = await fetch(`${API_BASE}${ORGS_ADMIN_PATH}/${encodeURIComponent(orgId)}/invites/${encodeURIComponent(inviteId)}/resend`, {
829
+ method: 'POST',
830
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
831
+ body: JSON.stringify({}),
832
+ });
833
+ const data = await res.json();
834
+ if (!res.ok) {
835
+ showToast(data?.error || 'Failed to resend invite', 'error');
836
+ return;
837
+ }
838
+ showToast('Invite resent', 'success');
839
+ await Promise.all([loadInvites(), loadSelectedOrg()]);
840
+ } catch (e) {
841
+ showToast(e.message || 'Failed to resend invite', 'error');
842
+ }
843
+ });
844
+ });
845
+ }
846
+ } catch (e) {
847
+ showToast(e.message || 'Failed to load invites', 'error');
848
+ if (subtitle) subtitle.textContent = 'Failed to load.';
849
+ }
850
+ }
851
+
852
+ async function createInvite() {
853
+ const orgId = state.orgs.selectedOrgId;
854
+ if (!orgId) return;
855
+
856
+ const email = document.getElementById('invite-email')?.value?.trim();
857
+ const role = document.getElementById('invite-role')?.value;
858
+ const expiresInDays = parseInt(document.getElementById('invite-expires')?.value || '7', 10);
859
+
860
+ if (!email) {
861
+ showToast('Email is required', 'error');
862
+ return;
863
+ }
864
+
865
+ try {
866
+ const res = await fetch(`${API_BASE}${ORGS_ADMIN_PATH}/${encodeURIComponent(orgId)}/invites`, {
867
+ method: 'POST',
868
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
869
+ body: JSON.stringify({ email, role, expiresInDays }),
870
+ });
871
+ const data = await res.json();
872
+ if (!res.ok) {
873
+ showToast(data?.error || 'Failed to create invite', 'error');
874
+ return;
875
+ }
876
+ showToast('Invite created', 'success');
877
+ document.getElementById('invite-email').value = '';
878
+ await Promise.all([loadInvites(), loadSelectedOrg()]);
879
+ } catch (e) {
880
+ showToast(e.message || 'Failed to create invite', 'error');
881
+ }
882
+ }
883
+
884
+ function bindEvents() {
885
+ setActiveTab('members');
886
+
887
+ const tabMembers = document.getElementById('tab-members');
888
+ const tabInvites = document.getElementById('tab-invites');
889
+ if (tabMembers) tabMembers.onclick = () => setActiveTab('members');
890
+ if (tabInvites) tabInvites.onclick = () => setActiveTab('invites');
891
+
892
+ const refreshOrgsBtn = document.getElementById('btn-orgs-refresh');
893
+ if (refreshOrgsBtn) refreshOrgsBtn.onclick = () => loadOrgs();
894
+
895
+ const applyOrgsBtn = document.getElementById('btn-orgs-apply');
896
+ if (applyOrgsBtn) applyOrgsBtn.onclick = () => { state.orgs.offset = 0; loadOrgs(); };
897
+
898
+ const resetOrgsBtn = document.getElementById('btn-orgs-reset');
899
+ if (resetOrgsBtn) resetOrgsBtn.onclick = () => {
900
+ document.getElementById('orgs-q').value = '';
901
+ document.getElementById('orgs-status').value = '';
902
+ document.getElementById('orgs-owner').value = '';
903
+ document.getElementById('orgs-limit').value = '25';
904
+ state.orgs.offset = 0;
905
+ loadOrgs();
906
+ };
907
+
908
+ const prevOrgsBtn = document.getElementById('btn-orgs-prev');
909
+ if (prevOrgsBtn) prevOrgsBtn.onclick = () => { state.orgs.offset = Math.max(0, state.orgs.offset - state.orgs.limit); loadOrgs(); };
910
+
911
+ const nextOrgsBtn = document.getElementById('btn-orgs-next');
912
+ if (nextOrgsBtn) nextOrgsBtn.onclick = () => { state.orgs.offset = Math.max(0, state.orgs.offset + state.orgs.limit); loadOrgs(); };
913
+
914
+ const selectedRefreshBtn = document.getElementById('btn-selected-refresh');
915
+ if (selectedRefreshBtn) selectedRefreshBtn.onclick = () => Promise.all([loadSelectedOrg(), loadMembers(), loadInvites()]);
916
+
917
+ const membersRefreshBtn = document.getElementById('btn-members-refresh');
918
+ if (membersRefreshBtn) membersRefreshBtn.onclick = () => loadMembers();
919
+
920
+ const membersApplyBtn = document.getElementById('btn-members-apply');
921
+ if (membersApplyBtn) membersApplyBtn.onclick = () => { state.members.offset = 0; loadMembers(); };
922
+
923
+ const membersResetBtn = document.getElementById('btn-members-reset');
924
+ if (membersResetBtn) membersResetBtn.onclick = () => {
925
+ document.getElementById('members-role').value = '';
926
+ document.getElementById('members-status').value = '';
927
+ document.getElementById('members-email').value = '';
928
+ state.members.offset = 0;
929
+ loadMembers();
930
+ };
931
+
932
+ const membersPrevBtn = document.getElementById('btn-members-prev');
933
+ if (membersPrevBtn) membersPrevBtn.onclick = () => { state.members.offset = Math.max(0, state.members.offset - state.members.limit); loadMembers(); };
934
+
935
+ const membersNextBtn = document.getElementById('btn-members-next');
936
+ if (membersNextBtn) membersNextBtn.onclick = () => { state.members.offset = Math.max(0, state.members.offset + state.members.limit); loadMembers(); };
937
+
938
+ const invitesRefreshBtn = document.getElementById('btn-invites-refresh');
939
+ if (invitesRefreshBtn) invitesRefreshBtn.onclick = () => loadInvites();
940
+
941
+ const invitesApplyBtn = document.getElementById('btn-invites-apply');
942
+ if (invitesApplyBtn) invitesApplyBtn.onclick = () => { state.invites.offset = 0; loadInvites(); };
943
+
944
+ const invitesResetBtn = document.getElementById('btn-invites-reset');
945
+ if (invitesResetBtn) invitesResetBtn.onclick = () => {
946
+ document.getElementById('invites-status').value = '';
947
+ document.getElementById('invites-email').value = '';
948
+ document.getElementById('invites-limit').value = '50';
949
+ state.invites.offset = 0;
950
+ loadInvites();
951
+ };
952
+
953
+ const invitesPrevBtn = document.getElementById('btn-invites-prev');
954
+ if (invitesPrevBtn) invitesPrevBtn.onclick = () => { state.invites.offset = Math.max(0, state.invites.offset - state.invites.limit); loadInvites(); };
955
+
956
+ const invitesNextBtn = document.getElementById('btn-invites-next');
957
+ if (invitesNextBtn) invitesNextBtn.onclick = () => { state.invites.offset = Math.max(0, state.invites.offset + state.invites.limit); loadInvites(); };
958
+
959
+ const inviteCreateBtn = document.getElementById('btn-invite-create');
960
+ if (inviteCreateBtn) inviteCreateBtn.onclick = () => createInvite();
961
+
962
+ const inviteClearBtn = document.getElementById('btn-invite-clear');
963
+ if (inviteClearBtn) inviteClearBtn.onclick = () => {
964
+ document.getElementById('invite-email').value = '';
965
+ document.getElementById('invite-role').value = 'member';
966
+ document.getElementById('invite-expires').value = '7';
967
+ };
968
+
969
+ setSelectedControlsEnabled(false);
970
+ }
971
+
972
+ bindEvents();
973
+ loadOrgs();
974
+ </script>
975
+ <script>
976
+ window.addEventListener("keydown", (e) => {
977
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
978
+ e.preventDefault();
979
+ window.parent.postMessage({ type: "keydown", ctrlK: true }, "*");
980
+ }
981
+ });
982
+ </script>
983
+ </body>
984
+ </html>