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,268 @@
1
+ // Mini @mojo/client — the typed django-mojo protocol layer.
2
+ //
3
+ // Contract (from the web-mojo port extraction):
4
+ // - Envelope: {status, ...} — status:false carries error/message. Unwrapped
5
+ // HERE, at exactly one boundary. A failed save REJECTS (MojoError); it is
6
+ // never resolved as success. (web-mojo's Model.save() never-rejects trap
7
+ // is deliberately not carried forward.)
8
+ // - Lists: {status, data: T[], count, size, start} → MojoList<T>.
9
+ // - Singles: {status, data: T} → T.
10
+ // - Paging: start/size. Sort: 'field' | '-field'. Filters: Django lookups.
11
+ // - Every request carries the X-Mojo-UID device header; when auth is
12
+ // initialized (initAuth), a pre-request gate + Authorization header are
13
+ // installed via installAuthHooks — the client core itself has zero auth
14
+ // knowledge (web-mojo's Rest-interceptor shape).
15
+ //
16
+ // With VITE_MOJO_API unset the transport is the in-memory mock; set it to a
17
+ // django-mojo origin and the same code talks to the real backend.
18
+ import { mockFetch } from './mock';
19
+ import { MojoError } from './errors';
20
+ import { DUID_HEADER, getDuid } from './duid';
21
+ import type { MojoList, Params } from './types';
22
+
23
+ export { MojoError, AuthRequiredError } from './errors';
24
+
25
+ const API_BASE: string = import.meta.env.VITE_MOJO_API ?? '';
26
+
27
+ /** True when the in-memory mock transport is active (VITE_MOJO_API unset). */
28
+ export function usingMockTransport(): boolean {
29
+ return !API_BASE;
30
+ }
31
+
32
+ /** The configured django-mojo origin ('' under the mock transport). */
33
+ export function apiOrigin(): string {
34
+ return API_BASE;
35
+ }
36
+
37
+ /**
38
+ * TanStack Query defaults tuned for the mojo protocol — spread these into the
39
+ * app's QueryClient:
40
+ * · 4xx MojoErrors never retry (a 404/401/403 is deterministic — retrying
41
+ * it only delays the error surfacing, e.g. the group-context fallback)
42
+ * · networkMode 'always' under the mock (an in-memory backend can't be
43
+ * offline; online-pausing would wedge failures forever), 'online' for
44
+ * real backends.
45
+ */
46
+ export function mojoQueryDefaults() {
47
+ return {
48
+ queries: {
49
+ networkMode: (usingMockTransport() ? 'always' : 'online') as 'always' | 'online',
50
+ retry: (failureCount: number, error: unknown): boolean => {
51
+ if (error instanceof MojoError && error.status >= 400 && error.status < 500) return false;
52
+ return failureCount < 1;
53
+ },
54
+ // No refetch storm on window focus (web-mojo parity: freshness
55
+ // comes from explicit refresh + the opt-in autoRefresh interval).
56
+ // With it on, every hop back from devtools/another window refired
57
+ // EVERY active query — read as phantom duplicate fetches.
58
+ refetchOnWindowFocus: false,
59
+ },
60
+ };
61
+ }
62
+
63
+ export function buildQuery(params: Params): string {
64
+ const qs = new URLSearchParams();
65
+ for (const [key, value] of Object.entries(params)) {
66
+ if (value === undefined || value === null || value === '') continue;
67
+ qs.set(key, String(value));
68
+ }
69
+ const s = qs.toString();
70
+ return s ? `?${s}` : '';
71
+ }
72
+
73
+ export interface FetchOpts {
74
+ params?: Params;
75
+ method?: 'GET' | 'POST' | 'DELETE';
76
+ body?: Record<string, unknown>;
77
+ /** Cancels the selected transport request. */
78
+ signal?: AbortSignal;
79
+ }
80
+
81
+ export interface Envelope {
82
+ status: boolean;
83
+ code?: number;
84
+ data?: unknown;
85
+ error?: string;
86
+ error_code?: number | string;
87
+ message?: string;
88
+ count?: number;
89
+ size?: number;
90
+ start?: number;
91
+ }
92
+
93
+ /** Installed by auth (initAuth). The transport stays auth-agnostic. */
94
+ export interface AuthHooks {
95
+ /** Pre-request gate; may throw AuthRequiredError → synthetic-401 reject, no fetch. */
96
+ preRequest(path: string): Promise<void>;
97
+ /** Authorization header value, or null when no session exists. */
98
+ authHeader(): string | null;
99
+ }
100
+
101
+ let authHooks: AuthHooks | null = null;
102
+
103
+ export function installAuthHooks(hooks: AuthHooks | null): void {
104
+ authHooks = hooks;
105
+ }
106
+
107
+ async function transport(path: string, opts: FetchOpts): Promise<Envelope> {
108
+ // Gate BEFORE headers are built: after a mid-gate token refresh the fresh
109
+ // Authorization value is picked up below by construction (web-mojo had to
110
+ // re-stamp a snapshotted header here).
111
+ if (authHooks) await authHooks.preRequest(path);
112
+
113
+ const headers: Record<string, string> = {
114
+ 'Content-Type': 'application/json',
115
+ [DUID_HEADER]: getDuid(),
116
+ };
117
+ const bearer = authHooks?.authHeader();
118
+ if (bearer) headers['Authorization'] = bearer;
119
+
120
+ if (!API_BASE) {
121
+ return (await mockFetch(path, { ...opts, headers })) as Envelope;
122
+ }
123
+ const res = await fetch(`${API_BASE}${path}${buildQuery(opts.params ?? {})}`, {
124
+ method: opts.method ?? 'GET',
125
+ headers,
126
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
127
+ signal: opts.signal,
128
+ });
129
+ if (!res.ok) {
130
+ // django-mojo sends its envelope on error responses too — surface the
131
+ // real message ("Invalid username or password"), not "HTTP 401".
132
+ let detail = `HTTP ${res.status}`;
133
+ let errorCode: string | number | undefined;
134
+ let data: unknown;
135
+ try {
136
+ const body = (await res.json()) as Envelope;
137
+ detail = body.error ?? body.message ?? detail;
138
+ errorCode = body.error_code;
139
+ data = body.data;
140
+ } catch {
141
+ // Non-JSON error body — keep the status text.
142
+ }
143
+ throw new MojoError(detail, res.status, errorCode, data);
144
+ }
145
+ return (await res.json()) as Envelope;
146
+ }
147
+
148
+ /** The single envelope-unwrap boundary. */
149
+ async function unwrap(path: string, opts: FetchOpts): Promise<Envelope> {
150
+ const body = await transport(path, opts);
151
+ if (body.status === false) {
152
+ const legacyStatus = typeof body.error_code === 'number' ? body.error_code : 0;
153
+ throw new MojoError(
154
+ body.error ?? body.message ?? 'Request failed',
155
+ body.code ?? legacyStatus,
156
+ body.error_code,
157
+ body.data,
158
+ );
159
+ }
160
+ return body;
161
+ }
162
+
163
+ /**
164
+ * Typed escape hatch over the unwrap boundary for protocol modules (auth) and
165
+ * one-off endpoints. Still the same single boundary — never parse envelopes
166
+ * anywhere else.
167
+ */
168
+ export function mojoCall(path: string, opts: FetchOpts = {}): Promise<Envelope> {
169
+ return unwrap(path, opts);
170
+ }
171
+
172
+ export async function mojoList<T>(endpoint: string, params: Params = {}): Promise<MojoList<T>> {
173
+ const body = await unwrap(endpoint, { params });
174
+ return {
175
+ rows: (body.data ?? []) as T[],
176
+ count: body.count ?? 0,
177
+ start: body.start ?? 0,
178
+ size: body.size ?? 0,
179
+ };
180
+ }
181
+
182
+ export async function mojoGet<T>(endpoint: string, id: number | string): Promise<T> {
183
+ const body = await unwrap(`${endpoint}/${id}`, {});
184
+ return body.data as T;
185
+ }
186
+
187
+ export interface MetricsResponse {
188
+ labels: string[];
189
+ datasets: { label: string; data: number[] }[];
190
+ }
191
+
192
+ /**
193
+ * django-mojo metrics. The wire shape (verified live) is
194
+ * `{data: {<slug>: number[]}, labels: string[]}` — a slug-keyed map with NO
195
+ * granularity/range echo. Normalized HERE (the one boundary) into the
196
+ * datasets array the chart components consume; display names for slugs are a
197
+ * presentation concern (MetricsChart), not a wire fact.
198
+ */
199
+ export async function mojoMetrics(params: Params): Promise<MetricsResponse> {
200
+ const body = await unwrap('/api/metrics/fetch', { params });
201
+ const raw = (body.data ?? {}) as { data?: Record<string, number[]>; labels?: string[] };
202
+ return {
203
+ labels: raw.labels ?? [],
204
+ datasets: Object.entries(raw.data ?? {}).map(([slug, data]) => ({ label: slug, data: data ?? [] })),
205
+ };
206
+ }
207
+
208
+ /** Create (no id) or update (with id). Rejects on any failure. */
209
+ export async function mojoSave<T>(endpoint: string, id: number | string | null, changes: Record<string, unknown>): Promise<T> {
210
+ const path = id == null ? endpoint : `${endpoint}/${id}`;
211
+ const body = await unwrap(path, { method: 'POST', body: changes });
212
+ return body.data as T;
213
+ }
214
+
215
+ /**
216
+ * Delete a record. django-mojo answers `{status: "deleted"}` (a string — the
217
+ * envelope's only non-boolean status; it passes the unwrap's `=== false`
218
+ * failure check by design). Failures reject like every other call.
219
+ */
220
+ export async function mojoDelete(endpoint: string, id: number | string): Promise<void> {
221
+ await unwrap(`${endpoint}/${id}`, { method: 'DELETE' });
222
+ }
223
+
224
+ /** Trigger a browser download of an in-memory file. Shared by safe client exports. */
225
+ export function downloadBlob(blob: Blob, filename: string): void {
226
+ const url = URL.createObjectURL(blob);
227
+ const a = document.createElement('a');
228
+ a.href = url;
229
+ a.download = filename;
230
+ document.body.appendChild(a);
231
+ a.click();
232
+ a.remove();
233
+ URL.revokeObjectURL(url);
234
+ }
235
+
236
+ /**
237
+ * Server-side export (mojo/models/rest.py on_rest_list_response): the list
238
+ * endpoint with `download_format=csv|json` + `filename` streams a file built
239
+ * from the model's graph fields under the CURRENT filters (paging params are
240
+ * stripped here — an export is the whole result set). Under the mock the
241
+ * same filter pipeline synthesizes the file content in-memory.
242
+ */
243
+ export async function mojoDownload(endpoint: string, params: Params, format: 'csv' | 'json'): Promise<void> {
244
+ const cleaned: Params = { ...params };
245
+ delete cleaned.start;
246
+ delete cleaned.size;
247
+ const filename = `export-${endpoint.split('/').filter(Boolean).pop() ?? 'data'}.${format}`;
248
+ const withFormat: Params = { ...cleaned, download_format: format, filename };
249
+
250
+ if (authHooks) await authHooks.preRequest(endpoint);
251
+ const headers: Record<string, string> = { [DUID_HEADER]: getDuid() };
252
+ const bearer = authHooks?.authHeader();
253
+ if (bearer) headers['Authorization'] = bearer;
254
+
255
+ if (!API_BASE) {
256
+ const body = (await mockFetch(endpoint, { params: withFormat, headers })) as Envelope;
257
+ if (body.status === false) {
258
+ const legacyStatus = typeof body.error_code === 'number' ? body.error_code : 0;
259
+ throw new MojoError(body.error ?? 'Export failed', body.code ?? legacyStatus, body.error_code, body.data);
260
+ }
261
+ const file = body.data as { filename: string; content: string; mime: string };
262
+ downloadBlob(new Blob([file.content], { type: file.mime }), file.filename);
263
+ return;
264
+ }
265
+ const res = await fetch(`${API_BASE}${endpoint}${buildQuery(withFormat)}`, { headers });
266
+ if (!res.ok) throw new MojoError(`Export failed (HTTP ${res.status})`, res.status);
267
+ downloadBlob(await res.blob(), filename);
268
+ }
@@ -0,0 +1,31 @@
1
+ // Device Unique ID — a per-browser UUID django-mojo uses for device tracking
2
+ // and rate limiting. Sent on every request as the X-Mojo-UID header (web-mojo
3
+ // Rest parity, storage key included).
4
+
5
+ export const DUID_HEADER = 'X-Mojo-UID';
6
+ const DUID_KEY = 'mojo_device_uid';
7
+
8
+ let duid: string | null = null;
9
+
10
+ function generate(): string {
11
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
12
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
13
+ const r = (Math.random() * 16) | 0;
14
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
15
+ });
16
+ }
17
+
18
+ /** Stable per-browser DUID; falls back to per-session when storage is blocked. */
19
+ export function getDuid(): string {
20
+ if (duid) return duid;
21
+ try {
22
+ duid = localStorage.getItem(DUID_KEY);
23
+ if (!duid) {
24
+ duid = generate();
25
+ localStorage.setItem(DUID_KEY, duid);
26
+ }
27
+ } catch {
28
+ duid = generate();
29
+ }
30
+ return duid;
31
+ }
@@ -0,0 +1,33 @@
1
+ // Error types for the mojo client. Own module so client.ts and auth.ts can
2
+ // both import them without a dependency cycle.
3
+
4
+ export class MojoError extends Error {
5
+ status: number;
6
+ /** Semantic django-mojo error code (`s3_operation_incomplete`, etc.). */
7
+ errorCode: string | number | undefined;
8
+ /** Structured, server-sanitized failure evidence. Never log indiscriminately. */
9
+ data: unknown;
10
+ constructor(message: string, status = 0, errorCode?: string | number, data?: unknown) {
11
+ super(message);
12
+ this.name = 'MojoError';
13
+ this.status = status;
14
+ this.errorCode = errorCode;
15
+ this.data = data;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Thrown by the pre-request auth gate when the access token is expired and
21
+ * cannot be refreshed. The transport recognizes it and short-circuits the
22
+ * request WITHOUT calling fetch — web-mojo's "synthetic 401". web-mojo
23
+ * resolved a 401-shaped response object (its Rest never rejects); here the
24
+ * same short-circuit REJECTS, per the failure-is-unmissable rule.
25
+ */
26
+ export class AuthRequiredError extends MojoError {
27
+ reason: 'unauthorized';
28
+ constructor(message = 'Authentication required') {
29
+ super(message, 401);
30
+ this.name = 'AuthRequiredError';
31
+ this.reason = 'unauthorized';
32
+ }
33
+ }
@@ -0,0 +1,28 @@
1
+ // Group context plumbing — types + the React context object, in their own
2
+ // module so me.ts (useCan reads the active member) and group.tsx (the
3
+ // provider needs useAuthSnapshot from me.ts) never import each other.
4
+ import { createContext } from 'react';
5
+ import type { MemberLike } from './me';
6
+
7
+ export interface Group {
8
+ id: number;
9
+ name: string;
10
+ kind: string;
11
+ uuid?: string | null;
12
+ parent?: { id: number; name: string; kind?: string } | null;
13
+ [field: string]: unknown;
14
+ }
15
+
16
+ export interface GroupContextValue {
17
+ /** The active group, or null when operating in global context. */
18
+ group: Group | null;
19
+ /** The signed-in user's membership in the active group (permissions dict). */
20
+ member: MemberLike | null;
21
+ /** True while boot resolution or a switch is in flight. */
22
+ loading: boolean;
23
+ setActiveGroup(group: Group): void;
24
+ clearActiveGroup(): void;
25
+ }
26
+
27
+ /** Null outside a <GroupProvider> — consumers must handle both. */
28
+ export const GroupContext = createContext<GroupContextValue | null>(null);
@@ -0,0 +1,126 @@
1
+ // GroupProvider — the active-group spine, ported from web-mojo
2
+ // PortalApp.checkActiveGroup / setActiveGroup (PortalApp.js:230-348):
3
+ // · resolution order: `?group=` URL param BEATS the stored id
4
+ // · the param rides the REAL search string (before the hash) —
5
+ // `/?group=5#/users` — so it survives every hash navigation and never
6
+ // fights the per-route params store
7
+ // · storage key `active_group_id` (web-mojo parity)
8
+ // · on activation the user's member record for that group is fetched
9
+ // (`/api/group/<id>/member`) — group-context permissions flow from it
10
+ // · URL-group failure falls back to the stored group, then to none
11
+ import { useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
12
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
13
+ import { mojoCall, mojoGet } from './client';
14
+ import { useAuthSnapshot } from './me';
15
+ import type { MemberLike } from './me';
16
+ import { GroupContext, type Group, type GroupContextValue } from './group-context';
17
+
18
+ const STORAGE_KEY = 'active_group_id';
19
+
20
+ function readUrlGroupParam(): string | null {
21
+ if (typeof window === 'undefined') return null;
22
+ return new URLSearchParams(window.location.search).get('group');
23
+ }
24
+
25
+ function writeUrlGroupParam(id: number | null): void {
26
+ const params = new URLSearchParams(window.location.search);
27
+ if (id == null) params.delete('group');
28
+ else params.set('group', String(id));
29
+ const qs = params.toString();
30
+ window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : '') + (window.location.hash || ''));
31
+ }
32
+
33
+ function loadStoredGroupId(): number | null {
34
+ try {
35
+ const raw = localStorage.getItem(STORAGE_KEY);
36
+ const id = raw == null ? NaN : Number(raw);
37
+ return Number.isFinite(id) && id > 0 ? id : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function saveStoredGroupId(id: number): void {
44
+ try {
45
+ localStorage.setItem(STORAGE_KEY, String(id));
46
+ } catch { /* storage unavailable — context stays session-only */ }
47
+ }
48
+
49
+ function clearStoredGroupId(): void {
50
+ try {
51
+ localStorage.removeItem(STORAGE_KEY);
52
+ } catch { /* ignore */ }
53
+ }
54
+
55
+ export function GroupProvider({ children }: { children: ReactNode }) {
56
+ const auth = useAuthSnapshot();
57
+ const queryClient = useQueryClient();
58
+ const [groupId, setGroupId] = useState<number | null>(() => {
59
+ const urlRaw = readUrlGroupParam();
60
+ const urlId = urlRaw == null ? NaN : Number(urlRaw);
61
+ if (Number.isFinite(urlId) && urlId > 0) return urlId;
62
+ return loadStoredGroupId();
63
+ });
64
+
65
+ const groupQuery = useQuery({
66
+ queryKey: ['group', groupId, auth.uid],
67
+ queryFn: () => mojoGet<Group>('/api/group', groupId!),
68
+ enabled: auth.authenticated && groupId != null,
69
+ staleTime: 5 * 60_000,
70
+ });
71
+
72
+ // web-mojo's fallback chain: a failing URL/stored group falls back to the
73
+ // stored one (when different), else the context clears entirely.
74
+ useEffect(() => {
75
+ if (!groupQuery.isError || groupId == null) return;
76
+ const stored = loadStoredGroupId();
77
+ if (stored != null && stored !== groupId) {
78
+ console.warn(`[portal-mojo] active group ${groupId} failed to load; falling back to stored group ${stored}`);
79
+ writeUrlGroupParam(stored);
80
+ setGroupId(stored);
81
+ } else {
82
+ console.warn(`[portal-mojo] active group ${groupId} failed to load; clearing group context`);
83
+ clearStoredGroupId();
84
+ writeUrlGroupParam(null);
85
+ setGroupId(null);
86
+ }
87
+ }, [groupQuery.isError, groupId]);
88
+
89
+ const group = groupId != null ? (groupQuery.data ?? null) : null;
90
+
91
+ const memberQuery = useQuery({
92
+ queryKey: ['group-member', group?.id, auth.uid],
93
+ queryFn: async () => (await mojoCall(`/api/group/${group!.id}/member`)).data as MemberLike,
94
+ enabled: auth.authenticated && group != null,
95
+ staleTime: 5 * 60_000,
96
+ });
97
+
98
+ const value = useMemo<GroupContextValue>(() => ({
99
+ group,
100
+ member: (group != null ? memberQuery.data : null) ?? null,
101
+ loading: auth.authenticated && groupId != null
102
+ && (groupQuery.isLoading || (group != null && memberQuery.isLoading)),
103
+ setActiveGroup(next: Group) {
104
+ // Seed the cache with the row the switcher already holds — no
105
+ // refetch flash on switch.
106
+ queryClient.setQueryData(['group', next.id, auth.uid], next);
107
+ saveStoredGroupId(next.id);
108
+ writeUrlGroupParam(next.id);
109
+ setGroupId(next.id);
110
+ },
111
+ clearActiveGroup() {
112
+ clearStoredGroupId();
113
+ writeUrlGroupParam(null);
114
+ setGroupId(null);
115
+ },
116
+ }), [group, memberQuery.data, memberQuery.isLoading, groupQuery.isLoading, groupId, auth.authenticated, auth.uid, queryClient]);
117
+
118
+ return <GroupContext.Provider value={value}>{children}</GroupContext.Provider>;
119
+ }
120
+
121
+ /** Active-group context. Throws outside <GroupProvider> — mount it app-wide. */
122
+ export function useActiveGroup(): GroupContextValue {
123
+ const ctx = useContext(GroupContext);
124
+ if (!ctx) throw new Error('useActiveGroup requires a <GroupProvider> above it');
125
+ return ctx;
126
+ }
@@ -0,0 +1,50 @@
1
+ // TanStack Query wraps the client: caching, dedup, background refetch,
2
+ // loading/error states — everything Collection.fetch + fetch:start/end
3
+ // events + stale guards did by hand in web-mojo.
4
+ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
5
+ import { mojoGet, mojoList, mojoSave } from './client';
6
+ import type { MojoList, Params } from './types';
7
+
8
+ export function useModelList<T>(
9
+ endpoint: string,
10
+ params: Params = {},
11
+ opts: { enabled?: boolean; sanitizeRow?: (row: T) => T } = {},
12
+ ) {
13
+ return useQuery({
14
+ queryKey: [endpoint, params],
15
+ queryFn: async (): Promise<MojoList<T>> => {
16
+ const list = await mojoList<T>(endpoint, params);
17
+ return opts.sanitizeRow
18
+ ? { ...list, rows: list.rows.map(opts.sanitizeRow) }
19
+ : list;
20
+ },
21
+ // Old page stays visible while the next loads — no skeleton flash on
22
+ // page/sort changes (web-mojo needed careful fetch-event juggling for
23
+ // this; here it's one option).
24
+ placeholderData: keepPreviousData,
25
+ // Lets ModelTable hold the first request until persisted view state
26
+ // has been rehydrated into the URL (persistState).
27
+ enabled: opts.enabled !== false,
28
+ });
29
+ }
30
+
31
+ export function useModel<T>(endpoint: string, id: number | string | null, sanitizeRow?: (row: T) => T) {
32
+ return useQuery({
33
+ queryKey: [endpoint, 'one', id],
34
+ queryFn: async () => {
35
+ const row = await mojoGet<T>(endpoint, id!);
36
+ return sanitizeRow ? sanitizeRow(row) : row;
37
+ },
38
+ enabled: id != null,
39
+ });
40
+ }
41
+
42
+ /** Save mutation; invalidates every query for the endpoint on success. */
43
+ export function useSaveModel<T>(endpoint: string) {
44
+ const qc = useQueryClient();
45
+ return useMutation({
46
+ mutationFn: ({ id, changes }: { id: number | string | null; changes: Record<string, unknown> }) =>
47
+ mojoSave<T>(endpoint, id, changes),
48
+ onSuccess: () => qc.invalidateQueries({ queryKey: [endpoint] }),
49
+ });
50
+ }
@@ -0,0 +1,37 @@
1
+ // portal-mojo/client — the typed django-mojo protocol layer: envelope unwrap
2
+ // (one boundary; a failed save REJECTS), start/size paging, '-field' sort,
3
+ // Django lookups, the URL-synced table-params store, TanStack Query hooks,
4
+ // and the auth client (password/magic/passkey flows, single-flight refresh,
5
+ // pre-request gate, DUID). The in-memory mock transport (mock.ts) is internal
6
+ // to client.ts — it is the wire contract's executable spec and evolves in
7
+ // lockstep with the client.
8
+ export * from './client';
9
+ export * from './errors';
10
+ export * from './model';
11
+ export * from './auth';
12
+ export * from './jwt';
13
+ export * from './duid';
14
+ export * from './me';
15
+ export * from './group-context';
16
+ export * from './group';
17
+ export * from './hooks';
18
+ export * from './params';
19
+ export * from './lookups';
20
+ export * from './location';
21
+ export * from './markdown';
22
+ export * from './record-feed';
23
+ export * from './safe-export';
24
+ export * from './upload';
25
+ export * from './realtime';
26
+ export * from './realtime-mock';
27
+ export * from './types';
28
+ // Dev affordance: per-endpoint call counts from the mock (single-flight proof).
29
+ export {
30
+ armMockReauth, clearMockRequestHistory, getMockCallCounts,
31
+ getMockRequestHistory, setMockDnsConfigMalformed, setMockDnsAcmeMode, armMockDnsWriteFault,
32
+ getMockPhoneConfigCredentialState, getMockEffectivePhoneConfigId,
33
+ deleteMockPhoneGroupForTest,
34
+ setMockDnsRegistrarMode, armMockRegistrarPurchaseFault,
35
+ getMockPushConfigCredentialState,
36
+ armMockUploadFault, armMockRecordNoteFault, clearMockUploadObservations, getMockUploadObservations, setMockUploadMode,
37
+ } from './mock';
@@ -0,0 +1,75 @@
1
+ // Client-side JWT inspection — decode only, NO signature verification (the
2
+ // server is the authority; the client only needs expiry/identity hints).
3
+ // Ported from web-mojo TokenManager's Token class.
4
+
5
+ export interface JwtPayload {
6
+ uid?: string | number;
7
+ sub?: string | number;
8
+ user_id?: string | number;
9
+ email?: string;
10
+ name?: string;
11
+ username?: string;
12
+ exp?: number;
13
+ iat?: number;
14
+ [claim: string]: unknown;
15
+ }
16
+
17
+ /** Decode a JWT payload (URL-safe base64, padded). Null on any malformation. */
18
+ export function decodeJwt(token: string | null | undefined): JwtPayload | null {
19
+ if (!token || typeof token !== 'string') return null;
20
+ const parts = token.split('.');
21
+ if (parts.length !== 3) return null;
22
+ try {
23
+ let base64 = parts[1]!.replace(/-/g, '+').replace(/_/g, '/');
24
+ const padding = 4 - (base64.length % 4);
25
+ if (padding !== 4) base64 += '='.repeat(padding);
26
+ return JSON.parse(atob(base64)) as JwtPayload;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export interface JwtInfo {
33
+ raw: string;
34
+ payload: JwtPayload;
35
+ uid: string | null;
36
+ email: string | null;
37
+ name: string | null;
38
+ exp: Date | null;
39
+ iat: Date | null;
40
+ /** Decodable and (when an exp claim exists) not past it. No exp ⇒ valid. */
41
+ isValid: boolean;
42
+ isExpired: boolean;
43
+ expiringSoon(thresholdMinutes?: number): boolean;
44
+ /** Minutes since iat, or null when the claim is absent. */
45
+ ageMinutes(): number | null;
46
+ }
47
+
48
+ /** Inspect a token. Null when the token is missing or undecodable. */
49
+ export function tokenInfo(token: string | null | undefined): JwtInfo | null {
50
+ const payload = decodeJwt(token);
51
+ if (!payload || !token) return null;
52
+ const nowSec = () => Math.floor(Date.now() / 1000);
53
+ const isExpired = payload.exp != null ? nowSec() >= payload.exp : false;
54
+ return {
55
+ raw: token,
56
+ payload,
57
+ uid: payload.uid != null ? String(payload.uid)
58
+ : payload.sub != null ? String(payload.sub)
59
+ : payload.user_id != null ? String(payload.user_id) : null,
60
+ email: payload.email ?? null,
61
+ name: payload.name ?? payload.username ?? null,
62
+ exp: payload.exp ? new Date(payload.exp * 1000) : null,
63
+ iat: payload.iat ? new Date(payload.iat * 1000) : null,
64
+ isValid: !isExpired,
65
+ isExpired,
66
+ expiringSoon(thresholdMinutes = 5) {
67
+ if (payload.exp == null) return false;
68
+ return payload.exp - nowSec() <= thresholdMinutes * 60;
69
+ },
70
+ ageMinutes() {
71
+ if (payload.iat == null) return null;
72
+ return Math.floor((nowSec() - payload.iat) / 60);
73
+ },
74
+ };
75
+ }