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,737 @@
1
+ // Auth client — ported from web-mojo TokenManager + PortalApp._installAuthGate
2
+ // + the mojo-auth endpoint contract, verified against django-mojo
3
+ // (apps/account/rest/user.py, passkeys.py).
4
+ //
5
+ // Contracts carried over exactly:
6
+ // · storage keys `access_token` / `refresh_token` (web-mojo parity — an
7
+ // existing web-mojo session survives a portal swap)
8
+ // · single-flight refresh: concurrent callers share ONE in-flight POST
9
+ // · token-status decision table incl. the proactive branch (expiring within
10
+ // 10 min, or older than 60 min, with a valid refresh token → refresh)
11
+ // · pre-request gate: bypass /api/token/refresh exactly (recursion guard)
12
+ // and requests made with no stored token (pre-login / public); otherwise
13
+ // ensureValidToken() — throws AuthRequiredError, which the transport
14
+ // turns into a synthetic 401 REJECT without calling fetch
15
+ // · auth-code / magic-token URL handoff: the param is scrubbed from the URL
16
+ // *synchronously, before any network call*, so a slow exchange cannot
17
+ // leak the one-time code to third-party scripts reading location.search
18
+ // · logout is client-side only — django-mojo has no logout endpoint
19
+ //
20
+ // Two web-mojo traps deliberately NOT carried forward:
21
+ // · setTokens now clears BOTH storages before writing one, so a stale
22
+ // localStorage token can never shadow a fresh sessionStorage session
23
+ // · a refresh re-stores into the storage the session came from — web-mojo
24
+ // silently promoted remember-me=false sessions to localStorage
25
+ //
26
+ // C3 additions (board #1259), same wire authority (django-mojo account app):
27
+ // · MFA completion — totp/verify, totp/recover, sms/send (which CONSUMES
28
+ // and RE-ISSUES the mfa_token), sms/verify (account/rest/totp.py, sms.py)
29
+ // · fresh-auth (step-up) challenge surface — HTTP 440 / 'reauth_required'
30
+ // (mojo/errors.py ReauthRequiredException, services/fresh_auth.py). A
31
+ // refresh can NEVER satisfy it: auth_time is carried forward unchanged
32
+ // across refreshes by design — only a genuine re-login mints a new one.
33
+ import { AuthRequiredError, MojoError } from './errors';
34
+ import { tokenInfo } from './jwt';
35
+ import { apiOrigin, installAuthHooks, mojoCall } from './client';
36
+
37
+ const ACCESS_KEY = 'access_token';
38
+ const REFRESH_KEY = 'refresh_token';
39
+ const AUTH_CODE_PARAM = 'auth_code';
40
+
41
+ /** The one path the pre-request gate must never gate (recursion guard). */
42
+ export const REFRESH_PATH = '/api/token/refresh';
43
+
44
+ // ── Token storage ─────────────────────────────────────────────────────
45
+
46
+ export function getAccessToken(): string | null {
47
+ return localStorage.getItem(ACCESS_KEY) ?? sessionStorage.getItem(ACCESS_KEY);
48
+ }
49
+
50
+ export function getRefreshToken(): string | null {
51
+ return localStorage.getItem(REFRESH_KEY) ?? sessionStorage.getItem(REFRESH_KEY);
52
+ }
53
+
54
+ /**
55
+ * Which storage the current session lives in (refresh keeps it there).
56
+ * Public since C3: a fresh-auth re-login must keep the session in the
57
+ * storage the user originally chose (remember-me), not silently promote it.
58
+ */
59
+ export function sessionIsPersistent(): boolean {
60
+ return localStorage.getItem(REFRESH_KEY) != null || localStorage.getItem(ACCESS_KEY) != null;
61
+ }
62
+
63
+ export function setTokens(access: string, refresh?: string | null, persistent = true): void {
64
+ // Clear both storages first — a leftover token in the other storage must
65
+ // never shadow this session (web-mojo wrote without clearing).
66
+ clearTokens({ silent: true });
67
+ const storage = persistent ? localStorage : sessionStorage;
68
+ storage.setItem(ACCESS_KEY, access);
69
+ if (refresh) storage.setItem(REFRESH_KEY, refresh);
70
+ notifyAuthChanged();
71
+ }
72
+
73
+ export function clearTokens(opts: { silent?: boolean } = {}): void {
74
+ localStorage.removeItem(ACCESS_KEY);
75
+ localStorage.removeItem(REFRESH_KEY);
76
+ sessionStorage.removeItem(ACCESS_KEY);
77
+ sessionStorage.removeItem(REFRESH_KEY);
78
+ if (!opts.silent) notifyAuthChanged();
79
+ }
80
+
81
+ // ── Auth state (useSyncExternalStore-compatible) ──────────────────────
82
+
83
+ export interface AuthSnapshot {
84
+ /** A currently-valid access token OR a valid refresh token exists. */
85
+ authenticated: boolean;
86
+ uid: string | null;
87
+ email: string | null;
88
+ }
89
+
90
+ let snapshotCache: AuthSnapshot = { authenticated: false, uid: null, email: null };
91
+ let snapshotKey = '∅';
92
+ const authListeners = new Set<() => void>();
93
+
94
+ function computeSnapshot(): void {
95
+ const access = getAccessToken();
96
+ const refresh = getRefreshToken();
97
+ const key = `${access ?? ''}|${refresh ?? ''}`;
98
+ if (key === snapshotKey) return;
99
+ snapshotKey = key;
100
+ const a = tokenInfo(access);
101
+ const r = tokenInfo(refresh);
102
+ snapshotCache = {
103
+ authenticated: Boolean(a?.isValid || r?.isValid),
104
+ uid: a?.uid ?? r?.uid ?? null,
105
+ email: a?.email ?? r?.email ?? null,
106
+ };
107
+ }
108
+
109
+ function notifyAuthChanged(): void {
110
+ computeSnapshot();
111
+ for (const cb of authListeners) cb();
112
+ }
113
+
114
+ /** Stable snapshot — reference changes only when the stored tokens change. */
115
+ export function getAuthSnapshot(): AuthSnapshot {
116
+ computeSnapshot();
117
+ return snapshotCache;
118
+ }
119
+
120
+ let storageListenerInstalled = false;
121
+
122
+ /** Subscribe to auth-state changes (incl. cross-tab via the storage event). */
123
+ export function subscribeAuth(cb: () => void): () => void {
124
+ if (!storageListenerInstalled && typeof window !== 'undefined') {
125
+ storageListenerInstalled = true;
126
+ window.addEventListener('storage', (e) => {
127
+ if (e.key === null || e.key === ACCESS_KEY || e.key === REFRESH_KEY) notifyAuthChanged();
128
+ });
129
+ }
130
+ authListeners.add(cb);
131
+ return () => authListeners.delete(cb);
132
+ }
133
+
134
+ // ── Auth events ───────────────────────────────────────────────────────
135
+
136
+ export type AuthEvent = 'login' | 'logout' | 'refreshed' | 'refresh-failed' | 'unauthorized';
137
+ const eventListeners = new Map<AuthEvent, Set<(detail?: unknown) => void>>();
138
+
139
+ export function onAuth(event: AuthEvent, cb: (detail?: unknown) => void): () => void {
140
+ let set = eventListeners.get(event);
141
+ if (!set) eventListeners.set(event, (set = new Set()));
142
+ set.add(cb);
143
+ return () => set.delete(cb);
144
+ }
145
+
146
+ function emitAuth(event: AuthEvent, detail?: unknown): void {
147
+ eventListeners.get(event)?.forEach((cb) => cb(detail));
148
+ }
149
+
150
+ // ── Token status + refresh (single-flight) ────────────────────────────
151
+
152
+ export type TokenAction = 'none' | 'refresh' | 'logout';
153
+
154
+ /** The TokenManager decision table, ported verbatim. */
155
+ export function checkTokenStatus(): { action: TokenAction; reason: string } {
156
+ const access = tokenInfo(getAccessToken());
157
+ const refresh = tokenInfo(getRefreshToken());
158
+ const refreshUsable = Boolean(refresh?.isValid && !refresh.isExpired);
159
+
160
+ if (!access || !access.isValid || access.isExpired) {
161
+ if (!refreshUsable) return { action: 'logout', reason: 'Both access and refresh tokens are invalid/expired' };
162
+ return { action: 'refresh', reason: 'Access token invalid/expired but refresh token valid' };
163
+ }
164
+ const age = access.ageMinutes();
165
+ if (access.expiringSoon(10) || (age != null && age > 60)) {
166
+ if (!refreshUsable) return { action: 'none', reason: 'Access token expiring but refresh token invalid' };
167
+ return { action: 'refresh', reason: 'Access token expiring soon or aged' };
168
+ }
169
+ return { action: 'none', reason: 'All tokens valid and not expiring soon' };
170
+ }
171
+
172
+ let refreshPromise: Promise<boolean> | null = null;
173
+
174
+ /**
175
+ * Refresh the access token. Single-flight: concurrent callers share one
176
+ * in-flight POST. Always resolves to a boolean; never throws.
177
+ */
178
+ export function refreshTokens(): Promise<boolean> {
179
+ if (!refreshPromise) {
180
+ refreshPromise = doRefresh().finally(() => {
181
+ refreshPromise = null;
182
+ });
183
+ }
184
+ return refreshPromise;
185
+ }
186
+
187
+ async function doRefresh(): Promise<boolean> {
188
+ const refresh = getRefreshToken();
189
+ const info = tokenInfo(refresh);
190
+ if (!refresh || !info?.isValid || info.isExpired) {
191
+ emitAuth('unauthorized');
192
+ stopAutoRefresh();
193
+ return false;
194
+ }
195
+ const persistent = sessionIsPersistent();
196
+ try {
197
+ const body = await mojoCall(REFRESH_PATH, { method: 'POST', body: { refresh_token: refresh } });
198
+ const pkg = body.data as { access_token: string; refresh_token?: string };
199
+ setTokens(pkg.access_token, pkg.refresh_token, persistent);
200
+ emitAuth('refreshed');
201
+ return true;
202
+ } catch (error) {
203
+ const status = error instanceof Error && 'status' in error ? (error as { status: number }).status : 0;
204
+ if (status === 401 || status === 403) {
205
+ emitAuth('unauthorized');
206
+ stopAutoRefresh();
207
+ } else {
208
+ // Transient (network/5xx): keep the session, report, let the
209
+ // watcher or the next gated request retry.
210
+ emitAuth('refresh-failed', error);
211
+ }
212
+ return false;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Pre-request gate (TokenManager.ensureValidToken). Throws AuthRequiredError
218
+ * when the request must not be sent; the transport short-circuits it to a
219
+ * synthetic-401 reject without touching the network.
220
+ */
221
+ export async function ensureValidToken(): Promise<void> {
222
+ const status = checkTokenStatus();
223
+ if (status.action === 'logout') {
224
+ emitAuth('unauthorized');
225
+ stopAutoRefresh();
226
+ throw new AuthRequiredError('Both access and refresh tokens are invalid');
227
+ }
228
+ if (status.action === 'refresh') {
229
+ const ok = await refreshTokens();
230
+ if (!ok) throw new AuthRequiredError('Token refresh failed');
231
+ }
232
+ }
233
+
234
+ // ── Auto-refresh watcher ──────────────────────────────────────────────
235
+
236
+ let tokenWatcher: ReturnType<typeof setInterval> | null = null;
237
+
238
+ export function startAutoRefresh(): void {
239
+ stopAutoRefresh();
240
+ tokenWatcher = setInterval(() => {
241
+ const status = checkTokenStatus();
242
+ if (status.action === 'logout') {
243
+ emitAuth('unauthorized');
244
+ stopAutoRefresh();
245
+ } else if (status.action === 'refresh') {
246
+ void refreshTokens();
247
+ }
248
+ }, 60_000);
249
+ }
250
+
251
+ export function stopAutoRefresh(): void {
252
+ if (tokenWatcher) {
253
+ clearInterval(tokenWatcher);
254
+ tokenWatcher = null;
255
+ }
256
+ }
257
+
258
+ // ── Login flows ───────────────────────────────────────────────────────
259
+ // Every flow REJECTS (MojoError) on failure — including "Invalid username or
260
+ // password" (401) and the verification gates `email_not_verified` /
261
+ // `phone_not_verified` (403) — and stores tokens on success.
262
+
263
+ /** django-mojo user dict (`to_dict("basic")`). Typed properly in Chunk A2. */
264
+ export type AuthUser = Record<string, unknown>;
265
+
266
+ /**
267
+ * The MFA challenge a password login can answer with. `methods` come from
268
+ * the server's get_mfa_methods: 'totp' | 'sms' | 'passkey' (a deployment may
269
+ * grow more — treat unknown entries as unofferable, never as fatal).
270
+ */
271
+ export interface MfaChallenge {
272
+ mfaToken: string;
273
+ methods: string[];
274
+ expiresIn: number;
275
+ }
276
+
277
+ export type LoginResult =
278
+ | { kind: 'authenticated'; user: AuthUser }
279
+ /** Password accepted but MFA is required — no tokens issued yet. */
280
+ | ({ kind: 'mfa' } & MfaChallenge);
281
+
282
+ export interface LoginOptions {
283
+ /** true (default) → localStorage; false → sessionStorage. */
284
+ remember?: boolean;
285
+ /** Extra payload fields, e.g. a `bouncer_token` when the deployment enforces one. */
286
+ extra?: Record<string, unknown>;
287
+ }
288
+
289
+ interface TokenGrant {
290
+ access_token: string;
291
+ refresh_token?: string;
292
+ user?: AuthUser;
293
+ mfa_required?: boolean;
294
+ mfa_token?: string;
295
+ mfa_methods?: string[];
296
+ expires_in?: number;
297
+ }
298
+
299
+ function adoptGrant(data: TokenGrant, remember = true): AuthUser {
300
+ setTokens(data.access_token, data.refresh_token, remember);
301
+ startAutoRefresh();
302
+ const user = data.user ?? {};
303
+ emitAuth('login', user);
304
+ return user;
305
+ }
306
+
307
+ /** Password login. `username` may be a username, email, or (if enabled) phone. */
308
+ export async function login(username: string, password: string, opts: LoginOptions = {}): Promise<LoginResult> {
309
+ const body = await mojoCall('/api/login', { method: 'POST', body: { username, password, ...opts.extra } });
310
+ const data = body.data as TokenGrant;
311
+ if (data.mfa_required) {
312
+ return { kind: 'mfa', mfaToken: data.mfa_token ?? '', methods: data.mfa_methods ?? [], expiresIn: data.expires_in ?? 300 };
313
+ }
314
+ return { kind: 'authenticated', user: adoptGrant(data, opts.remember ?? true) };
315
+ }
316
+
317
+ // ── MFA completion (deferred from A1; landed with the C3 pages) ───────
318
+ // Each completion consumes the mfa_token and answers with a full TokenGrant
319
+ // (django-mojo jwt_login) — adopted here exactly like a password login.
320
+ // `remember` mirrors LoginOptions: the MFA step continues the login the
321
+ // user began, so the page passes the same remember-me choice through.
322
+
323
+ export interface MfaCompletionOptions {
324
+ /** true (default) → localStorage; false → sessionStorage. */
325
+ remember?: boolean;
326
+ }
327
+
328
+ /** Second factor: TOTP code (`POST /api/auth/totp/verify`). */
329
+ export async function completeMfaTotp(mfaToken: string, code: string, opts: MfaCompletionOptions = {}): Promise<AuthUser> {
330
+ const body = await mojoCall('/api/auth/totp/verify', { method: 'POST', body: { mfa_token: mfaToken, code } });
331
+ return adoptGrant(body.data as TokenGrant, opts.remember ?? true);
332
+ }
333
+
334
+ /** Second factor: one single-use TOTP recovery code (`POST /api/auth/totp/recover`). */
335
+ export async function completeMfaRecovery(mfaToken: string, recoveryCode: string, opts: MfaCompletionOptions = {}): Promise<AuthUser> {
336
+ const body = await mojoCall('/api/auth/totp/recover', { method: 'POST', body: { mfa_token: mfaToken, recovery_code: recoveryCode } });
337
+ return adoptGrant(body.data as TokenGrant, opts.remember ?? true);
338
+ }
339
+
340
+ /**
341
+ * Send the SMS one-time code for an `sms` MFA challenge. The server CONSUMES
342
+ * the mfa_token and RE-ISSUES a fresh one in the response — the caller must
343
+ * replace its stored token with the returned `mfaToken` or the verify step
344
+ * will fail with "Invalid or expired MFA token".
345
+ */
346
+ export async function sendMfaSms(mfaToken: string): Promise<{ mfaToken: string; expiresIn: number }> {
347
+ const body = await mojoCall('/api/auth/sms/send', { method: 'POST', body: { mfa_token: mfaToken } });
348
+ const data = body.data as { mfa_token: string; expires_in?: number };
349
+ return { mfaToken: data.mfa_token, expiresIn: data.expires_in ?? 300 };
350
+ }
351
+
352
+ /** Second factor: the SMS code (`POST /api/auth/sms/verify` with mfa_token). */
353
+ export async function completeMfaSms(mfaToken: string, code: string, opts: MfaCompletionOptions = {}): Promise<AuthUser> {
354
+ const body = await mojoCall('/api/auth/sms/verify', { method: 'POST', body: { mfa_token: mfaToken, code } });
355
+ return adoptGrant(body.data as TokenGrant, opts.remember ?? true);
356
+ }
357
+
358
+ // TODO(C3-deferred) OAuth redirect flows: django-mojo ships the full wire —
359
+ // GET /api/auth/oauth/<provider>/begin?redirect_uri=… → {auth_url, state}
360
+ // (browser navigates to auth_url; provider → backend callback → 302 back
361
+ // to redirect_uri with ?code=&state= merged into its query)
362
+ // POST /api/auth/oauth/<provider>/complete {code, state} → TokenGrant.
363
+ // Deferred with the workspec's blessing: redirect_uri must be allowlisted
364
+ // server-side (ALLOWED_REDIRECT_URLS), providers are deployment-specific
365
+ // (GET /api/auth/config → login.methods), and the round-trip cannot be
366
+ // exercised against the mock or without real provider credentials. The
367
+ // hosted /auth pages cover OAuth deployments today. Seam: add
368
+ // `beginOAuth(provider, redirectUri)` + `completeOAuth(provider, code,
369
+ // state)` here, and an `?code=&state=` landing branch beside
370
+ // handleAuthCodeFromURL().
371
+
372
+ /** Request a reset email: `code` → 6-digit code, `link` → `pr:` token link. */
373
+ export async function forgotPassword(email: string, method: 'code' | 'link' = 'code'): Promise<void> {
374
+ await mojoCall('/api/auth/forgot', { method: 'POST', body: { email, method } });
375
+ }
376
+
377
+ /** Complete a code reset. Logs the user in (tokens stored). */
378
+ export async function resetPasswordWithCode(email: string, code: string, newPassword: string): Promise<AuthUser> {
379
+ const body = await mojoCall('/api/auth/password/reset/code', { method: 'POST', body: { email, code, new_password: newPassword } });
380
+ return adoptGrant(body.data as TokenGrant);
381
+ }
382
+
383
+ /** Complete a link reset with the `pr:` token. Logs the user in. */
384
+ export async function resetPasswordWithToken(token: string, newPassword: string): Promise<AuthUser> {
385
+ const body = await mojoCall('/api/auth/password/reset/token', { method: 'POST', body: { token, new_password: newPassword } });
386
+ return adoptGrant(body.data as TokenGrant);
387
+ }
388
+
389
+ /** Send a passwordless `ml:` login link. */
390
+ export async function sendMagicLink(email: string): Promise<void> {
391
+ await mojoCall('/api/auth/magic/send', { method: 'POST', body: { email } });
392
+ }
393
+
394
+ /** Complete login with the `ml:` token from a magic link. */
395
+ export async function loginWithMagicToken(token: string): Promise<AuthUser> {
396
+ const body = await mojoCall('/api/auth/magic/login', { method: 'POST', body: { token } });
397
+ return adoptGrant(body.data as TokenGrant);
398
+ }
399
+
400
+ /**
401
+ * Read `?token=ml:…` from the URL, scrub it (before any network call), and
402
+ * log in. Null when no magic token is present. Never throws URL-side; the
403
+ * network call rejects like any other flow.
404
+ */
405
+ export function handleMagicTokenFromURL(): Promise<AuthUser> | null {
406
+ if (typeof window === 'undefined') return null;
407
+ const params = new URLSearchParams(window.location.search);
408
+ const token = params.get('token');
409
+ if (!token || !token.startsWith('ml:')) return null;
410
+ params.delete('token');
411
+ scrubUrl(params, window.location.hash || '');
412
+ return loginWithMagicToken(token);
413
+ }
414
+
415
+ // ── Passkeys (WebAuthn) ───────────────────────────────────────────────
416
+
417
+ export function isPasskeySupported(): boolean {
418
+ return typeof window !== 'undefined'
419
+ && typeof window.PublicKeyCredential !== 'undefined'
420
+ && typeof navigator.credentials?.get === 'function';
421
+ }
422
+
423
+ function base64urlToBuffer(base64url: string): ArrayBuffer {
424
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
425
+ const binary = atob(base64);
426
+ const bytes = new Uint8Array(binary.length);
427
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
428
+ return bytes.buffer;
429
+ }
430
+
431
+ function bufferToBase64url(buffer: ArrayBuffer): string {
432
+ const bytes = new Uint8Array(buffer);
433
+ let binary = '';
434
+ for (const b of bytes) binary += String.fromCharCode(b);
435
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
436
+ }
437
+
438
+ interface PasskeyBeginResponse {
439
+ challenge_id: string;
440
+ publicKey: PublicKeyCredentialRequestOptions & {
441
+ challenge: unknown;
442
+ allowCredentials?: { id: unknown; type: string; transports?: string[] }[];
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Passkey login: begin → authenticator prompt → complete. With no username
448
+ * the server returns empty allowCredentials and the browser offers every
449
+ * discoverable credential for this domain.
450
+ */
451
+ export interface PasskeyLoginOptions {
452
+ /** true (default) → localStorage; false → sessionStorage. */
453
+ remember?: boolean;
454
+ }
455
+
456
+ export async function loginWithPasskey(username?: string, opts: PasskeyLoginOptions = {}): Promise<AuthUser> {
457
+ if (!isPasskeySupported()) throw new Error('Passkeys are not supported in this browser');
458
+
459
+ const begin = await mojoCall('/api/auth/passkeys/login/begin', { method: 'POST', body: username ? { username } : {} });
460
+ const { challenge_id, publicKey } = begin.data as unknown as PasskeyBeginResponse;
461
+
462
+ const request: PublicKeyCredentialRequestOptions = {
463
+ ...publicKey,
464
+ challenge: base64urlToBuffer(String(publicKey.challenge)),
465
+ allowCredentials: publicKey.allowCredentials?.map((c) => ({
466
+ type: 'public-key' as const,
467
+ id: base64urlToBuffer(String(c.id)),
468
+ transports: c.transports as AuthenticatorTransport[] | undefined,
469
+ })),
470
+ };
471
+
472
+ const credential = (await navigator.credentials.get({ publicKey: request })) as PublicKeyCredential | null;
473
+ if (!credential) throw new Error('No credential received from authenticator');
474
+ const assertion = credential.response as AuthenticatorAssertionResponse;
475
+
476
+ const body = await mojoCall('/api/auth/passkeys/login/complete', {
477
+ method: 'POST',
478
+ body: {
479
+ challenge_id,
480
+ credential: {
481
+ id: credential.id,
482
+ rawId: bufferToBase64url(credential.rawId),
483
+ type: credential.type,
484
+ response: {
485
+ clientDataJSON: bufferToBase64url(assertion.clientDataJSON),
486
+ authenticatorData: bufferToBase64url(assertion.authenticatorData),
487
+ signature: bufferToBase64url(assertion.signature),
488
+ userHandle: assertion.userHandle ? bufferToBase64url(assertion.userHandle) : null,
489
+ },
490
+ },
491
+ },
492
+ });
493
+ return adoptGrant(body.data as TokenGrant, opts.remember ?? true);
494
+ }
495
+
496
+ // ── Fresh-auth (step-up) challenges ───────────────────────────────────
497
+ // django-mojo's freshness gate (account/services/fresh_auth.py): sensitive
498
+ // operations can require that the JWT's `auth_time` claim — stamped at the
499
+ // last GENUINE login — is younger than FRESH_AUTH_WINDOW. A stale call gets
500
+ // HTTP 440 / reason 'reauth_required' (mojo/errors.py, deliberately NOT 401:
501
+ // a token refresh carries auth_time forward unchanged and can never satisfy
502
+ // the gate). The only cure is a real re-login (password / MFA / passkey),
503
+ // which the UI presents as a modal OVER the current screen — page state is
504
+ // never thrown away for a redirect.
505
+ //
506
+ // Wiring: the app registers ONE UI handler (setFreshAuthHandler → the C3
507
+ // fresh-auth modal). Call sites wrap sensitive operations in
508
+ // `withFreshAuth(fn)` — on a 440 the handler prompts, and a successful
509
+ // re-login (new tokens stored by the normal login flows) retries fn once.
510
+
511
+ /** HTTP status + envelope error_code of a step-up challenge. */
512
+ export const REAUTH_STATUS = 440;
513
+
514
+ /** True when an error is django-mojo's fresh-auth (step-up) challenge. */
515
+ export function isReauthRequired(error: unknown): boolean {
516
+ return error instanceof MojoError
517
+ && (error.status === REAUTH_STATUS || error.message === 'reauth_required');
518
+ }
519
+
520
+ /**
521
+ * Resolve true when the user completed a fresh re-login, false when they
522
+ * dismissed the prompt (or no prompt is wired).
523
+ */
524
+ export type FreshAuthHandler = () => Promise<boolean>;
525
+
526
+ let freshAuthHandler: FreshAuthHandler | null = null;
527
+ let freshAuthPromise: Promise<boolean> | null = null;
528
+
529
+ /**
530
+ * Register the UI that answers step-up challenges (one per app — the C3
531
+ * FreshAuthHost). Pass null to unregister. The handler re-authenticates via
532
+ * the normal login flows, so the fresh tokens (and their new auth_time) are
533
+ * stored before it resolves true; keep the session's storage by passing
534
+ * `{remember: sessionIsPersistent()}` to the login call.
535
+ */
536
+ export function setFreshAuthHandler(handler: FreshAuthHandler | null): void {
537
+ freshAuthHandler = handler;
538
+ }
539
+
540
+ /**
541
+ * Prompt for a fresh re-login. Single-flight: concurrent 440s (a burst of
542
+ * gated saves) share ONE prompt and all observe its outcome. Resolves false
543
+ * — with a console.warn, never a throw — when no handler is registered.
544
+ */
545
+ export function requestFreshAuth(): Promise<boolean> {
546
+ if (!freshAuthHandler) {
547
+ console.warn('requestFreshAuth: no fresh-auth handler registered (setFreshAuthHandler) — challenge cannot be answered');
548
+ return Promise.resolve(false);
549
+ }
550
+ if (!freshAuthPromise) {
551
+ const handler = freshAuthHandler;
552
+ freshAuthPromise = handler()
553
+ .catch((error) => {
554
+ // A handler bug must not wedge the single-flight forever.
555
+ console.warn('fresh-auth handler threw', error);
556
+ return false;
557
+ })
558
+ .finally(() => {
559
+ freshAuthPromise = null;
560
+ });
561
+ }
562
+ return freshAuthPromise;
563
+ }
564
+
565
+ /**
566
+ * Run a sensitive operation with step-up handling: on a 440 reject, prompt
567
+ * (requestFreshAuth) and retry ONCE after a successful re-login. Any other
568
+ * failure — and a dismissed prompt — rejects with the ORIGINAL error, so
569
+ * failure stays unmissable at the call site.
570
+ */
571
+ export async function withFreshAuth<T>(fn: () => Promise<T>): Promise<T> {
572
+ try {
573
+ return await fn();
574
+ } catch (error) {
575
+ if (!isReauthRequired(error)) throw error;
576
+ const ok = await requestFreshAuth();
577
+ if (!ok) throw error;
578
+ return fn();
579
+ }
580
+ }
581
+
582
+ // ── Cross-origin auth handoff ─────────────────────────────────────────
583
+
584
+ let exchangePromise: Promise<AuthUser | null> | null = null;
585
+
586
+ /**
587
+ * Exchange a one-time handoff code (32-hex, 60s TTL, single-use — all
588
+ * server-enforced) for tokens. Single-flight; resolves null on failure
589
+ * (the code is burned either way — nothing to retry).
590
+ */
591
+ export function exchangeAuthCode(code: string): Promise<AuthUser | null> {
592
+ if (!exchangePromise) {
593
+ exchangePromise = doExchange(code).finally(() => {
594
+ exchangePromise = null;
595
+ });
596
+ }
597
+ return exchangePromise;
598
+ }
599
+
600
+ async function doExchange(code: string): Promise<AuthUser | null> {
601
+ try {
602
+ const body = await mojoCall('/api/auth/exchange', { method: 'POST', body: { code } });
603
+ return adoptGrant(body.data as TokenGrant);
604
+ } catch (error) {
605
+ emitAuth('refresh-failed', error);
606
+ return null;
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Read `?auth_code=` from the URL and exchange it. The scrub happens
612
+ * synchronously, BEFORE any await, so a slow exchange cannot leak the code.
613
+ * Resolves null when no code is present or the exchange fails.
614
+ *
615
+ * Two landing shapes are handled, because the hosted auth page string-appends
616
+ * `?auth_code=` to whatever `?redirect=` carried:
617
+ * · the real search string (`/?auth_code=x#/route`) — what
618
+ * redirectToHostedAuth() produces by sending a hash-free return URL;
619
+ * · inside the hash query (`/#/route?auth_code=x`) — a deployment that
620
+ * passed a full hash-router URL as the redirect.
621
+ * After a successful exchange the stashed return route (if any) is restored.
622
+ */
623
+ export function handleAuthCodeFromURL(): Promise<AuthUser | null> {
624
+ if (typeof window === 'undefined' || !window.location) return Promise.resolve(null);
625
+ if (exchangePromise) return exchangePromise;
626
+
627
+ const params = new URLSearchParams(window.location.search);
628
+ let code = params.get(AUTH_CODE_PARAM);
629
+ if (code) {
630
+ params.delete(AUTH_CODE_PARAM);
631
+ scrubUrl(params, window.location.hash || '');
632
+ } else {
633
+ // Hash-embedded: '#/route?auth_code=x&…'
634
+ const hash = window.location.hash || '';
635
+ const qIndex = hash.indexOf('?');
636
+ if (qIndex === -1) return Promise.resolve(null);
637
+ const hashParams = new URLSearchParams(hash.slice(qIndex + 1));
638
+ code = hashParams.get(AUTH_CODE_PARAM);
639
+ if (!code) return Promise.resolve(null);
640
+ hashParams.delete(AUTH_CODE_PARAM);
641
+ const rest = hashParams.toString();
642
+ scrubUrl(params, hash.slice(0, qIndex) + (rest ? `?${rest}` : ''));
643
+ }
644
+
645
+ return exchangeAuthCode(code).then((user) => {
646
+ if (user) restoreReturnRoute();
647
+ return user;
648
+ });
649
+ }
650
+
651
+ function scrubUrl(params: URLSearchParams, hash: string): void {
652
+ const remaining = params.toString();
653
+ const clean = window.location.pathname + (remaining ? `?${remaining}` : '') + hash;
654
+ window.history.replaceState({}, '', clean);
655
+ }
656
+
657
+ // ── Hosted auth pages (the django-mojo /auth "bouncer" pages) ─────────
658
+ // The backend serves themed login/register pages at <origin>/auth. A portal
659
+ // sends the user there with ?redirect=<return url>; after sign-in the page
660
+ // mints a single-use handoff code (60s TTL) and navigates to
661
+ // <redirect>?auth_code=<code>, which handleAuthCodeFromURL() exchanges.
662
+
663
+ const RETURN_ROUTE_KEY = 'mojo:auth-return-route';
664
+
665
+ /**
666
+ * The hosted auth page URL for this deployment, or null under the mock
667
+ * transport (there is no page to host). `returnTo` defaults to a HASH-FREE
668
+ * form of the current URL — the auth page appends `?auth_code=` with plain
669
+ * string concatenation, and a hash in the redirect would swallow it.
670
+ */
671
+ export function hostedAuthUrl(returnTo?: string): string | null {
672
+ const origin = apiOrigin();
673
+ if (!origin || typeof window === 'undefined') return null;
674
+ const target = returnTo ?? window.location.origin + window.location.pathname + window.location.search;
675
+ return `${origin}/auth?redirect=${encodeURIComponent(target)}`;
676
+ }
677
+
678
+ /**
679
+ * Send the user to the hosted auth page, remembering the current in-app
680
+ * (hash) route so the post-exchange boot can put them back where they were.
681
+ */
682
+ export function redirectToHostedAuth(): boolean {
683
+ const url = hostedAuthUrl();
684
+ if (!url) return false;
685
+ try {
686
+ const route = window.location.hash;
687
+ if (route && route !== '#/') sessionStorage.setItem(RETURN_ROUTE_KEY, route);
688
+ } catch { /* storage denied — land on the default route */ }
689
+ window.location.href = url;
690
+ return true;
691
+ }
692
+
693
+ function restoreReturnRoute(): void {
694
+ try {
695
+ const route = sessionStorage.getItem(RETURN_ROUTE_KEY);
696
+ if (route) {
697
+ sessionStorage.removeItem(RETURN_ROUTE_KEY);
698
+ window.location.hash = route;
699
+ }
700
+ } catch { /* storage denied */ }
701
+ }
702
+
703
+ // ── Logout + boot wiring ──────────────────────────────────────────────
704
+
705
+ /** Client-side logout (django-mojo has no logout endpoint — tokens just die). */
706
+ export function logout(): void {
707
+ clearTokens();
708
+ stopAutoRefresh();
709
+ emitAuth('logout');
710
+ }
711
+
712
+ /**
713
+ * Wire auth into the transport and start session upkeep. Call once at app
714
+ * boot. Installs the pre-request gate + Authorization header provider
715
+ * (PortalApp._installAuthGate parity) and starts the 60s watcher when a
716
+ * session exists. Without this call the client runs ungated (pre-login).
717
+ */
718
+ export function initAuth(): void {
719
+ installAuthHooks({
720
+ async preRequest(path: string) {
721
+ // Recursion guard: never gate the refresh call itself.
722
+ if (path === REFRESH_PATH) return;
723
+ // No stored token → pre-login / public flow, pass through.
724
+ if (!getAccessToken()) return;
725
+ await ensureValidToken();
726
+ // NOTE: web-mojo had to re-stamp Authorization here because its
727
+ // Rest snapshotted headers before the gate ran. Our transport
728
+ // reads the token AFTER this hook, so the fresh token is picked
729
+ // up by construction.
730
+ },
731
+ authHeader() {
732
+ const token = getAccessToken();
733
+ return token ? `Bearer ${token}` : null;
734
+ },
735
+ });
736
+ if (getRefreshToken()) startAutoRefresh();
737
+ }