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,72 @@
1
+ // OAuth section — AdminConnectedSection port (read in full 2026-08-05):
2
+ // the user's linked OAuth providers with admin unlink. The same list powers
3
+ // the Profile card's "manage linked accounts" modal (source Phase 3 shared
4
+ // exactly this list between the section and the modal).
5
+ //
6
+ // Wire: GET /api/account/oauth_connection?user=<id> → rows
7
+ // {id, provider, email, is_active, created} (default graph, measured in
8
+ // django-mojo oauth.py); DELETE /api/account/oauth_connection/<id> unlinks.
9
+ import { modal, toast, fmt, Eyebrow } from '../../../../ui';
10
+ import { OAuthConnectionModel, type UserRow } from '../models';
11
+ import { providerIcon } from './shared';
12
+
13
+ export function OAuthConnectionList({ userId, canManage = true }: { userId: number; canManage?: boolean }) {
14
+ const { data, isPending } = OAuthConnectionModel.useList({ user: userId, size: 25, sort: '-created' });
15
+ const del = OAuthConnectionModel.useDelete();
16
+ const rows = data?.rows ?? [];
17
+
18
+ const unlink = async (id: number, provider: string) => {
19
+ const ok = await modal.confirm({
20
+ title: 'Unlink account',
21
+ message: <>Unlink <b>{provider}</b> for this user?</>,
22
+ confirmText: 'Unlink',
23
+ danger: true,
24
+ });
25
+ if (!ok) return;
26
+ try {
27
+ await del.mutateAsync({ id });
28
+ toast.success(`${provider} account unlinked`);
29
+ } catch (err) {
30
+ toast.error(err instanceof Error ? err.message : 'Failed to unlink account');
31
+ }
32
+ };
33
+
34
+ if (isPending) return <p className="dim">Loading…</p>;
35
+ if (rows.length === 0) {
36
+ return (
37
+ <div className="us-empty">
38
+ <i className="bi bi-plug" />
39
+ <div>No connected accounts</div>
40
+ </div>
41
+ );
42
+ }
43
+ return (
44
+ <>
45
+ {rows.map((c) => (
46
+ <div key={c.id} className="us-oauth-row">
47
+ <div className="us-row-icon"><i className={`bi ${providerIcon(c.provider)}`} /></div>
48
+ <div className="us-row-info">
49
+ <div className="us-row-title us-cap">{c.provider}</div>
50
+ <div className="us-row-meta">
51
+ {c.email ?? <span className="dim-italic">no email</span>} · Connected {fmt.relative(c.created)}
52
+ </div>
53
+ </div>
54
+ {canManage && <div className="us-row-actions">
55
+ <button className="btn btn-compact us-danger" onClick={() => void unlink(c.id, c.provider)}>
56
+ <i className="bi bi-x-lg" /> Unlink
57
+ </button>
58
+ </div>}
59
+ </div>
60
+ ))}
61
+ </>
62
+ );
63
+ }
64
+
65
+ export function OAuthSection({ user, canManage }: { user: UserRow; canManage: boolean }) {
66
+ return (
67
+ <>
68
+ <Eyebrow>Linked accounts</Eyebrow>
69
+ <OAuthConnectionList userId={user.id} canManage={canManage} />
70
+ </>
71
+ );
72
+ }
@@ -0,0 +1,109 @@
1
+ // Overview — UserOverviewSection port (read in full 2026-08-05): KPI cards
2
+ // (Devices · Last login · Active sessions · Groups) fed by the SHARED
3
+ // queries UserDetail owns, the identity flat rows, and the recent-activity
4
+ // merge feed (logins + incidents + object logs + activity → top 5 by ts).
5
+ //
6
+ // KPI values are TOTAL row counts from the API envelopes (`count`), never
7
+ // the fetched-page length — the source read collection.meta.count for
8
+ // exactly this reason.
9
+ import { Eyebrow, FlatRow, MetricCard, Timeline, fmt, type TimelineItem } from '../../../../ui';
10
+ import type { UserRow } from '../models';
11
+ import { accountType, LOG_LEVEL_TONE, toMs } from './shared';
12
+ import type { SharedUserQueries } from './queries';
13
+
14
+ export function OverviewSection({ user, shared, onOpenGroup }: {
15
+ user: UserRow;
16
+ shared: SharedUserQueries;
17
+ onOpenGroup?: (groupId: number) => void;
18
+ }) {
19
+ const deviceTotal = (shared.devices.data?.count ?? 0) + (shared.pushDevices.data?.count ?? 0);
20
+ const sessionCount = shared.devices.data?.count ?? 0;
21
+ const groupCount = shared.members.data?.count ?? 0;
22
+ const lastLogin = shared.logins.data?.rows[0]?.created ?? user.last_login;
23
+
24
+ // ── Merge feed: 2 logins + 2 incidents + 2 object logs + 1 activity,
25
+ // newest 5 win (UserOverviewSection._buildActivityItems).
26
+ const items: (TimelineItem & { _ts: number | null })[] = [];
27
+ for (const l of shared.logins.data?.rows.slice(0, 2) ?? []) {
28
+ const where = [l.city, l.country_code].filter(Boolean).join(', ');
29
+ items.push({
30
+ _ts: toMs(l.created),
31
+ tone: 'info',
32
+ title: 'Logged in',
33
+ body: (
34
+ <span className="dim">
35
+ {l.ip_address && <code>{l.ip_address}</code>}
36
+ {l.ip_address && where ? ' · ' : ''}
37
+ {where}
38
+ </span>
39
+ ),
40
+ meta: fmt.relative(l.created),
41
+ });
42
+ }
43
+ for (const e of shared.events.data?.rows.slice(0, 2) ?? []) {
44
+ items.push({
45
+ _ts: toMs(e.created),
46
+ tone: 'danger',
47
+ title: e.title || e.category || 'Incident event',
48
+ body: e.category ? <span className="dim">{e.category}</span> : undefined,
49
+ meta: fmt.relative(e.created),
50
+ });
51
+ }
52
+ for (const log of shared.objectLogs.data?.rows.slice(0, 2) ?? []) {
53
+ items.push({
54
+ _ts: toMs(log.created),
55
+ tone: LOG_LEVEL_TONE[(log.level ?? '').toLowerCase()] ?? 'muted',
56
+ title: log.kind || 'Change',
57
+ body: log.log ? <span className="dim">{fmt.truncate(log.log, 80)}</span> : undefined,
58
+ meta: fmt.relative(log.created),
59
+ });
60
+ }
61
+ for (const a of shared.activity.data?.rows.slice(0, 1) ?? []) {
62
+ items.push({
63
+ _ts: toMs(a.created),
64
+ tone: LOG_LEVEL_TONE[(a.level ?? '').toLowerCase()] ?? 'muted',
65
+ title: a.kind || 'Activity',
66
+ body: a.path ? <code className="us-mono-sm">{a.path}</code> : undefined,
67
+ meta: fmt.relative(a.created),
68
+ });
69
+ }
70
+ const feed = items
71
+ .filter((i) => i._ts != null)
72
+ .sort((a, b) => (b._ts ?? 0) - (a._ts ?? 0))
73
+ .slice(0, 5);
74
+
75
+ const org = typeof user.org === 'object' && user.org ? user.org : null;
76
+
77
+ return (
78
+ <>
79
+ <Eyebrow>Account snapshot</Eyebrow>
80
+ <div className="grid gap-3 grid-cols-2 xl:grid-cols-4" style={{ marginBottom: 14 }}>
81
+ <MetricCard label="Devices" value={String(deviceTotal)} />
82
+ <MetricCard label="Last login" value={fmt.relative(lastLogin, '—')} />
83
+ <MetricCard label="Active sessions" value={String(sessionCount)} />
84
+ <MetricCard label="Groups" value={String(groupCount)} />
85
+ </div>
86
+
87
+ <Eyebrow>Identity</Eyebrow>
88
+ <FlatRow label="Display name">{user.display_name ?? <span className="dim">—</span>}</FlatRow>
89
+ <FlatRow label="Email">
90
+ {user.email ? user.email : <span className="dim">—</span>}
91
+ </FlatRow>
92
+ <FlatRow label="Phone">
93
+ {user.phone_number ? <code>{user.phone_number}</code> : <span className="dim">—</span>}
94
+ </FlatRow>
95
+ <FlatRow label="Account type">{accountType(user)}</FlatRow>
96
+ {org && onOpenGroup && (
97
+ <FlatRow label="Organization">
98
+ <a href="#" onClick={(e) => { e.preventDefault(); onOpenGroup(org.id); }}>
99
+ <i className="bi bi-buildings" /> {org.name}
100
+ </a>
101
+ </FlatRow>
102
+ )}
103
+ {org && !onOpenGroup && <FlatRow label="Organization"><i className="bi bi-buildings" /> {org.name}</FlatRow>}
104
+
105
+ <Eyebrow>Recent activity</Eyebrow>
106
+ <Timeline items={feed} emptyText="No recent activity yet." />
107
+ </>
108
+ );
109
+ }
@@ -0,0 +1,35 @@
1
+ // Sys Perms / App Perms — UserPermissionsSection port (read in full
2
+ // 2026-08-05): one autosaving FormView wrapping a single tabset. Flip a
3
+ // switch → FormView batches `{"permissions.<name>": true}` into ONE save →
4
+ // django-mojo dict-MERGES it into the permissions JSONField, so absent keys
5
+ // keep their grants.
6
+ //
7
+ // "Sys Perms" always renders (SYSTEM_PERMISSION_TABS — the framework
8
+ // catalog); "App Perms" renders only when the app registered tabs under
9
+ // USER_APP_PERMS_TABSET via registerFormTabs — the portal's
10
+ // User.registerPermissions equivalent (UserDetail gates the section on the
11
+ // registry being non-empty).
12
+ import { Eyebrow, FormView } from '../../../../ui';
13
+ import { UserModel, type UserRow } from '../models';
14
+ import { SYSTEM_PERMISSION_TABS, USER_APP_PERMS_TABSET } from './permission-catalog';
15
+
16
+ export function SysPermsSection({ user }: { user: UserRow }) {
17
+ return (
18
+ <>
19
+ <Eyebrow>System permissions</Eyebrow>
20
+ <p className="dim" style={{ margin: '0 0 12px' }}>Toggles autosave as soon as you flip them.</p>
21
+ <FormView model={UserModel} row={user} tabs={SYSTEM_PERMISSION_TABS} />
22
+ </>
23
+ );
24
+ }
25
+
26
+ export function AppPermsSection({ user }: { user: UserRow }) {
27
+ return (
28
+ <>
29
+ <Eyebrow>App permissions</Eyebrow>
30
+ <p className="dim" style={{ margin: '0 0 12px' }}>Toggles autosave as soon as you flip them.</p>
31
+ {/* Registry NAME (not a snapshot) so late registrations re-render. */}
32
+ <FormView model={UserModel} row={user} tabs={USER_APP_PERMS_TABSET} />
33
+ </>
34
+ );
35
+ }
@@ -0,0 +1,73 @@
1
+ // Personal — AdminPersonalSection port (read in full 2026-08-05): name
2
+ // fields, DOB, timezone, address. The source's pencil-prompt-per-row grid
3
+ // becomes ONE FormView autosave surface (the C4 house rule: inline autosave,
4
+ // no save buttons); dotted metadata.* names merge server-side so editing the
5
+ // address never clobbers the rest of the blob.
6
+ //
7
+ // Deliberate deviation, backend-grounded: the source's force-verify /
8
+ // unverify DOB buttons are NOT ported — `is_dob_verified` sits in
9
+ // django-mojo's NO_SAVE_FIELDS (user.py RestMeta, read 2026-08-05), so the
10
+ // server silently DROPS the write and the source UI toasts a success that
11
+ // never happened. The verified badge still renders from the row.
12
+ import { Badge, Eyebrow, FlatRow, FormView } from '../../../../ui';
13
+ import type { Field } from '../../../../client';
14
+ import { UserModel, type UserRow } from '../models';
15
+
16
+ const NAME_FIELDS: Field[] = [
17
+ { name: 'display_name', type: 'text', label: 'Display name', columns: 6 },
18
+ { name: 'first_name', type: 'text', label: 'First name', columns: 6 },
19
+ { name: 'last_name', type: 'text', label: 'Last name', columns: 6 },
20
+ ];
21
+
22
+ const DETAIL_FIELDS: Field[] = [
23
+ {
24
+ name: 'dob', type: 'datepicker', label: 'Date of birth', columns: 6,
25
+ outputFormat: 'date', // DateField on the wire: canonical YYYY-MM-DD
26
+ },
27
+ { name: 'metadata.timezone', type: 'timezone', label: 'Timezone', columns: 6 },
28
+ ];
29
+
30
+ const ADDRESS_FIELDS: Field[] = [
31
+ { name: 'metadata.street', type: 'text', label: 'Street', columns: 12, placeholder: '123 Main St' },
32
+ { name: 'metadata.city', type: 'text', label: 'City', columns: 6 },
33
+ { name: 'metadata.state', type: 'text', label: 'State / Province', columns: 6 },
34
+ { name: 'metadata.zip', type: 'text', label: 'Zip / Postal code', columns: 6 },
35
+ { name: 'metadata.country', type: 'text', label: 'Country', columns: 6 },
36
+ ];
37
+
38
+ export function PersonalSection({ user }: { user: UserRow }) {
39
+ return (
40
+ <>
41
+ <Eyebrow>Name</Eyebrow>
42
+ <FormView model={UserModel} row={user} fields={NAME_FIELDS} />
43
+
44
+ <Eyebrow>Details</Eyebrow>
45
+ <FlatRow label="DOB status">
46
+ {user.dob
47
+ ? (
48
+ <>
49
+ <code>{user.dob}</code>{' '}
50
+ <Badge tone={user.is_dob_verified ? 'success' : 'warning'}>
51
+ {user.is_dob_verified ? 'Verified' : 'Unverified'}
52
+ </Badge>
53
+ </>
54
+ )
55
+ : <span className="dim-italic">Not set</span>}
56
+ </FlatRow>
57
+ <FormView model={UserModel} row={user} fields={DETAIL_FIELDS} />
58
+
59
+ <Eyebrow>Address</Eyebrow>
60
+ <FlatRow label="On file">
61
+ {addressSummary(user) || <span className="dim-italic">Not set</span>}
62
+ </FlatRow>
63
+ <FormView model={UserModel} row={user} fields={ADDRESS_FIELDS} />
64
+ </>
65
+ );
66
+ }
67
+
68
+ function addressSummary(user: UserRow): string {
69
+ const meta = (user.metadata ?? {}) as Record<string, unknown>;
70
+ return [meta.street, meta.city, meta.state, meta.zip, meta.country]
71
+ .filter((v): v is string => typeof v === 'string' && v !== '')
72
+ .join(', ');
73
+ }
@@ -0,0 +1,175 @@
1
+ // Profile — UserProfileSection port (read in full 2026-08-05): identity
2
+ // cards with the admin credential actions, the Account summary rows (edit
3
+ // pencil → Edit Account modal), and Linked accounts (SSO providers + manage
4
+ // passkeys). Non-admin viewers see read-only cards + the send flows —
5
+ // contact-keyed actions stay ungated (the backend trusts the email/SMS
6
+ // recipient, not the JWT).
7
+ import { Badge, Eyebrow, FlatRow, SecurityItem, fmt } from '../../../../ui';
8
+ import { OAuthConnectionModel, type UserRow } from '../models';
9
+ import type { UserAdminActions } from './actions';
10
+ import { accountType, providerIcon } from './shared';
11
+
12
+ export function ProfileSection({ user, actions, canManage, canCredentials }: {
13
+ user: UserRow;
14
+ actions: UserAdminActions;
15
+ canManage: boolean;
16
+ canCredentials: boolean;
17
+ }) {
18
+ const { data: connections } = OAuthConnectionModel.useList(
19
+ { user: user.id, size: 25, sort: '-created' },
20
+ { enabled: canCredentials },
21
+ );
22
+ const meta = (user.metadata ?? {}) as Record<string, unknown>;
23
+ const timezone = typeof meta.timezone === 'string' ? meta.timezone : null;
24
+ const hasEmail = Boolean(user.email);
25
+ const hasPhone = Boolean(user.phone_number);
26
+ const resetContact = user.email || user.phone_number || null;
27
+
28
+ return (
29
+ <>
30
+ <Eyebrow>Personal</Eyebrow>
31
+ <FlatRow
32
+ label="Display name"
33
+ action={canManage ? () => void actions.editDisplayName() : undefined}
34
+ >
35
+ {user.display_name ?? <span className="dim">—</span>}
36
+ </FlatRow>
37
+
38
+ <Eyebrow>Identity</Eyebrow>
39
+
40
+ {/* Username card */}
41
+ <SecurityItem icon="bi-at" title="Username" desc="">
42
+ <code className="us-id-value">{user.username || '—'}</code>
43
+ {canManage && (
44
+ <button className="btn-icon btn-icon-sm" title="Edit username" aria-label="Edit username" onClick={() => void actions.editUsername()}>
45
+ <i className="bi bi-pencil" />
46
+ </button>
47
+ )}
48
+ </SecurityItem>
49
+
50
+ {/* Email card — verify state + admin verification overrides */}
51
+ <SecurityItem
52
+ icon="bi-envelope"
53
+ title="Email"
54
+ desc={user.email ?? ''}
55
+ >
56
+ {hasEmail
57
+ ? <Badge tone={user.is_email_verified ? 'success' : 'warning'}>{user.is_email_verified ? 'Verified' : 'Unverified'}</Badge>
58
+ : <span className="dim-italic">Not set</span>}
59
+ {canManage && hasEmail && user.is_email_verified && (
60
+ <button className="btn-icon btn-icon-sm" title="Mark as unverified" aria-label="Mark email as unverified" onClick={() => void actions.setVerification('is_email_verified', false, 'Email')}>
61
+ <i className="bi bi-x-circle" />
62
+ </button>
63
+ )}
64
+ {canManage && hasEmail && !user.is_email_verified && (
65
+ <>
66
+ <button className="btn-icon btn-icon-sm" title="Send verification email" aria-label="Send verification email" onClick={() => void actions.sendVerificationEmail()}>
67
+ <i className="bi bi-envelope-arrow-up" />
68
+ </button>
69
+ <button className="btn-icon btn-icon-sm" title="Force verify" aria-label="Force verify email" onClick={() => void actions.setVerification('is_email_verified', true, 'Email')}>
70
+ <i className="bi bi-patch-check" />
71
+ </button>
72
+ </>
73
+ )}
74
+ {canManage && (
75
+ <button className="btn-icon btn-icon-sm" title="Edit email" aria-label="Edit email" onClick={() => void actions.editEmail()}>
76
+ <i className="bi bi-pencil" />
77
+ </button>
78
+ )}
79
+ </SecurityItem>
80
+
81
+ {/* Phone card — set / clear / verify overrides */}
82
+ <SecurityItem
83
+ icon="bi-telephone"
84
+ title="Phone"
85
+ desc={user.phone_number ?? ''}
86
+ >
87
+ {hasPhone
88
+ ? <Badge tone={user.is_phone_verified ? 'success' : 'warning'}>{user.is_phone_verified ? 'Verified' : 'Unverified'}</Badge>
89
+ : <span className="dim-italic">Not set</span>}
90
+ {canManage && hasPhone && (
91
+ <>
92
+ {user.is_phone_verified ? (
93
+ <button className="btn-icon btn-icon-sm" title="Mark as unverified" aria-label="Mark phone as unverified" onClick={() => void actions.setVerification('is_phone_verified', false, 'Phone')}>
94
+ <i className="bi bi-x-circle" />
95
+ </button>
96
+ ) : (
97
+ <button className="btn-icon btn-icon-sm" title="Force verify" aria-label="Force verify phone" onClick={() => void actions.setVerification('is_phone_verified', true, 'Phone')}>
98
+ <i className="bi bi-patch-check" />
99
+ </button>
100
+ )}
101
+ <button className="btn-icon btn-icon-sm" title="Clear phone" aria-label="Clear phone" onClick={() => void actions.clearPhone()}>
102
+ <i className="bi bi-x-lg" />
103
+ </button>
104
+ <button className="btn-icon btn-icon-sm" title="Edit phone" aria-label="Edit phone" onClick={() => void actions.editPhone()}>
105
+ <i className="bi bi-pencil" />
106
+ </button>
107
+ </>
108
+ )}
109
+ {canManage && !hasPhone && (
110
+ <button className="btn-icon btn-icon-sm" title="Set phone" aria-label="Set phone" onClick={() => void actions.setPhone()}>
111
+ <i className="bi bi-plus-lg" />
112
+ </button>
113
+ )}
114
+ </SecurityItem>
115
+
116
+ {/* Password card — send-reset flow (email OR SMS code) */}
117
+ <SecurityItem
118
+ icon="bi-key"
119
+ title="Password"
120
+ desc={resetContact ? `Send a password reset link or code to ${resetContact}` : 'No email or phone on file'}
121
+ >
122
+ {canManage && resetContact && (
123
+ <button className="btn btn-compact" onClick={() => void actions.sendPasswordReset()}>
124
+ <i className="bi bi-envelope" /> Send reset…
125
+ </button>
126
+ )}
127
+ </SecurityItem>
128
+
129
+ <Eyebrow>
130
+ Account
131
+ {canManage && (
132
+ <button className="btn-icon btn-icon-sm us-eyebrow-action" title="Edit account" aria-label="Edit account" onClick={() => void actions.editAccount()}>
133
+ <i className="bi bi-pencil" />
134
+ </button>
135
+ )}
136
+ </Eyebrow>
137
+ <FlatRow label="Account type">{accountType(user)}</FlatRow>
138
+ <FlatRow label="Status">
139
+ <Badge tone={user.is_active ? 'success' : 'muted'}>{user.is_active ? 'Active' : 'Inactive'}</Badge>
140
+ </FlatRow>
141
+ <FlatRow label="MFA">
142
+ <Badge tone={user.requires_mfa ? 'success' : 'muted'}>{user.requires_mfa ? 'Required' : 'Not required'}</Badge>
143
+ </FlatRow>
144
+ <FlatRow label="Last login">{fmt.relative(user.last_login, '—')}</FlatRow>
145
+ <FlatRow label="Last seen">{fmt.relative(user.last_activity, '—')}</FlatRow>
146
+ <FlatRow label="Timezone">
147
+ {timezone ?? <span className="dim">—</span>}
148
+ </FlatRow>
149
+
150
+ <Eyebrow>
151
+ Linked accounts
152
+ {canCredentials && <button className="btn-icon btn-icon-sm us-eyebrow-action" title="Manage linked accounts" aria-label="Manage linked accounts" onClick={() => actions.openLinkedAccountsModal()}>
153
+ <i className="bi bi-pencil" />
154
+ </button>}
155
+ </Eyebrow>
156
+ <FlatRow label="SSO providers">
157
+ {(connections?.rows.length ?? 0) === 0
158
+ ? <span className="dim-italic">No linked accounts</span>
159
+ : (
160
+ <span className="chip-row">
161
+ {connections!.rows.map((c) => (
162
+ <span key={c.id} className="chip chip-muted">
163
+ <i className={`bi ${providerIcon(c.provider)}`} /> {c.provider}{c.email ? ` · ${c.email}` : ''}
164
+ </span>
165
+ ))}
166
+ </span>
167
+ )}
168
+ </FlatRow>
169
+ <FlatRow label="2-factor">
170
+ <Badge tone={user.requires_mfa ? 'success' : 'muted'}>{user.requires_mfa ? 'Required' : 'Not required'}</Badge>
171
+ {canCredentials && <a href="#" className="us-inline-link" onClick={(e) => { e.preventDefault(); actions.openPasskeysModal(); }}>Manage passkeys</a>}
172
+ </FlatRow>
173
+ </>
174
+ );
175
+ }
@@ -0,0 +1,123 @@
1
+ // Security — AdminSecuritySection port (read in full 2026-08-05):
2
+ // Authentication rows (send reset / magic link / admin set-password), MFA
3
+ // rows (requirement toggle, authenticator state, SMS eligibility, passkeys),
4
+ // Sessions (revoke all — the ArmedButton treatment kept from the earlier
5
+ // port). Every action routes through the canonical handlers in actions.tsx
6
+ // (the source's Phase 4 dedup: one handler per action across kebab + cards +
7
+ // this section).
8
+ //
9
+ // Deviations, backend-grounded (django-mojo read 2026-08-05):
10
+ // · TOTP enrollment state: NO user graph serializes has_totp — the source
11
+ // getter fell back to "Not enrolled" against this backend and we keep
12
+ // that exact degrade (fields stay optional; a future graph extra lights
13
+ // it up). The disable affordance appears only when the state IS known.
14
+ // · Recovery codes: the source's GET /api/user/<id>/totp/recovery-codes
15
+ // route does not exist (codes are self-only at /api/account/totp/…) —
16
+ // row omitted rather than shipped against a phantom endpoint.
17
+ import { Badge, Eyebrow, SecurityItem, ArmedButton, fmt } from '../../../../ui';
18
+ import { PasskeyModel, type UserRow } from '../models';
19
+ import type { UserAdminActions } from './actions';
20
+
21
+ /** has_totp / totp_enabled ride no current graph — optional by design. */
22
+ type UserSecurityRow = UserRow & { has_totp?: boolean; totp_enabled?: boolean };
23
+
24
+ export function SecuritySection({ user, actions, canManage, canPasskeys }: {
25
+ user: UserRow;
26
+ actions: UserAdminActions;
27
+ canManage: boolean;
28
+ canPasskeys: boolean;
29
+ }) {
30
+ const su = user as UserSecurityRow;
31
+ const totpEnabled = Boolean(su.has_totp || su.totp_enabled);
32
+ const smsEligible = Boolean(user.phone_number && user.is_phone_verified);
33
+ const { data: passkeys } = PasskeyModel.useList(
34
+ { user: user.id, size: 25, sort: '-created' },
35
+ { enabled: canPasskeys },
36
+ );
37
+ const passkeyCount = passkeys?.count ?? 0;
38
+ const hasPasskey = user.has_passkey === true || passkeyCount > 0;
39
+
40
+ return (
41
+ <>
42
+ <Eyebrow>Authentication</Eyebrow>
43
+
44
+ {canManage && <SecurityItem icon="bi-envelope" title="Send password reset" desc={`Send a reset link or code to ${user.email || user.phone_number || 'this user'}`}>
45
+ <button className="btn btn-compact" onClick={() => void actions.sendPasswordReset()}>Send…</button>
46
+ </SecurityItem>}
47
+
48
+ {canManage && <SecurityItem icon="bi-link-45deg" title="Send magic login link" desc={`Send a one-click login link to ${user.email || user.phone_number || 'this user'}`}>
49
+ <button className="btn btn-compact" onClick={() => void actions.sendMagicLink()}>Send…</button>
50
+ </SecurityItem>}
51
+
52
+ {canManage && (
53
+ <SecurityItem icon="bi-key" title="Set password" desc="Set a new password directly for this user">
54
+ <button className="btn btn-compact" onClick={() => void actions.changePassword()}>Set…</button>
55
+ </SecurityItem>
56
+ )}
57
+
58
+ <Eyebrow>Multi-factor authentication</Eyebrow>
59
+
60
+ {canManage && (
61
+ <SecurityItem
62
+ icon="bi-shield-lock"
63
+ title="MFA requirement"
64
+ desc={user.requires_mfa ? 'User is required to use MFA' : 'MFA is not required for this user'}
65
+ >
66
+ <Badge tone={user.requires_mfa ? 'success' : 'muted'}>{user.requires_mfa ? 'Required' : 'Not required'}</Badge>
67
+ <button className="btn btn-compact" onClick={() => void actions.toggleMfaRequirement()}>
68
+ {user.requires_mfa ? 'Disable' : 'Enable'}
69
+ </button>
70
+ </SecurityItem>
71
+ )}
72
+
73
+ <SecurityItem
74
+ icon="bi-key"
75
+ title="Authenticator (TOTP)"
76
+ desc={totpEnabled
77
+ ? 'User has an authenticator app enrolled'
78
+ : 'No enrolled authenticator on record'}
79
+ >
80
+ <Badge tone={totpEnabled ? 'success' : 'muted'}>{totpEnabled ? 'Enrolled' : 'Not enrolled'}</Badge>
81
+ {canManage && totpEnabled && (
82
+ <button className="btn btn-compact" onClick={() => void actions.disableTotp()}>Disable</button>
83
+ )}
84
+ </SecurityItem>
85
+
86
+ <SecurityItem
87
+ icon="bi-phone"
88
+ title="SMS verification"
89
+ desc={smsEligible
90
+ ? 'Verified phone available — SMS-based MFA can be used'
91
+ : 'No verified phone on file — SMS-based MFA unavailable'}
92
+ >
93
+ <Badge tone={smsEligible ? 'success' : 'muted'}>{smsEligible ? 'Eligible' : 'Unavailable'}</Badge>
94
+ </SecurityItem>
95
+
96
+ {canPasskeys && <SecurityItem icon="bi-fingerprint" title="Passkeys" desc="View and manage registered passkeys">
97
+ {hasPasskey && <Badge tone="success">{passkeyCount > 0 ? `${passkeyCount} registered` : 'Registered'}</Badge>}
98
+ <button className="btn btn-compact" onClick={() => actions.openPasskeysModal()}>
99
+ Manage <i className="bi bi-chevron-right" />
100
+ </button>
101
+ </SecurityItem>}
102
+
103
+ {canManage && (
104
+ <>
105
+ <Eyebrow>Sessions</Eyebrow>
106
+ <SecurityItem
107
+ icon="bi-box-arrow-right"
108
+ title="Revoke all sessions"
109
+ desc={`Force sign-out from all devices · last seen ${fmt.relative(user.last_activity, 'never')}`}
110
+ >
111
+ {/* Irreversible + no input → the armed-button idiom. */}
112
+ <ArmedButton
113
+ label="Revoke all sessions"
114
+ armedLabel="Click again — signs out every device"
115
+ icon="bi-x-octagon"
116
+ onConfirm={actions.revokeAllSessions}
117
+ />
118
+ </SecurityItem>
119
+ </>
120
+ )}
121
+ </>
122
+ );
123
+ }