portal-mojo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. package/src/ui/ui.tsx +45 -0
@@ -0,0 +1,163 @@
1
+ // Users — the full-fidelity ModelTable proof (B2): columns + filters +
2
+ // presets from B1, now with selection + batch actions (real POST_SAVE_ACTIONS
3
+ // fanned out with allSettled), column chooser, persisted view state,
4
+ // auto-refresh, expandable rows, recency grouping, and server-side export.
5
+ import { useQueryClient } from '@tanstack/react-query';
6
+ import { useCan, type User } from '../../../client';
7
+ import {
8
+ Badge, fmt, formModal, groupByRecency, modal, toast, ModelTable,
9
+ type BatchAction, type Column, type FilterDef,
10
+ } from '../../../ui';
11
+ import { USER_MANAGE_PERMISSIONS, UserModel } from './models';
12
+ import { UserDetail } from './UserDetail';
13
+
14
+ const COLUMNS: Column<User>[] = [
15
+ {
16
+ // The identity column is the table's anchor — locked in the chooser.
17
+ key: 'display_name', label: 'User', hideable: false, render: (u) => (
18
+ <div className="cell-user">
19
+ <span className="cell-avatar">{fmt.initials(u.display_name || u.username)}</span>
20
+ <span>
21
+ <span className="cell-name">
22
+ {u.display_name || u.username}
23
+ {u.is_online && <span className="online-dot" title="Online" />}
24
+ </span>
25
+ <span className="cell-sub">{u.email}</span>
26
+ </span>
27
+ </div>
28
+ ),
29
+ },
30
+ { key: 'username', label: 'Username', render: (u) => <span className="dim">{u.username}</span> },
31
+ {
32
+ key: 'is_superuser', label: 'Access', render: (u) =>
33
+ u.is_superuser ? <Badge tone="primary">superuser</Badge>
34
+ : Object.keys(u.permissions ?? {}).length > 0 ? <Badge tone="warning">staff</Badge>
35
+ : <span className="dim">—</span>,
36
+ },
37
+ { key: 'is_active', label: 'Status', render: (u) => <Badge>{u.is_active ? 'Active' : 'Inactive'}</Badge> },
38
+ {
39
+ key: 'is_email_verified', label: 'Verified', align: 'center', render: (u) =>
40
+ u.is_email_verified ? <i className="bi bi-patch-check-fill text-ok" /> : <span className="dim">—</span>,
41
+ },
42
+ { key: 'last_login', label: 'Last Login', render: (u) => fmt.relative(u.last_login) },
43
+ { key: 'last_activity', label: 'Last Activity', render: (u) => fmt.relative(u.last_activity, '—') },
44
+ ];
45
+
46
+ const FILTERS: FilterDef[] = [
47
+ { key: 'display_name', label: 'Name', type: 'text', placeholder: 'Contains…' },
48
+ { key: 'email', label: 'Email', type: 'text', placeholder: 'Contains…' },
49
+ { key: 'username', label: 'Username', type: 'text', placeholder: 'Contains…' },
50
+ { key: 'is_active', label: 'Status', type: 'boolean', trueLabel: 'Active', falseLabel: 'Inactive' },
51
+ { key: 'is_email_verified', label: 'Email verified', type: 'boolean', trueLabel: 'Verified', falseLabel: 'Unverified' },
52
+ { key: 'is_phone_verified', label: 'Phone verified', type: 'boolean', trueLabel: 'Verified', falseLabel: 'Unverified' },
53
+ { key: 'is_superuser', label: 'Superuser', type: 'boolean', trueLabel: 'Yes', falseLabel: 'No' },
54
+ { key: 'last_login', label: 'Last login', type: 'daterange' },
55
+ { key: 'last_activity', label: 'Last activity', type: 'daterange' },
56
+ ];
57
+
58
+ /** Expanded-row detail: the quick facts a hover-curious admin wants. */
59
+ function UserExpand({ u }: { u: User }) {
60
+ const perms = Object.entries(u.permissions ?? {}).filter(([, v]) => v === true || v === 1).map(([k]) => k);
61
+ return (
62
+ <div className="expand-grid">
63
+ <div>
64
+ <div className="eyebrow">Contact</div>
65
+ <div>{u.email}</div>
66
+ <div className="dim">{u.phone_number ?? 'No phone'}</div>
67
+ </div>
68
+ <div>
69
+ <div className="eyebrow">Verification</div>
70
+ <div>Email {u.is_email_verified ? <Badge tone="success">Verified</Badge> : <Badge tone="muted">Unverified</Badge>}</div>
71
+ <div>Phone {u.is_phone_verified ? <Badge tone="success">Verified</Badge> : <Badge tone="muted">Unverified</Badge>}</div>
72
+ </div>
73
+ <div>
74
+ <div className="eyebrow">Permissions</div>
75
+ <div className="chip-row">
76
+ {u.is_superuser && <Badge tone="primary">superuser</Badge>}
77
+ {perms.map((k) => <Badge key={k} tone="info">{k}</Badge>)}
78
+ {!u.is_superuser && perms.length === 0 && <span className="dim-italic">none</span>}
79
+ </div>
80
+ </div>
81
+ </div>
82
+ );
83
+ }
84
+
85
+ export function UsersPage({ onOpenGroup }: { onOpenGroup?: (groupId: number) => void } = {}) {
86
+ const qc = useQueryClient();
87
+ const { can: canManage } = useCan(USER_MANAGE_PERMISSIONS);
88
+ const save = UserModel.useSave();
89
+ const disable = UserModel.useAction('disable');
90
+ const reactivate = UserModel.useAction('reactivate');
91
+
92
+ const addUser = async () => {
93
+ const form = UserModel.forms.create!;
94
+ const data = await formModal(form);
95
+ if (!data) return;
96
+ const changes = Object.fromEntries(Object.entries(data).filter(([, v]) => v !== ''));
97
+ try {
98
+ const created = await save.mutateAsync({ id: null, changes });
99
+ toast.success(`${created.display_name || created.username} created`);
100
+ } catch (err) {
101
+ toast.error(err instanceof Error ? err.message : 'Create failed');
102
+ }
103
+ };
104
+
105
+ const openUser = (u: User) => {
106
+ // Prefetch through the shared cache key: the modal's useOne attaches
107
+ // to this in-flight request instead of issuing its own.
108
+ void UserModel.fetchOne(qc, u.id).catch(() => {});
109
+ void modal.detail((close) => <UserDetail id={u.id} onClose={() => close(null)} onOpenGroup={onOpenGroup} />);
110
+ };
111
+
112
+ // Batch actions run the model's REAL POST_SAVE_ACTIONS per row; the
113
+ // reason for a batch disable is collected once and shared.
114
+ const BATCH: BatchAction<User>[] = [
115
+ {
116
+ key: 'disable', label: 'Disable', icon: 'bi-slash-circle', danger: true, confirm: false,
117
+ eligible: (row) => row.is_active,
118
+ prepare: async () => {
119
+ const data = await formModal(UserModel.forms.disable!);
120
+ if (!data) return null;
121
+ const payload: Record<string, unknown> = { reason: data.reason };
122
+ if (data.note) payload.note = data.note;
123
+ return payload;
124
+ },
125
+ run: (row, payload) => disable.mutateAsync({ id: row.id, payload }),
126
+ },
127
+ {
128
+ key: 'reactivate', label: 'Reactivate', icon: 'bi-arrow-counterclockwise',
129
+ eligible: (row) => !row.is_active,
130
+ run: (row) => reactivate.mutateAsync({ id: row.id }),
131
+ },
132
+ ];
133
+
134
+ return (
135
+ <ModelTable<User>
136
+ model={UserModel}
137
+ eyebrow="Account"
138
+ title="Users"
139
+ searchPlaceholder="Search name, username, email…"
140
+ columns={COLUMNS}
141
+ filters={FILTERS}
142
+ presets={[
143
+ { key: 'all', label: 'All', params: {} },
144
+ { key: 'active', label: 'Active', params: { is_active: 'true' } },
145
+ { key: 'superusers', label: 'Superusers', params: { is_superuser: 'true' } },
146
+ { key: 'never', label: 'Never signed in', params: { last_login__isnull: 'true' } },
147
+ { key: 'inactive', label: 'Inactive', params: { is_active: 'false' } },
148
+ ]}
149
+ defaultSort="-last_activity"
150
+ selectable={canManage}
151
+ batchActions={canManage ? BATCH : []}
152
+ columnChooser
153
+ persistState
154
+ exportFormats={['csv', 'json']}
155
+ autoRefresh={30}
156
+ rowExpand={(u) => <UserExpand u={u} />}
157
+ {...groupByRecency<User>('last_activity')}
158
+ onRowClick={openUser}
159
+ addLabel="Add User"
160
+ onAdd={canManage ? addUser : undefined}
161
+ />
162
+ );
163
+ }
@@ -0,0 +1,27 @@
1
+ import type { AdminSection } from '../../index';
2
+ import { UserDetail } from './UserDetail';
3
+ import { UsersPage } from './UsersPage';
4
+ import { USER_VIEW_PERMISSIONS } from './models';
5
+
6
+ export * from './models';
7
+ export * from './UsersPage';
8
+ export * from './UserDetail';
9
+ export * from './sections/permission-catalog';
10
+
11
+ export const USERS_ADMIN_SECTION: AdminSection = {
12
+ id: 'users',
13
+ basePath: '',
14
+ title: 'Users',
15
+ icon: 'bi-people',
16
+ navigationGroup: 'identity-access',
17
+ permissions: USER_VIEW_PERMISSIONS,
18
+ routes: [{
19
+ path: 'users',
20
+ label: 'Users',
21
+ component: UsersPage,
22
+ permissions: USER_VIEW_PERMISSIONS,
23
+ }],
24
+ };
25
+
26
+ /** Useful to custom routers that open a User detail outside ModelTable. */
27
+ export const USER_ADMIN_SURFACES = { UsersPage, UserDetail } as const;
@@ -0,0 +1,232 @@
1
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
2
+ import {
3
+ defineModel, mojoCall, withFreshAuth,
4
+ sanitizeAvatarRelation, type User,
5
+ } from '../../../client';
6
+ // ONE defineModel per endpoint (#1291). The device / device-location /
7
+ // login-event models are DEFINED in admin/security/devices/models.ts — the
8
+ // fleet-wide owner of those three endpoints — and re-exported here so
9
+ // UserDetail's sections keep their existing import surface. Two definitions
10
+ // would mean two cache keys and therefore a double fetch of the same rows.
11
+ import {
12
+ LoginEventModel, UserDeviceLocationModel, UserDeviceModel,
13
+ USER_DEVICE_VIEW_PERMS, LOGIN_EVENT_VIEW_PERMS,
14
+ } from '../../security/devices/models';
15
+ export {
16
+ PUSH_DEVICE_VIEW_PERMISSIONS as USER_PUSH_DEVICE_PERMISSIONS,
17
+ PushDeviceModel,
18
+ } from '../../messaging/push/models';
19
+ export type { PushDeviceRow } from '../../messaging/push/models';
20
+
21
+ export {
22
+ LoginEventModel,
23
+ UserDeviceLocationModel,
24
+ UserDeviceModel,
25
+ /** Historical alias — UserDetail's Devices section imports `DeviceModel`. */
26
+ UserDeviceModel as DeviceModel,
27
+ };
28
+ export type {
29
+ LoginEventRow,
30
+ UAInfo,
31
+ UserBasicRef,
32
+ UserDeviceLocationRow,
33
+ UserDeviceRow,
34
+ /** Historical alias for the row type. */
35
+ UserDeviceRow as DeviceRow,
36
+ } from '../../security/devices/models';
37
+
38
+ /** Global Admin gates. `sys.` prevents active-member authority leaking in. */
39
+ export const USER_VIEW_PERMISSIONS = ['sys.users', 'sys.view_users', 'sys.manage_users'];
40
+ export const USER_MANAGE_PERMISSIONS = ['sys.users', 'sys.manage_users'];
41
+ /** Same clause as the canonical `USER_DEVICE_VIEW_PERMS`, kept as the name
42
+ * UserDetail already imports. Both derive from `UserDevice.VIEW_PERMS`. */
43
+ export const USER_DEVICE_PERMISSIONS = USER_DEVICE_VIEW_PERMS;
44
+ export const USER_LOGIN_PERMISSIONS = LOGIN_EVENT_VIEW_PERMS;
45
+ export const USER_LOG_PERMISSIONS = ['sys.view_logs', 'sys.manage_logs', 'sys.security'];
46
+ export const USER_EVENT_PERMISSIONS = ['sys.view_security', 'sys.security'];
47
+
48
+ /** Default user graph plus fields that do not ride the list graph. */
49
+ export type UserRow = User & {
50
+ requires_mfa?: boolean;
51
+ has_passkey?: boolean;
52
+ };
53
+
54
+ /** UserModel's independent cache boundary (lists, detail, saves, actions). */
55
+ export function sanitizeUserRow(row: UserRow): UserRow {
56
+ return { ...row, avatar: sanitizeAvatarRelation(row.avatar) };
57
+ }
58
+
59
+ export const UserModel = defineModel<UserRow>({
60
+ name: 'user',
61
+ endpoint: '/api/user',
62
+ sanitizeRow: sanitizeUserRow,
63
+ permissions: {
64
+ view: USER_VIEW_PERMISSIONS,
65
+ manage: USER_MANAGE_PERMISSIONS,
66
+ create: USER_MANAGE_PERMISSIONS,
67
+ },
68
+ forms: {
69
+ create: {
70
+ title: 'Add user',
71
+ submitText: 'Create',
72
+ fields: [
73
+ { name: 'display_name', type: 'text', label: 'Display name', required: true, placeholder: 'Jane Cooper' },
74
+ { name: 'email', type: 'email', label: 'Email', required: true, placeholder: 'jane@example.com' },
75
+ { name: 'phone_number', type: 'tel', label: 'Phone', columns: 6 },
76
+ { name: 'username', type: 'text', label: 'Username', columns: 6, placeholder: 'Defaults from email', help: 'Leave blank to derive from email' },
77
+ ],
78
+ },
79
+ disable: {
80
+ title: 'Disable user',
81
+ submitText: 'Disable',
82
+ fields: [
83
+ {
84
+ name: 'reason', type: 'select', label: 'Reason', required: true, options: [
85
+ { value: 'admin', label: 'Admin — block / policy violation' },
86
+ { value: 'abuse', label: 'Abuse — banned' },
87
+ ],
88
+ },
89
+ { name: 'note', type: 'textarea', label: 'Note', placeholder: 'Optional note about why this user is being disabled.' },
90
+ ],
91
+ },
92
+ },
93
+ actions: {
94
+ change_username: { permissions: USER_MANAGE_PERMISSIONS, response: 'payload' },
95
+ disable: { permissions: USER_MANAGE_PERMISSIONS },
96
+ reactivate: { permissions: USER_MANAGE_PERMISSIONS },
97
+ send_invite: { permissions: USER_MANAGE_PERMISSIONS },
98
+ revoke_sessions: { permissions: USER_MANAGE_PERMISSIONS, response: 'payload' },
99
+ disable_totp: { permissions: USER_MANAGE_PERMISSIONS, response: 'payload' },
100
+ },
101
+ });
102
+
103
+ /** `/api/account/api_keys` never serializes its owner or signing material. */
104
+ export interface ApiKeyRow {
105
+ id: number;
106
+ label: string;
107
+ allowed_ips: string[];
108
+ expires: number;
109
+ is_active: boolean;
110
+ last_used: number | null;
111
+ created: number;
112
+ }
113
+
114
+ function sanitizeApiKeyRow(row: ApiKeyRow): ApiKeyRow {
115
+ const safe = { ...row } as ApiKeyRow & Record<string, unknown>;
116
+ for (const key of ['token', 'jti', 'auth_key', 'secret', 'token_hash']) delete safe[key];
117
+ return safe;
118
+ }
119
+
120
+ export const ApiKeyModel = defineModel<ApiKeyRow>({
121
+ name: 'api_key',
122
+ endpoint: '/api/account/api_keys',
123
+ permissions: { manageOthers: USER_MANAGE_PERMISSIONS },
124
+ sanitizeRow: sanitizeApiKeyRow,
125
+ forms: {
126
+ generate: {
127
+ title: 'Generate API key',
128
+ submitText: 'Generate',
129
+ fields: [
130
+ { name: 'label', type: 'text', label: 'Label', required: true, placeholder: 'e.g. CI/CD pipeline, Mobile app' },
131
+ {
132
+ name: 'allowed_ips', type: 'text', label: 'Allowed IPs',
133
+ placeholder: 'e.g. 203.0.113.0/24, 10.0.0.1',
134
+ help: 'Optional. Comma-separated IPs or CIDR ranges; empty allows any.',
135
+ },
136
+ {
137
+ name: 'expire_days', type: 'select', label: 'Expires in', options: [
138
+ { value: '30', label: '30 days' },
139
+ { value: '60', label: '60 days' },
140
+ { value: '90', label: '90 days' },
141
+ { value: '180', label: '180 days' },
142
+ { value: '360', label: '360 days (max)' },
143
+ ],
144
+ },
145
+ ],
146
+ },
147
+ },
148
+ actions: { revoke: { response: 'payload' } },
149
+ });
150
+
151
+ export interface GenerateUserApiKeyVariables {
152
+ changes: Record<string, unknown>;
153
+ /** Receives the token while the mutation is still pending. */
154
+ onToken?: (token: string) => void | Promise<void>;
155
+ }
156
+
157
+ interface GeneratedKeyResponse {
158
+ id?: number;
159
+ expires?: number;
160
+ token?: string;
161
+ jti?: string;
162
+ auth_key?: string;
163
+ secret?: string;
164
+ }
165
+
166
+ export interface GeneratedKeyReceipt {
167
+ id: number | null;
168
+ expires: number | null;
169
+ }
170
+
171
+ /**
172
+ * Caller-only key generation. The raw response is split before mutation
173
+ * resolution, so TanStack MutationCache receives only non-secret metadata.
174
+ */
175
+ export function useGenerateUserApiKey() {
176
+ const qc = useQueryClient();
177
+ return useMutation<GeneratedKeyReceipt, Error, GenerateUserApiKeyVariables>({
178
+ mutationFn: async ({ changes, onToken }) => {
179
+ const response = await withFreshAuth(() => mojoCall('/api/auth/generate_api_key', {
180
+ method: 'POST',
181
+ body: changes,
182
+ }));
183
+ const payload = response.data as GeneratedKeyResponse | undefined;
184
+ const created: GeneratedKeyResponse = payload && typeof payload === 'object' ? { ...payload } : {};
185
+ let token = typeof created.token === 'string' && created.token ? created.token : null;
186
+ try {
187
+ if (token && onToken) await onToken(token);
188
+ } finally {
189
+ token = null;
190
+ for (const key of ['token', 'jti', 'auth_key', 'secret'] as const) delete created[key];
191
+ }
192
+ return {
193
+ id: typeof created.id === 'number' ? created.id : null,
194
+ expires: typeof created.expires === 'number' ? created.expires : null,
195
+ };
196
+ },
197
+ onSuccess: () => { void qc.invalidateQueries({ queryKey: ApiKeyModel.keys.root }); },
198
+ });
199
+ }
200
+
201
+ // LoginEventRow / LoginEventModel are re-exported from the canonical module
202
+ // above. The local copy carried a phantom `event_type?: string` field that
203
+ // `UserLoginEvent` has never had — see docs/admin-devices-geoip.md.
204
+
205
+ export interface PasskeyRow {
206
+ id: number;
207
+ friendly_name: string | null;
208
+ credential_id: string;
209
+ rp_id: string;
210
+ is_enabled: boolean;
211
+ sign_count: number;
212
+ transports: string | null;
213
+ aaguid: string | null;
214
+ last_used: number | null;
215
+ created: number;
216
+ }
217
+ export const PasskeyModel = defineModel<PasskeyRow>({
218
+ name: 'passkey', endpoint: '/api/account/passkeys',
219
+ permissions: { view: USER_MANAGE_PERMISSIONS, manage: USER_MANAGE_PERMISSIONS },
220
+ });
221
+
222
+ export interface OAuthConnectionRow {
223
+ id: number;
224
+ provider: string;
225
+ email: string | null;
226
+ is_active: boolean;
227
+ created: number;
228
+ }
229
+ export const OAuthConnectionModel = defineModel<OAuthConnectionRow>({
230
+ name: 'oauth_connection', endpoint: '/api/account/oauth_connection',
231
+ permissions: { view: USER_MANAGE_PERMISSIONS, manage: USER_MANAGE_PERMISSIONS },
232
+ });
@@ -0,0 +1,119 @@
1
+ // API Keys — UserApiKeysSection port (read in full 2026-08-05): the user's
2
+ // key list + caller-only "Generate key" reveal + revoke.
3
+ //
4
+ // Wire (live-measured):
5
+ // · list — GET /api/account/api_keys?user=<id> (label/allowed_ips/expires/
6
+ // is_active/last_used/created; the owner FK filters but never serializes)
7
+ // · generate — caller-only POST /api/auth/generate_api_key. Another user's
8
+ // detail renders no Generate action because no admin-targetable route exists.
9
+ // · revoke — the `revoke` POST_SAVE_ACTION (POST <id> {revoke:{}} → its
10
+ // own {status:true} payload). Deviation from web-mojo's DELETE: the live
11
+ // model is CAN_DELETE=false (DELETE answers 403, measured) — the action
12
+ // IS the kill switch.
13
+ import { Badge, Eyebrow, fmt, formModal, modal, toast } from '../../../../ui';
14
+ import { showSecretDialog } from '../../../credentials';
15
+ import { ApiKeyModel, useGenerateUserApiKey, type UserRow } from '../models';
16
+
17
+ export function ApiKeysSection({ user, canManage, isSelf }: { user: UserRow; canManage: boolean; isSelf: boolean }) {
18
+ const { data, isPending } = ApiKeyModel.useList({ user: user.id, size: 25, sort: '-id' });
19
+ const revoke = ApiKeyModel.useAction('revoke');
20
+ const generateKey = useGenerateUserApiKey();
21
+ const rows = data?.rows ?? [];
22
+
23
+ const generate = async () => {
24
+ const form = ApiKeyModel.forms.generate!;
25
+ const dataIn = await formModal({
26
+ ...form,
27
+ title: `Generate API key for ${user.display_name || user.email || user.username}`,
28
+ });
29
+ if (!dataIn) return;
30
+ const body: Record<string, unknown> = {
31
+ label: dataIn.label,
32
+ expire_days: parseInt(String(dataIn.expire_days || '90'), 10),
33
+ };
34
+ const ips = String(dataIn.allowed_ips ?? '').trim();
35
+ if (ips) body.allowed_ips = ips.split(',').map((s) => s.trim()).filter(Boolean);
36
+ try {
37
+ await generateKey.mutateAsync({
38
+ changes: body,
39
+ onToken: async (token) => {
40
+ await showSecretDialog({
41
+ title: 'API key created',
42
+ intro: <><b>{String(dataIn.label || 'Unlabeled key')}</b> is ready.</>,
43
+ warning: 'Save this token now. It will not be shown again.',
44
+ secret: token,
45
+ ariaLabel: 'Generated API key',
46
+ });
47
+ },
48
+ });
49
+ toast.success('API key generated');
50
+ } catch (err) {
51
+ toast.error(err instanceof Error ? err.message : 'Failed to generate API key');
52
+ }
53
+ };
54
+
55
+ const revokeKey = async (id: number, label: string) => {
56
+ const ok = await modal.confirm({
57
+ title: 'Revoke API key',
58
+ message: <>Revoke <b>{label || 'this API key'}</b>? Any applications using it will lose access immediately.</>,
59
+ confirmText: 'Revoke',
60
+ danger: true,
61
+ });
62
+ if (!ok) return;
63
+ try {
64
+ await revoke.mutateAsync({ id });
65
+ toast.success('API key revoked');
66
+ } catch (err) {
67
+ toast.error(err instanceof Error ? err.message : 'Failed to revoke API key');
68
+ }
69
+ };
70
+
71
+ const nowSec = Math.floor(Date.now() / 1000);
72
+
73
+ return (
74
+ <>
75
+ <Eyebrow>
76
+ API keys
77
+ {isSelf && (
78
+ <button className="btn btn-primary btn-compact us-eyebrow-action" onClick={() => void generate()}>
79
+ <i className="bi bi-plus-lg" /> Generate key
80
+ </button>
81
+ )}
82
+ </Eyebrow>
83
+
84
+ {isPending && <p className="dim">Loading…</p>}
85
+ {!isPending && rows.length === 0 && (
86
+ <div className="us-empty">
87
+ <i className="bi bi-key" />
88
+ <div>No API keys for this user.</div>
89
+ </div>
90
+ )}
91
+ {rows.map((k) => {
92
+ const expired = k.expires != null && k.expires < nowSec;
93
+ return (
94
+ <div key={k.id} className="us-key-row">
95
+ <div className="us-row-icon"><i className="bi bi-key" /></div>
96
+ <div className="us-row-info">
97
+ <div className="us-row-title">{k.label || 'Unlabeled key'}</div>
98
+ <div className="us-row-meta">
99
+ Created {fmt.date(k.created)} · Expires {fmt.date(k.expires, 'never')} · Last used {fmt.relative(k.last_used, 'never')}
100
+ {' · '}
101
+ {k.allowed_ips.length > 0 ? <>IPs <code>{k.allowed_ips.join(', ')}</code></> : 'Any IP'}
102
+ </div>
103
+ </div>
104
+ <div className="us-row-actions">
105
+ <Badge tone={!k.is_active ? 'muted' : expired ? 'warning' : 'success'}>
106
+ {!k.is_active ? 'Revoked' : expired ? 'Expired' : 'Active'}
107
+ </Badge>
108
+ {canManage && k.is_active && (
109
+ <button className="btn btn-compact us-danger" onClick={() => void revokeKey(k.id, k.label)}>
110
+ Revoke
111
+ </button>
112
+ )}
113
+ </div>
114
+ </div>
115
+ );
116
+ })}
117
+ </>
118
+ );
119
+ }