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,752 @@
1
+ // user-sections/actions — the canonical UserView action handlers, one per
2
+ // action across every surface that offers it (kebab + Profile identity cards
3
+ // + Security rows), the source's Phase 4 dedup carried over.
4
+ //
5
+ // Send-flow endpoints are the django-mojo routes verified in source
6
+ // (account/rest/user.py, read 2026-08-05): auth/magic/send + auth/forgot
7
+ // accept {email|phone_number} with method/channel switches, ALWAYS answer
8
+ // success (anti-enumeration) and strict-rate-limit at 5/IP/5min — a 429
9
+ // surfaces via the error toast like any failure. auth/email/verify/send is
10
+ // the public, admin-targetable verification sender (NOT the JWT-scoped
11
+ // auth/verify/email/send).
12
+ import { useEffect, useState } from 'react';
13
+ import { useQueryClient } from '@tanstack/react-query';
14
+ import { mojoCall, withFreshAuth, type Field } from '../../../../client';
15
+ import {
16
+ Badge, ImageField, PasswordStrengthMeter, fmt, formModal, modal, toast,
17
+ type FileFieldOwnerResult,
18
+ } from '../../../../ui';
19
+ import { PasskeyModel, UserModel, type UserRow } from '../models';
20
+ import { openGroupDetail, useAdminCaller } from './shared';
21
+ import { OAuthConnectionList } from './OAuthSection';
22
+
23
+ /** One-field prompt modal (the source's Modal.prompt pencils). */
24
+ async function promptField(title: string, field: Field, initial: string): Promise<string | null> {
25
+ const data = await formModal({ title, fields: [{ ...field, required: true }], initial: { [field.name]: initial } });
26
+ const value = data?.[field.name];
27
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
28
+ }
29
+
30
+ export interface UserAdminActions {
31
+ sendMagicLink: () => Promise<void>;
32
+ sendPasswordReset: () => Promise<void>;
33
+ sendVerificationEmail: () => Promise<void>;
34
+ setVerification: (field: 'is_email_verified' | 'is_phone_verified', value: boolean, label: string) => Promise<void>;
35
+ editDisplayName: () => Promise<void>;
36
+ editUsername: () => Promise<void>;
37
+ editEmail: () => Promise<void>;
38
+ editPhone: () => Promise<void>;
39
+ setPhone: () => Promise<void>;
40
+ clearPhone: () => Promise<void>;
41
+ editUser: () => Promise<void>;
42
+ editAccount: () => Promise<void>;
43
+ changePassword: () => Promise<void>;
44
+ resetMfa: () => Promise<void>;
45
+ toggleMfaRequirement: () => Promise<void>;
46
+ disableTotp: () => Promise<void>;
47
+ clearRateLimit: () => Promise<void>;
48
+ revokeAllSessions: () => Promise<void>;
49
+ resendInvite: () => Promise<void>;
50
+ disableUser: () => Promise<void>;
51
+ reactivateUser: () => Promise<void>;
52
+ openAvatarModal: () => void;
53
+ openPasskeysModal: () => void;
54
+ openLinkedAccountsModal: () => void;
55
+ openOrg: () => Promise<void>;
56
+ }
57
+
58
+ export function useUserAdminActions(
59
+ user: UserRow,
60
+ opts: { throttleRetry?: number; refetchThrottle?: () => void; onOpenGroup?: (groupId: number) => void } = {},
61
+ ): UserAdminActions {
62
+ const qc = useQueryClient();
63
+ const save = UserModel.useSave();
64
+ const disable = UserModel.useAction('disable');
65
+ const reactivate = UserModel.useAction('reactivate');
66
+ const sendInvite = UserModel.useAction('send_invite');
67
+ const revokeSessions = UserModel.useAction('revoke_sessions');
68
+ const changeUsername = UserModel.useAction('change_username');
69
+
70
+ const fail = (err: unknown, fallback: string) =>
71
+ toast.error(err instanceof Error ? err.message : fallback);
72
+
73
+ /** model.save + toast + fallthrough (the source's _savePersonalField). */
74
+ const saveFields = async (changes: Record<string, unknown>, label: string) => {
75
+ try {
76
+ await save.mutateAsync({ id: user.id, changes });
77
+ toast.success(`${label} updated`);
78
+ } catch (err) {
79
+ fail(err, `Failed to update ${label.toLowerCase()}`);
80
+ }
81
+ };
82
+
83
+ // ── Send flows ────────────────────────────────────────────────────
84
+
85
+ const sendMagicLink = async () => {
86
+ const email = user.email || null;
87
+ const phone = user.phone_number || null;
88
+ if (!email && !phone) {
89
+ toast.error('User has no email or phone on file');
90
+ return;
91
+ }
92
+ // Both channels on file → let the admin choose; one channel → plain
93
+ // confirm (no fake choice).
94
+ let channel = email ? 'email' : 'sms';
95
+ if (email && phone) {
96
+ const data = await formModal({
97
+ title: 'Send magic login link',
98
+ submitText: 'Send',
99
+ fields: [{
100
+ name: 'channel', type: 'select', label: 'Send via', required: true,
101
+ options: [
102
+ { value: 'email', label: `Email link to ${email}` },
103
+ { value: 'sms', label: `Text link to ${phone}` },
104
+ ],
105
+ }],
106
+ initial: { channel: 'email' },
107
+ });
108
+ if (!data) return;
109
+ channel = String(data.channel || 'email');
110
+ } else {
111
+ const ok = await modal.confirm({
112
+ title: 'Send magic login link',
113
+ message: <>Send a magic login link to <b>{email ?? phone}</b>?</>,
114
+ confirmText: 'Send',
115
+ });
116
+ if (!ok) return;
117
+ }
118
+ // Identifier: email when present, else phone — the backend looks the
119
+ // account up from either; method:'sms' routes delivery.
120
+ const body: Record<string, unknown> = email ? { email } : { phone_number: phone };
121
+ if (channel === 'sms') body.method = 'sms';
122
+ try {
123
+ await mojoCall('/api/auth/magic/send', { method: 'POST', body });
124
+ toast.success('Magic login link sent');
125
+ } catch (err) {
126
+ fail(err, 'Failed to send magic link');
127
+ }
128
+ };
129
+
130
+ const sendPasswordReset = async () => {
131
+ const email = user.email || null;
132
+ const phone = user.phone_number || null;
133
+ if (!email && !phone) {
134
+ toast.error('User has no email or phone on file');
135
+ return;
136
+ }
137
+ // Backend delivery variants: email link, email code, SMS code. There
138
+ // is NO SMS-link variant (link mode is email-only).
139
+ const options: { value: string; label: string }[] = [];
140
+ if (email) {
141
+ options.push({ value: 'link', label: `Email a reset link to ${email}` });
142
+ options.push({ value: 'code', label: `Email a 6-digit code to ${email}` });
143
+ }
144
+ if (phone) {
145
+ options.push({ value: 'sms-code', label: `Text a 6-digit code to ${phone}` });
146
+ }
147
+ let delivery = options[0]!.value;
148
+ if (options.length > 1) {
149
+ const data = await formModal({
150
+ title: 'Send password reset',
151
+ submitText: 'Send',
152
+ fields: [{ name: 'delivery', type: 'select', label: 'Delivery', required: true, options }],
153
+ initial: { delivery },
154
+ });
155
+ if (!data) return;
156
+ delivery = String(data.delivery || delivery);
157
+ } else {
158
+ const ok = await modal.confirm({
159
+ title: 'Send password reset',
160
+ message: <>{options[0]!.label}?</>,
161
+ confirmText: 'Send',
162
+ });
163
+ if (!ok) return;
164
+ }
165
+ const body: Record<string, unknown> = email ? { email } : { phone_number: phone };
166
+ if (delivery === 'link') {
167
+ body.method = 'link';
168
+ } else {
169
+ body.method = 'code';
170
+ if (delivery === 'sms-code') body.channel = 'sms';
171
+ }
172
+ try {
173
+ await mojoCall('/api/auth/forgot', { method: 'POST', body });
174
+ toast.success('Password reset sent');
175
+ } catch (err) {
176
+ fail(err, 'Failed to send password reset');
177
+ }
178
+ };
179
+
180
+ const sendVerificationEmail = async () => {
181
+ if (!user.email) {
182
+ toast.error('User has no email on file');
183
+ return;
184
+ }
185
+ const ok = await modal.confirm({
186
+ title: 'Send verification email',
187
+ message: <>Send a verification link to <b>{user.email}</b>?</>,
188
+ confirmText: 'Send',
189
+ });
190
+ if (!ok) return;
191
+ try {
192
+ await mojoCall('/api/auth/email/verify/send', { method: 'POST', body: { email: user.email } });
193
+ toast.success('Verification email sent');
194
+ } catch (err) {
195
+ fail(err, 'Failed to send verification email');
196
+ }
197
+ };
198
+
199
+ // ── Verification overrides (admin force-verify / unverify) ────────
200
+
201
+ const setVerification = async (field: 'is_email_verified' | 'is_phone_verified', value: boolean, label: string) => {
202
+ const target = field === 'is_email_verified' ? user.email : user.phone_number;
203
+ if (!target) {
204
+ toast.error(`User has no ${label.toLowerCase()} on file`);
205
+ return;
206
+ }
207
+ const verb = value ? 'Mark as verified' : 'Mark as unverified';
208
+ const ok = await modal.confirm({
209
+ title: `${verb} — ${label}`,
210
+ message: <>{verb.toLowerCase().replace(/^m/, 'M')}: <b>{target}</b>?</>,
211
+ confirmText: value ? 'Verify' : 'Unverify',
212
+ });
213
+ if (!ok) return;
214
+ try {
215
+ await save.mutateAsync({ id: user.id, changes: { [field]: value } });
216
+ toast.success(`${label} ${value ? 'marked verified' : 'marked unverified'}`);
217
+ } catch (err) {
218
+ fail(err, 'Failed to update verification');
219
+ }
220
+ };
221
+
222
+ // ── Identity pencils ──────────────────────────────────────────────
223
+
224
+ const editDisplayName = async () => {
225
+ const v = await promptField('Edit display name', { name: 'display_name', type: 'text', label: 'Display name' }, user.display_name ?? '');
226
+ if (v != null) await saveFields({ display_name: v }, 'Display name');
227
+ };
228
+ const editUsername = async () => {
229
+ const v = await promptField('Edit username', { name: 'username', type: 'text', label: 'Username' }, user.username ?? '');
230
+ if (v == null) return;
231
+ try {
232
+ await changeUsername.mutateAsync({ id: user.id, payload: { username: v } });
233
+ toast.success('Username updated');
234
+ } catch (err) {
235
+ fail(err, 'Failed to update username');
236
+ }
237
+ };
238
+ const editEmail = async () => {
239
+ const v = await promptField('Change email', { name: 'email', type: 'email', label: 'Email address' }, user.email ?? '');
240
+ if (v != null) await saveFields({ email: v }, 'Email');
241
+ };
242
+ const editPhone = async () => {
243
+ const v = await promptField('Change phone', {
244
+ name: 'phone_number', type: 'tel', label: 'Phone number',
245
+ placeholder: '+15555550142', help: 'E.164 — the server rejects pretty formats',
246
+ }, user.phone_number ?? '');
247
+ if (v != null) await saveFields({ phone_number: v }, 'Phone number');
248
+ };
249
+ const setPhone = async () => {
250
+ const v = await promptField('Set phone', {
251
+ name: 'phone_number', type: 'tel', label: 'Phone number',
252
+ placeholder: '+15555550142', help: 'E.164 — the server rejects pretty formats',
253
+ }, '');
254
+ if (v != null) await saveFields({ phone_number: v }, 'Phone number');
255
+ };
256
+ const clearPhone = async () => {
257
+ const ok = await modal.confirm({
258
+ title: 'Clear phone',
259
+ message: "Clear this user's phone number?",
260
+ confirmText: 'Clear',
261
+ danger: true,
262
+ });
263
+ if (!ok) return;
264
+ await saveFields({ phone_number: null }, 'Phone number');
265
+ };
266
+
267
+ // ── Record modals ─────────────────────────────────────────────────
268
+
269
+ const editUser = async () => {
270
+ // User.EDIT_FORM port: email / display name / phone / org picker.
271
+ const orgId = typeof user.org === 'object' && user.org ? user.org.id : typeof user.org === 'number' ? user.org : null;
272
+ const data = await formModal({
273
+ title: 'Edit user',
274
+ submitText: 'Save',
275
+ fields: [
276
+ { name: 'email', type: 'email', label: 'Email' },
277
+ { name: 'display_name', type: 'text', label: 'Display name' },
278
+ {
279
+ name: 'phone_number', type: 'tel', label: 'Phone number',
280
+ placeholder: '+15555550142', help: 'E.164 — the server rejects pretty formats',
281
+ },
282
+ {
283
+ name: 'org', type: 'collection', label: 'Organization',
284
+ endpoint: '/api/group', labelField: 'name', valueField: 'id',
285
+ placeholder: 'None', help: 'Search groups by name.',
286
+ },
287
+ ],
288
+ initial: {
289
+ email: user.email ?? '',
290
+ display_name: user.display_name ?? '',
291
+ phone_number: user.phone_number ?? '',
292
+ org: orgId,
293
+ },
294
+ });
295
+ if (!data) return;
296
+ const changes: Record<string, unknown> = {
297
+ email: data.email,
298
+ display_name: data.display_name,
299
+ phone_number: data.phone_number === '' ? null : data.phone_number,
300
+ org: data.org ?? null,
301
+ };
302
+ await saveFields(changes, 'User');
303
+ };
304
+
305
+ const editAccount = async () => {
306
+ // Source modal carried is_active + is_staff too. Deliberate portal
307
+ // deviations, both backend-grounded: is_active flips go through the
308
+ // header toggle's disable/reactivate actions (services/disable.py is
309
+ // the single source of truth — a bare is_active save bypasses the
310
+ // lifecycle); is_staff is serialized by NO user graph, so a switch
311
+ // here could only lie (and would clear the flag it cannot read).
312
+ const meta = (user.metadata ?? {}) as Record<string, unknown>;
313
+ const data = await formModal({
314
+ title: 'Edit account',
315
+ submitText: 'Save',
316
+ fields: [
317
+ {
318
+ name: 'requires_mfa', type: 'switch', label: 'Requires MFA',
319
+ help: 'User must complete a second factor at sign-in.',
320
+ },
321
+ { name: 'metadata.timezone', type: 'timezone', label: 'Timezone' },
322
+ ],
323
+ initial: {
324
+ requires_mfa: user.requires_mfa === true,
325
+ 'metadata.timezone': typeof meta.timezone === 'string' ? meta.timezone : '',
326
+ },
327
+ });
328
+ if (!data) return;
329
+ const changes: Record<string, unknown> = { requires_mfa: data.requires_mfa === true };
330
+ const tz = data['metadata.timezone'];
331
+ changes.metadata = { timezone: typeof tz === 'string' ? tz : '' };
332
+ await saveFields(changes, 'Account');
333
+ };
334
+
335
+ const changePassword = async () => {
336
+ await modal.open((close) => <ChangePasswordModal user={user} onClose={() => close(null)} />);
337
+ };
338
+
339
+ // ── MFA ───────────────────────────────────────────────────────────
340
+
341
+ const resetMfa = async () => {
342
+ // disable_totp POST_SAVE_ACTION (admin-capable; no-ops gracefully
343
+ // when nothing is enrolled). Default KEEPS requiring MFA: login with
344
+ // requires_mfa and no enrolled methods proceeds without a challenge,
345
+ // so the user is never locked out — they re-enroll or fall back to
346
+ // verified-SMS / passkey MFA.
347
+ const data = await formModal({
348
+ title: 'Reset MFA',
349
+ submitText: 'Reset MFA',
350
+ fields: [{
351
+ name: 'clear_requirement', type: 'switch',
352
+ label: 'Also stop requiring MFA for this account',
353
+ help: 'Leave off to keep MFA required — the user re-enrolls an authenticator at next login, or falls back to verified-SMS / passkey MFA.',
354
+ }],
355
+ initial: { clear_requirement: false },
356
+ });
357
+ if (!data) return;
358
+ // One POST carries the action + the optional field, exactly like the
359
+ // source (fields save before action handlers run server-side).
360
+ const body: Record<string, unknown> = { disable_totp: true };
361
+ if (data.clear_requirement === true) body.requires_mfa = false;
362
+ try {
363
+ await withFreshAuth(() => mojoCall(`/api/user/${user.id}`, { method: 'POST', body }));
364
+ toast.success('MFA reset — authenticator enrollment cleared');
365
+ await UserModel.invalidate(qc);
366
+ } catch (err) {
367
+ fail(err, 'Failed to reset MFA');
368
+ }
369
+ };
370
+
371
+ const toggleMfaRequirement = async () => {
372
+ const current = user.requires_mfa === true;
373
+ const ok = await modal.confirm({
374
+ title: `${current ? 'Disable' : 'Enable'} MFA requirement`,
375
+ message: `${current ? 'Disable' : 'Enable'} the MFA requirement for this user?`,
376
+ confirmText: current ? 'Disable' : 'Enable',
377
+ });
378
+ if (!ok) return;
379
+ await saveFields({ requires_mfa: !current }, 'MFA requirement');
380
+ };
381
+
382
+ const disableTotp = async () => {
383
+ const ok = await modal.confirm({
384
+ title: 'Disable authenticator',
385
+ message: 'Disable the authenticator app for this user? Their existing TOTP enrollment will be removed and they will need to re-enroll to use one again.',
386
+ confirmText: 'Disable',
387
+ danger: true,
388
+ });
389
+ if (!ok) return;
390
+ try {
391
+ await withFreshAuth(() => mojoCall(`/api/user/${user.id}`, { method: 'POST', body: { disable_totp: true } }));
392
+ toast.success('Authenticator disabled');
393
+ await UserModel.invalidate(qc);
394
+ } catch (err) {
395
+ fail(err, 'Failed to disable authenticator');
396
+ }
397
+ };
398
+
399
+ // ── Rate limit / sessions / invite ────────────────────────────────
400
+
401
+ const clearRateLimit = async () => {
402
+ if (!opts.throttleRetry) {
403
+ toast.info('No active rate limit to clear');
404
+ return;
405
+ }
406
+ const ok = await modal.confirm({
407
+ title: 'Clear rate limit',
408
+ message: 'Clear the login rate-limit on this user? They will be able to attempt sign-in immediately.',
409
+ confirmText: 'Clear',
410
+ });
411
+ if (!ok) return;
412
+ try {
413
+ await mojoCall('/api/auth/manage/clear_rate_limit', { method: 'POST', body: { key: 'login', user_id: user.id } });
414
+ toast.success('Rate limit cleared');
415
+ opts.refetchThrottle?.();
416
+ } catch (err) {
417
+ fail(err, 'Failed to clear rate limit');
418
+ }
419
+ };
420
+
421
+ const revokeAllSessions = async () => {
422
+ try {
423
+ // A `response:'payload'` action — the toast text IS the server's
424
+ // action response, not client copy.
425
+ const outcome = await revokeSessions.mutateAsync({ id: user.id });
426
+ toast.success(String(outcome.body.message ?? 'Sessions revoked'));
427
+ } catch (err) {
428
+ fail(err, 'Failed to revoke sessions');
429
+ }
430
+ };
431
+
432
+ const resendInvite = async () => {
433
+ if (!user.email) {
434
+ toast.error('User has no email on file');
435
+ return;
436
+ }
437
+ const ok = await modal.confirm({
438
+ title: 'Resend invite',
439
+ message: <>Resend the invite email to <b>{user.email}</b>?</>,
440
+ confirmText: 'Send',
441
+ });
442
+ if (!ok) return;
443
+ try {
444
+ await sendInvite.mutateAsync({ id: user.id, payload: true });
445
+ toast.success('Invite sent');
446
+ } catch (err) {
447
+ fail(err, 'Failed to send invite');
448
+ }
449
+ };
450
+
451
+ // ── Disable lifecycle ─────────────────────────────────────────────
452
+
453
+ const disableUser = async () => {
454
+ // Reason is REQUIRED server-side (services/disable.py
455
+ // USER_REST_REASONS = admin | abuse); note is the audit trail.
456
+ const data = await formModal({
457
+ ...UserModel.forms.disable!,
458
+ intro: <>Disable <b>{user.display_name || user.username}</b>? They will no longer be able to sign in.</>,
459
+ });
460
+ if (!data) return;
461
+ const payload: Record<string, unknown> = { reason: data.reason };
462
+ if (data.note) payload.note = data.note;
463
+ try {
464
+ await disable.mutateAsync({ id: user.id, payload });
465
+ toast.success('User disabled');
466
+ } catch (err) {
467
+ fail(err, 'Disable failed');
468
+ }
469
+ };
470
+
471
+ const reactivateUser = async () => {
472
+ // No prompt — reactivate is not destructive (source parity).
473
+ try {
474
+ await reactivate.mutateAsync({ id: user.id });
475
+ toast.success('User reactivated');
476
+ } catch (err) {
477
+ fail(err, 'Reactivate failed');
478
+ }
479
+ };
480
+
481
+ // ── Avatar / passkeys / linked accounts / org ─────────────────────
482
+
483
+ const openAvatarModal = () => {
484
+ let pending = false;
485
+ void modal.open((close) => <AvatarModal user={user} onClose={() => close(null)} onPendingChange={(next) => { pending = next; }} />, { canDismiss: () => !pending });
486
+ };
487
+
488
+ const openPasskeysModal = () => {
489
+ void modal.open((close) => <PasskeysModal userId={user.id} onClose={() => close(null)} />);
490
+ };
491
+
492
+ const openLinkedAccountsModal = () => {
493
+ void modal.open((close) => (
494
+ <div className="modal-pad">
495
+ <h2 className="modal-title">Linked accounts</h2>
496
+ <OAuthConnectionList userId={user.id} />
497
+ <div className="modal-actions">
498
+ <button className="btn" onClick={() => close(null)}>Close</button>
499
+ </div>
500
+ </div>
501
+ ));
502
+ };
503
+
504
+ const openOrg = async () => {
505
+ const orgId = typeof user.org === 'object' && user.org ? user.org.id : typeof user.org === 'number' ? user.org : null;
506
+ if (orgId == null) return;
507
+ openGroupDetail(orgId, opts.onOpenGroup);
508
+ };
509
+
510
+ return {
511
+ sendMagicLink, sendPasswordReset, sendVerificationEmail, setVerification,
512
+ editDisplayName, editUsername, editEmail, editPhone, setPhone, clearPhone,
513
+ editUser, editAccount, changePassword,
514
+ resetMfa, toggleMfaRequirement, disableTotp,
515
+ clearRateLimit, revokeAllSessions, resendInvite,
516
+ disableUser, reactivateUser,
517
+ openAvatarModal, openPasskeysModal, openLinkedAccountsModal, openOrg,
518
+ };
519
+ }
520
+
521
+ // ── Modal bodies (proper components so hooks are legal) ───────────────
522
+
523
+ /**
524
+ * Admin direct password set — `model.save({new_password})`; django-mojo lets
525
+ * the users/manage_users tier set it without current_password. Built by hand
526
+ * (not formModal) so the inputs are real type="password" controls with the
527
+ * house strength meter.
528
+ */
529
+ function ChangePasswordModal({ user, onClose }: { user: UserRow; onClose: () => void }) {
530
+ const save = UserModel.useSave();
531
+ const [password, setPassword] = useState('');
532
+ const [confirm, setConfirm] = useState('');
533
+ const [busy, setBusy] = useState(false);
534
+ const [error, setError] = useState<string | null>(null);
535
+
536
+ const submit = async () => {
537
+ if (!password) return setError('Password is required.');
538
+ if (password !== confirm) return setError('Passwords do not match');
539
+ setBusy(true);
540
+ setError(null);
541
+ try {
542
+ await save.mutateAsync({ id: user.id, changes: { new_password: password } });
543
+ toast.success('Password updated');
544
+ onClose();
545
+ } catch (err) {
546
+ setError(err instanceof Error ? err.message : 'Failed to set password');
547
+ } finally {
548
+ setBusy(false);
549
+ }
550
+ };
551
+
552
+ return (
553
+ <div className="modal-pad">
554
+ <h2 className="modal-title">Set password</h2>
555
+ <p className="dim" style={{ margin: '0 0 12px' }}>
556
+ Set a new password for <b>{user.display_name || user.username}</b> directly.
557
+ </p>
558
+ <label className="field">
559
+ <span className="field-label">New password</span>
560
+ <input
561
+ className="input"
562
+ type="password"
563
+ autoComplete="new-password"
564
+ value={password}
565
+ onChange={(e) => setPassword(e.target.value)}
566
+ autoFocus
567
+ />
568
+ </label>
569
+ <PasswordStrengthMeter password={password} />
570
+ <label className="field" style={{ marginTop: 10 }}>
571
+ <span className="field-label">Confirm password</span>
572
+ <input
573
+ className="input"
574
+ type="password"
575
+ autoComplete="new-password"
576
+ value={confirm}
577
+ onChange={(e) => setConfirm(e.target.value)}
578
+ onKeyDown={(e) => { if (e.key === 'Enter') void submit(); }}
579
+ />
580
+ </label>
581
+ {error && <div className="field-error" role="alert">{error}</div>}
582
+ <div className="modal-actions">
583
+ <button className="btn" onClick={onClose} disabled={busy}>Cancel</button>
584
+ <button className="btn btn-primary" onClick={() => void submit()} disabled={busy}>
585
+ {busy ? 'Saving…' : 'Set password'}
586
+ </button>
587
+ </div>
588
+ </div>
589
+ );
590
+ }
591
+
592
+ /** Personal-scope upload followed by strict backend #1488 id/null attachment. */
593
+ function AvatarModal({ user, onClose, onPendingChange }: { user: UserRow; onClose: () => void; onPendingChange: (pending: boolean) => void }) {
594
+ const save = UserModel.useSave();
595
+ const [busy, setBusy] = useState(false);
596
+ const [uploadPending, setUploadPending] = useState(false);
597
+ const canManage = useAdminCaller();
598
+ const [ownerResult, setOwnerResult] = useState<FileFieldOwnerResult>();
599
+ const relationId = user.avatar?.id ?? null;
600
+ const resultGeneration = useState(() => ({ current: 0 }))[0];
601
+
602
+ useEffect(() => {
603
+ onPendingChange(uploadPending || busy);
604
+ }, [busy, onPendingChange, uploadPending]);
605
+ useEffect(() => {
606
+ if (!canManage) onClose();
607
+ }, [canManage, onClose]);
608
+
609
+ const attach = async (avatar: number | null) => {
610
+ setBusy(true);
611
+ try {
612
+ const saved = await save.mutateAsync({ id: user.id, changes: { avatar } });
613
+ setOwnerResult({ generation: ++resultGeneration.current, status: 'success', requestedValue: avatar, authoritativeValue: saved.avatar });
614
+ const authoritative = saved.avatar?.id ?? null;
615
+ if (authoritative !== avatar) throw new Error('The saved user did not confirm the requested avatar relation');
616
+ toast.success(avatar == null ? 'Avatar cleared' : 'Avatar updated');
617
+ } catch (err) {
618
+ setOwnerResult({ generation: ++resultGeneration.current, status: 'failed', requestedValue: avatar });
619
+ toast.error(err instanceof Error ? err.message : 'Failed to update avatar');
620
+ } finally {
621
+ setBusy(false);
622
+ }
623
+ };
624
+
625
+ return (
626
+ <div className="modal-pad">
627
+ <h2 className="modal-title">Avatar</h2>
628
+ <p className="dim">Upload an image in your personal File scope, then attach its completed numeric File id to this user.</p>
629
+ <ImageField
630
+ value={relationId}
631
+ onChange={(value) => { void attach(value); }}
632
+ disabled={busy || !canManage}
633
+ accept="image/*"
634
+ edit={{
635
+ title: 'Edit avatar',
636
+ startMode: 'crop',
637
+ crop: { aspectRatio: 1, cropAndScale: { width: 200, height: 200 } },
638
+ }}
639
+ requireEdit
640
+ ownerResult={ownerResult}
641
+ onPendingChange={setUploadPending}
642
+ onOrphan={(fileId) => toast.warning(`File #${fileId} remains uploaded but is not attached to this user.`)}
643
+ />
644
+ <div className="modal-actions">
645
+ <button className="btn" onClick={onClose} disabled={busy || uploadPending}>Close</button>
646
+ </div>
647
+ </div>
648
+ );
649
+ }
650
+
651
+ /**
652
+ * Manage passkeys — list / rename / enable-disable / delete over
653
+ * /api/account/passkeys (?user=<id>): the two REST-editable fields are
654
+ * friendly_name and is_enabled; delete is real (CAN_DELETE).
655
+ */
656
+ function PasskeysModal({ userId, onClose }: { userId: number; onClose: () => void }) {
657
+ const { data, isPending } = PasskeyModel.useList({ user: userId, size: 25, sort: '-created' });
658
+ const save = PasskeyModel.useSave();
659
+ const del = PasskeyModel.useDelete();
660
+ const rows = data?.rows ?? [];
661
+
662
+ const rename = async (id: number, current: string | null) => {
663
+ const data = await formModal({
664
+ title: 'Edit passkey',
665
+ submitText: 'Save',
666
+ fields: [{
667
+ name: 'friendly_name', type: 'text', label: 'Name', required: true,
668
+ placeholder: 'My iPhone', help: 'A friendly name to identify this passkey',
669
+ }],
670
+ initial: { friendly_name: current ?? '' },
671
+ });
672
+ const name = data?.friendly_name;
673
+ if (typeof name !== 'string' || !name.trim()) return;
674
+ try {
675
+ await save.mutateAsync({ id, changes: { friendly_name: name.trim() } });
676
+ toast.success('Passkey updated');
677
+ } catch (err) {
678
+ toast.error(err instanceof Error ? err.message : 'Failed to update passkey');
679
+ }
680
+ };
681
+
682
+ const toggleEnabled = async (id: number, next: boolean) => {
683
+ try {
684
+ await save.mutateAsync({ id, changes: { is_enabled: next } });
685
+ toast.success(next ? 'Passkey enabled' : 'Passkey disabled');
686
+ } catch (err) {
687
+ toast.error(err instanceof Error ? err.message : 'Failed to update passkey');
688
+ }
689
+ };
690
+
691
+ const remove = async (id: number) => {
692
+ const ok = await modal.confirm({
693
+ title: 'Delete passkey',
694
+ message: 'Delete this passkey?',
695
+ confirmText: 'Delete',
696
+ danger: true,
697
+ });
698
+ if (!ok) return;
699
+ try {
700
+ await del.mutateAsync({ id });
701
+ toast.success('Passkey deleted');
702
+ } catch (err) {
703
+ toast.error(err instanceof Error ? err.message : 'Failed to delete passkey');
704
+ }
705
+ };
706
+
707
+ return (
708
+ <div className="modal-pad">
709
+ <h2 className="modal-title">Passkeys</h2>
710
+ {isPending && <p className="dim">Loading…</p>}
711
+ {!isPending && rows.length === 0 && (
712
+ <div className="us-empty">
713
+ <i className="bi bi-fingerprint" />
714
+ <div>No passkeys registered</div>
715
+ </div>
716
+ )}
717
+ {rows.map((p) => (
718
+ <div key={p.id} className="us-passkey-row">
719
+ <div className="us-row-icon"><i className="bi bi-fingerprint" /></div>
720
+ <div className="us-row-info">
721
+ <div className="us-row-title">
722
+ {p.friendly_name || 'Unnamed passkey'}
723
+ {!p.is_enabled && <Badge tone="muted">Disabled</Badge>}
724
+ </div>
725
+ <div className="us-row-meta">
726
+ Created {fmt.date(p.created)} · Last used {fmt.relative(p.last_used, 'never')} · {p.sign_count} uses
727
+ </div>
728
+ </div>
729
+ <div className="us-row-actions">
730
+ <button className="btn-icon btn-icon-sm" title="Rename" aria-label={`Rename ${p.friendly_name ?? 'passkey'}`} onClick={() => void rename(p.id, p.friendly_name)}>
731
+ <i className="bi bi-pencil" />
732
+ </button>
733
+ <button
734
+ className="btn-icon btn-icon-sm"
735
+ title={p.is_enabled ? 'Disable' : 'Enable'}
736
+ aria-label={p.is_enabled ? 'Disable passkey' : 'Enable passkey'}
737
+ onClick={() => void toggleEnabled(p.id, !p.is_enabled)}
738
+ >
739
+ <i className={`bi ${p.is_enabled ? 'bi-toggle-on' : 'bi-toggle-off'}`} />
740
+ </button>
741
+ <button className="btn-icon btn-icon-sm us-danger" title="Delete" aria-label="Delete passkey" onClick={() => void remove(p.id)}>
742
+ <i className="bi bi-trash" />
743
+ </button>
744
+ </div>
745
+ </div>
746
+ ))}
747
+ <div className="modal-actions">
748
+ <button className="btn" onClick={onClose}>Close</button>
749
+ </div>
750
+ </div>
751
+ );
752
+ }