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,68 @@
1
+ // Network security — perimeter control as ONE admin section under the
2
+ // Security navigation group, beside Security Operations, Bouncer, Devices &
3
+ // Logins and IP Intelligence.
4
+ //
5
+ // The section gate is the ANY-of UNION of the four child gates (the
6
+ // MONITORING_ADMIN_SECTION pattern); each route then carries its exact backend
7
+ // gate. A geofence-only operator (`sys.view_geofence` alone) therefore reaches
8
+ // Geofencing and is denied everywhere else — and the engine's landing redirect
9
+ // resolves to the first route they can actually see, so nobody lands on a
10
+ // denial page.
11
+ import type { AdminSection } from '../index';
12
+ import { BlockedIPsPage } from './BlockedIPsPage';
13
+ import { FirewallLogPage, FIREWALL_LOG_PERMS } from './FirewallLogPage';
14
+ import { IPSetsPage } from './IPSetsPage';
15
+ import { GeofencingPage } from './geofence/GeofencingPage';
16
+ import { GEOIP_VIEW_PERMS } from '../security/geoip';
17
+ import { IPSET_VIEW_PERMS } from './models';
18
+ import { GEOFENCE_VIEW_PERMS } from './geofence/geofence-data';
19
+
20
+ export * from './models';
21
+ export * from './BlockedIPsPage';
22
+ export * from './BlockedIpDetail';
23
+ export * from './FirewallLogPage';
24
+ export * from './IPSetsPage';
25
+ export * from './IPSetDetail';
26
+ export * from './IPSetEditor';
27
+ export * from './geofence/geofence-data';
28
+ export * from './geofence/RuleEditor';
29
+ export * from './geofence/PostureHeader';
30
+ export * from './geofence/RulesTab';
31
+ export * from './geofence/SimulatorTab';
32
+ export * from './geofence/BlocksTab';
33
+ export * from './geofence/ExemptionsTab';
34
+ export * from './geofence/GeofencingPage';
35
+
36
+ export const NETWORK_SECURITY_ADMIN_SECTION: AdminSection = {
37
+ id: 'network-security',
38
+ basePath: 'security/network',
39
+ title: 'Network Security',
40
+ icon: 'bi-hdd-network',
41
+ navigationGroup: 'security',
42
+ // The union of the four route gates — deduped so the sidebar's any-of
43
+ // check stays cheap and the verifier can compare it set-wise.
44
+ permissions: [...new Set([
45
+ ...GEOIP_VIEW_PERMS,
46
+ ...FIREWALL_LOG_PERMS,
47
+ ...IPSET_VIEW_PERMS,
48
+ ...GEOFENCE_VIEW_PERMS,
49
+ ])],
50
+ routes: [
51
+ {
52
+ path: 'blocked-ips', label: 'Blocked IPs',
53
+ component: BlockedIPsPage, permissions: GEOIP_VIEW_PERMS,
54
+ },
55
+ {
56
+ path: 'firewall-log', label: 'Firewall Log',
57
+ component: FirewallLogPage, permissions: FIREWALL_LOG_PERMS,
58
+ },
59
+ {
60
+ path: 'ip-sets', label: 'IP Sets',
61
+ component: IPSetsPage, permissions: IPSET_VIEW_PERMS,
62
+ },
63
+ {
64
+ path: 'geofencing', label: 'Geofencing',
65
+ component: GeofencingPage, permissions: GEOFENCE_VIEW_PERMS,
66
+ },
67
+ ],
68
+ };
@@ -0,0 +1,607 @@
1
+ // Network security model layer — IP sets, the geofence config plane, the
2
+ // geofence evidence projection, and the firewall-log payload contract.
3
+ //
4
+ // Ported from web-mojo `admin/models/IPSet.js` + `admin/security/IPSetView.js`
5
+ // + the five `admin/security/geofence/*` views (all read in full 2026-08-06),
6
+ // reconciled against the authoritative django-mojo sources:
7
+ // · `mojo/apps/incident/models/ipset.py` + `rest/ipset.py`
8
+ // · `mojo/apps/account/rest/geofence.py`
9
+ // · `mojo/apps/account/services/geofence/{engine,evidence}.py`
10
+ // · `mojo/apps/account/models/geolocated_ip.py` (firewall Log rows)
11
+ // · `mojo/apps/metrics/rest/{categories,helpers}.py`
12
+ //
13
+ // `/api/system/geoip` is NOT defined here. #1291 owns it
14
+ // (`admin/security/geoip/models.ts`) — one endpoint, one defineModel, one
15
+ // cache key. Blocked IPs is a projection over that model.
16
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
17
+ import {
18
+ MojoError, defineModel, mojoCall, mojoList, mojoMetrics, useCan,
19
+ type Envelope, type Params,
20
+ } from '../../client';
21
+ import { SECURITY_DELETE_PERMS, SECURITY_MANAGE_PERMS, SECURITY_VIEW_PERMS } from '../security-permissions';
22
+ import { sanitizeEventRow } from '../incidents/sanitize';
23
+ import type { EventRow } from '../incidents/models';
24
+ import { GEOFENCE_VIEW_PERMS, SECURITY_EVENTS_PERMS } from './geofence/geofence-data';
25
+ import type {
26
+ AllowlistPayload, BypassHoldersPayload, GeoDecision, GeoRulesConfig, GeofenceRule,
27
+ } from './geofence/geofence-data';
28
+
29
+ // ── Permissions (backend-derived, `sys.`-pinned, fail-closed) ─────────
30
+
31
+ /** `IPSet.RestMeta.VIEW_PERMS = ["view_security","security"]`. */
32
+ export const IPSET_VIEW_PERMS = SECURITY_VIEW_PERMS;
33
+ /** `IPSet.RestMeta.SAVE_PERMS = ["manage_security","security"]`. */
34
+ export const IPSET_MANAGE_PERMS = SECURITY_MANAGE_PERMS;
35
+ /** `IPSet.RestMeta.DELETE_PERMS = ["manage_security"]` — deliberately narrower. */
36
+ export const IPSET_DELETE_PERMS = SECURITY_DELETE_PERMS;
37
+
38
+ /**
39
+ * `metrics/rest/helpers.check_view_permissions`: for `account == "global"` the
40
+ * required any-of list is exactly `["view_metrics","metrics"]`, checked against
41
+ * the caller's GLOBAL permissions. Every geofence metrics read is gated on this
42
+ * so a geofence-only operator issues no denied request.
43
+ */
44
+ export const METRICS_GLOBAL_VIEW_PERMS = ['sys.view_metrics', 'sys.metrics'];
45
+
46
+ // ── IPSet ─────────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * The `default` graph: `{"exclude": ["data", "source_key"]}`.
50
+ * `source_key` (the AbuseIPDB API key) is excluded from BOTH graphs and an
51
+ * unknown graph name falls back to `default`, so it can never be read back —
52
+ * it is deliberately absent from this type.
53
+ */
54
+ export interface IPSetRow {
55
+ id: number;
56
+ created: number;
57
+ modified: number;
58
+ name: string;
59
+ kind: string;
60
+ description: string | null;
61
+ source: string;
62
+ source_url: string | null;
63
+ is_enabled: boolean;
64
+ cidr_count: number;
65
+ last_synced: number | null;
66
+ sync_error: string | null;
67
+ }
68
+
69
+ /** The `detailed` graph adds `data`: the CIDR list, one entry per line. */
70
+ export interface IPSetDetailedRow extends IPSetRow {
71
+ data: string;
72
+ }
73
+
74
+ /** `IPSet.KIND_CHOICES`, with the source's explanatory create-form labels. */
75
+ export const IPSET_KIND_OPTIONS = [
76
+ { value: 'country', label: 'Country — block all traffic from a country' },
77
+ { value: 'abuse', label: 'Abuse feed — import known attacker IPs' },
78
+ { value: 'datacenter', label: 'Datacenter — block datacenter/hosting ranges' },
79
+ { value: 'custom', label: 'Custom — define your own CIDR list' },
80
+ ];
81
+
82
+ /** The same four values with short labels, for badges and filters. */
83
+ export const IPSET_KIND_BADGE_OPTIONS = [
84
+ { value: 'country', label: 'Country' },
85
+ { value: 'abuse', label: 'Abuse feed' },
86
+ { value: 'datacenter', label: 'Datacenter' },
87
+ { value: 'custom', label: 'Custom' },
88
+ ];
89
+
90
+ /**
91
+ * BACKEND CORRECTION. `IPSet.SOURCE_CHOICES` has FIVE members; web-mojo listed
92
+ * three, so `tor` and `blocklist_de` rows — the two cache-only threat lists —
93
+ * rendered a raw slug in the Source column and could not be filtered at all.
94
+ */
95
+ export const IPSET_SOURCE_OPTIONS = [
96
+ { value: 'ipdeny', label: 'IPDeny (country zones)' },
97
+ { value: 'abuseipdb', label: 'AbuseIPDB' },
98
+ { value: 'tor', label: 'Tor exit list' },
99
+ { value: 'blocklist_de', label: 'blocklist.de' },
100
+ { value: 'manual', label: 'Manual' },
101
+ ];
102
+
103
+ /**
104
+ * `IPSet.THREAT_CACHE_SETS` — sets that exist to feed geoip THREAT DETECTION,
105
+ * not the kernel firewall. `on_action_enable` refuses them outright, and
106
+ * `sync()` silently no-ops for them.
107
+ */
108
+ export const IPSET_CACHE_ONLY_NAMES: readonly string[] = ['tor_exits', 'blocklist_de'];
109
+
110
+ /** The model's own words for why enabling a cache-only set is refused. */
111
+ export const IPSET_CACHE_ONLY_HELP =
112
+ 'geoip detection cache — enabling would kernel-block every listed IP fleet-wide';
113
+
114
+ export function isCacheOnlyIPSet(row: Pick<IPSetRow, 'name'> | null | undefined): boolean {
115
+ return row != null && IPSET_CACHE_ONLY_NAMES.includes(row.name);
116
+ }
117
+
118
+ /**
119
+ * web-mojo's `CommonBlockCountries` — pinned to the top of the country picker
120
+ * so the twenty codes an operator actually reaches for are one scroll away.
121
+ * The FULL ISO2 list follows (from #1426's centroid table); the source offered
122
+ * only these twenty, so no other country could be blocked from the UI at all.
123
+ */
124
+ export const IPSET_COMMON_BLOCK_COUNTRIES: readonly string[] = [
125
+ 'CN', 'RU', 'KP', 'IR', 'NG', 'RO', 'BR', 'IN', 'PK', 'ID',
126
+ 'VN', 'UA', 'TH', 'PH', 'BD', 'EG', 'TR', 'MX', 'AR', 'CO',
127
+ ];
128
+
129
+ const COMMON_PARAMS = new Set(['start', 'size', 'sort', 'search', 'dr_field', 'dr_start', 'dr_end']);
130
+
131
+ /** Filterable fields. `graph` is pinned to `default`, so `data` can never
132
+ * ride a list response into the persistent query cache. */
133
+ const IPSET_FILTERS = new Set([
134
+ 'id', 'id__in', 'name', 'name__icontains', 'description__icontains',
135
+ 'kind', 'kind__in', 'source', 'source__in', 'is_enabled',
136
+ 'cidr_count__gte', 'cidr_count__lte',
137
+ 'last_synced__gte', 'last_synced__lte', 'sync_error__isnull',
138
+ ]);
139
+
140
+ export function normalizeIPSetListParams(params: Params): Params {
141
+ const out: Params = { graph: 'default' };
142
+ for (const [key, value] of Object.entries(params)) {
143
+ if ((COMMON_PARAMS.has(key) || IPSET_FILTERS.has(key)) && value != null && value !== '') out[key] = value;
144
+ }
145
+ out.sort = String(out.sort ?? 'name');
146
+ if (out.dr_start != null || out.dr_end != null) out.dr_field = String(out.dr_field ?? 'last_synced');
147
+ return out;
148
+ }
149
+
150
+ /**
151
+ * `IPSet.RestMeta.POST_SAVE_ACTIONS = ["sync","enable","disable","refresh_source"]`.
152
+ * All four handlers return nothing, so `on_rest_save` answers with the
153
+ * refreshed record — `response: 'row'` (the default) is correct for all of
154
+ * them and the UI re-reads state rather than optimistically toggling.
155
+ *
156
+ * `enable` is the ONLY path that runs the cache-only rejection
157
+ * (`on_action_enable` raises a 400 naming the set). `is_enabled` is therefore
158
+ * NEVER written as a plain field anywhere in this module — web-mojo's create
159
+ * default and edit switch both did, producing a row that reads "Enabled" and
160
+ * silently never syncs. Direct #1097 lineage.
161
+ */
162
+ export const IPSetModel = defineModel<IPSetRow>({
163
+ name: 'ipset',
164
+ endpoint: '/api/incident/ipset',
165
+ permissions: {
166
+ view: IPSET_VIEW_PERMS,
167
+ manage: IPSET_MANAGE_PERMS,
168
+ delete: IPSET_DELETE_PERMS,
169
+ },
170
+ actions: {
171
+ sync: { permissions: IPSET_MANAGE_PERMS },
172
+ enable: { permissions: IPSET_MANAGE_PERMS },
173
+ disable: { permissions: IPSET_MANAGE_PERMS },
174
+ refresh_source: { permissions: IPSET_MANAGE_PERMS },
175
+ },
176
+ normalizeListParams: normalizeIPSetListParams,
177
+ });
178
+
179
+ /** Graph is part of the key so a sparse `default` row cannot satisfy detail. */
180
+ export function ipSetCidrKey(id: number) {
181
+ return [IPSetModel.endpoint, 'one', id, { graph: 'detailed' }] as const;
182
+ }
183
+
184
+ export interface IPSetCidrData {
185
+ /** The stored blob, verbatim. */
186
+ text: string;
187
+ /** Lines that are neither blank nor comments — the HONEST count. */
188
+ lines: string[];
189
+ }
190
+
191
+ /**
192
+ * The only reader of `graph=detailed`, i.e. the only path that sees `data`.
193
+ * `gcTime: 0` keeps a multi-megabyte country zone out of the persistent cache.
194
+ */
195
+ export function useIPSetCidrData(id: number | null, enabled: boolean) {
196
+ return useQuery({
197
+ queryKey: id == null
198
+ ? [IPSetModel.endpoint, 'one', null, { graph: 'detailed' }] as const
199
+ : ipSetCidrKey(id),
200
+ queryFn: async (): Promise<IPSetCidrData> => {
201
+ const body = await mojoCall(`${IPSetModel.endpoint}/${id!}`, { params: { graph: 'detailed' } });
202
+ const raw = body.data as IPSetDetailedRow | null;
203
+ const text = typeof raw?.data === 'string' ? raw.data : '';
204
+ return { text, lines: parseCidrLines(text) };
205
+ },
206
+ enabled: enabled && id != null,
207
+ gcTime: 0,
208
+ staleTime: 0,
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Split a textarea blob into the CIDR list the backend expects.
214
+ *
215
+ * BACKEND TRAP. `data` has a `set_data()` setter, and `on_rest_save_field`
216
+ * prefers `set_<key>` over a plain assignment — so a posted `data` runs
217
+ * `"\n".join(value)`. Posting a STRING therefore interleaves a newline between
218
+ * every CHARACTER and sets `cidr_count` to the character count. The value must
219
+ * be posted as a JSON LIST. web-mojo posted the raw textarea string.
220
+ *
221
+ * Blank lines and `#` comments are dropped (the source's own help text says
222
+ * they are ignored — but the backend does not drop them, so we do).
223
+ */
224
+ export function parseCidrLines(text: string | null | undefined): string[] {
225
+ return String(text ?? '')
226
+ .split(/\r?\n/)
227
+ .map((line) => line.trim())
228
+ .filter((line) => line !== '' && !line.startsWith('#'));
229
+ }
230
+
231
+ // ── Geofence evidence projection ──────────────────────────────────────
232
+
233
+ /**
234
+ * A SECOND `defineModel` on `/api/incident/event`, deliberately.
235
+ *
236
+ * The shared `EventModel` (admin/incidents) allowlists a filter set built for
237
+ * the security-events page; `metadata__reason`, `metadata__geofence_scope` and
238
+ * `metadata__region_code` are not in it and would be silently DROPPED, which
239
+ * is the worst possible failure for a filter (the table would answer with
240
+ * everything and look correct). A local definition keeps
241
+ * `admin/incidents/models.ts` untouched.
242
+ *
243
+ * The two models share `endpoint`, and `defineModel`'s invalidation root is
244
+ * `[endpoint]` — so an invalidation from either reaches both, and their query
245
+ * keys never collide because their params never overlap.
246
+ */
247
+ const GEOFENCE_EVENT_FILTERS = new Set([
248
+ 'id', 'category', 'category__in', 'level', 'level__gte', 'level__lte',
249
+ 'source_ip', 'source_ip__icontains', 'scope',
250
+ // The INDEXED top-level column. `Event.sync_metadata` fills it from the
251
+ // geolocated source IP, so it is populated for geofence blocks and is the
252
+ // right thing to filter on — the metadata copy is not indexed.
253
+ 'country_code', 'country_code__in',
254
+ 'created__gte', 'created__lte',
255
+ // Written by services/geofence/evidence.py. `geofence_scope` — NOT
256
+ // `scope`: the reporter never passes the reporter's `scope=` argument, so
257
+ // the top-level Event column stays "global" for every geofence row.
258
+ // web-mojo read `metadata.scope` and the column was always "—".
259
+ 'metadata__reason', 'metadata__geofence_scope', 'metadata__region_code',
260
+ 'metadata__rule_level', 'metadata__allowlist_source', 'metadata__username',
261
+ ]);
262
+
263
+ export function normalizeGeofenceEventListParams(params: Params): Params {
264
+ const out: Params = { graph: 'default' };
265
+ for (const [key, value] of Object.entries(params)) {
266
+ if ((COMMON_PARAMS.has(key) || GEOFENCE_EVENT_FILTERS.has(key)) && value != null && value !== '') out[key] = value;
267
+ }
268
+ // Day grouping requires chronological contiguity.
269
+ const sort = String(out.sort ?? '-created');
270
+ out.sort = sort === 'created' || sort === '-created' ? sort : '-created';
271
+ if (out.dr_start != null || out.dr_end != null) out.dr_field = String(out.dr_field ?? 'created');
272
+ return out;
273
+ }
274
+
275
+ export const GeofenceEventModel = defineModel<EventRow>({
276
+ name: 'geofence-event',
277
+ endpoint: '/api/incident/event',
278
+ permissions: { view: SECURITY_EVENTS_PERMS },
279
+ normalizeListParams: normalizeGeofenceEventListParams,
280
+ sanitizeRow: sanitizeEventRow,
281
+ });
282
+
283
+ export const GEOFENCE_EVENT_CATEGORIES = {
284
+ block: 'geofence_block',
285
+ exempt: 'geofence_exempt',
286
+ config: 'geofence_config',
287
+ } as const;
288
+
289
+ /**
290
+ * `evidence._block_level` (evidence.py:150-158), labelled from the module's
291
+ * own docstring. These are the only four levels a geofence block can carry.
292
+ */
293
+ export const GEOFENCE_LEVEL_OPTIONS = [
294
+ { value: '3', label: 'Jurisdiction block (3)' },
295
+ { value: '5', label: 'Abuse / fail-closed / strict (5)' },
296
+ { value: '6', label: 'Fail-open pass-through (6)' },
297
+ { value: '7', label: 'Rule error (7)' },
298
+ ];
299
+
300
+ /**
301
+ * The "who" on a `geofence_config` event.
302
+ *
303
+ * BACKEND CORRECTION. `evidence.report_config_change` writes `changed_by` (a
304
+ * username). `reporter._create_event_dict` separately writes `user_name` (the
305
+ * display name) for any authenticated request. web-mojo read
306
+ * `metadata.username`, which NEITHER writes — so the Last-change chip and the
307
+ * change-history "Who" column always rendered blank.
308
+ */
309
+ export function configChangedBy(metadata: Record<string, unknown> | null | undefined): string {
310
+ const changedBy = metadata?.changed_by;
311
+ if (typeof changedBy === 'string' && changedBy) return changedBy;
312
+ const userName = metadata?.user_name;
313
+ if (typeof userName === 'string' && userName) return userName;
314
+ return '';
315
+ }
316
+
317
+ // ── Firewall log ──────────────────────────────────────────────────────
318
+
319
+ /** The exact `kind` values `GeoLocatedIP` writes. */
320
+ export const FIREWALL_LOG_KINDS = {
321
+ block: 'firewall:block',
322
+ unblock: 'firewall:unblock',
323
+ whitelist: 'firewall:whitelist',
324
+ unwhitelist: 'firewall:unwhitelist',
325
+ } as const;
326
+
327
+ /**
328
+ * The parsed firewall payload.
329
+ *
330
+ * COLUMN HONESTY. `Log.path` is the ADMIN's HTTP request path and `Log.ip` is
331
+ * the ADMIN's IP — both come from the ambient request, not from the record
332
+ * being blocked. The blocked address lives only in `payload.ip`. web-mojo
333
+ * labelled `path` as "IP / Path", which was never either.
334
+ *
335
+ * The payload shape is NOT uniform across the four kinds:
336
+ * block {ip, reason, ttl, blocked_until, block_count, trigger}
337
+ * unblock {ip, reason, trigger}
338
+ * whitelist {ip, reason, until, was_blocked, trigger}
339
+ * unwhitelist {ip, trigger}
340
+ */
341
+ export interface FirewallPayload {
342
+ ip?: string;
343
+ reason?: string;
344
+ ttl?: number | null;
345
+ blocked_until?: string | null;
346
+ block_count?: number;
347
+ until?: string | null;
348
+ was_blocked?: boolean;
349
+ trigger?: string;
350
+ }
351
+
352
+ export function firewallPayloadOf(raw: string | null | undefined): FirewallPayload | null {
353
+ if (!raw) return null;
354
+ try {
355
+ const parsed: unknown = JSON.parse(raw);
356
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
357
+ ? parsed as FirewallPayload
358
+ : null;
359
+ } catch {
360
+ return null;
361
+ }
362
+ }
363
+
364
+ // ── Geofence config plane ─────────────────────────────────────────────
365
+
366
+ export const GEO_RULES_ENDPOINT = '/api/geo/rules';
367
+ export const GEO_SIMULATE_ENDPOINT = '/api/geo/simulate';
368
+ export const GEO_ALLOWLIST_ENDPOINT = '/api/geo/allowlist';
369
+ export const GEO_BYPASS_ENDPOINT = '/api/geo/bypass_holders';
370
+
371
+ export const geoKeys = {
372
+ rules: (groupUuid?: string | null) => [GEO_RULES_ENDPOINT, groupUuid ?? null] as const,
373
+ allowlist: () => [GEO_ALLOWLIST_ENDPOINT] as const,
374
+ bypass: () => [GEO_BYPASS_ENDPOINT] as const,
375
+ };
376
+
377
+ /**
378
+ * `/api/geo/*` is simply NOT REGISTERED on a backend older than the config
379
+ * plane, so the wire answer is a 404 — not an error envelope. Every geofence
380
+ * surface branches on this and explains; any other status shows the server's
381
+ * own message.
382
+ */
383
+ export function isGeofenceApiMissing(error: unknown): boolean {
384
+ return error instanceof MojoError && error.status === 404;
385
+ }
386
+
387
+ export const GEOFENCE_API_MISSING_MESSAGE =
388
+ 'This server does not expose the geofence administration API. It was added to django-mojo in v1.2.42; older deployments simply do not register the /api/geo routes.';
389
+
390
+ /** GET /api/geo/rules. One query feeds the posture header and all four tabs. */
391
+ export function useGeoConfig(groupUuid?: string | null) {
392
+ const { can } = useCan(GEOFENCE_VIEW_PERMS);
393
+ return useQuery({
394
+ queryKey: geoKeys.rules(groupUuid),
395
+ queryFn: async (): Promise<GeoRulesConfig> => {
396
+ const body = await mojoCall(GEO_RULES_ENDPOINT, {
397
+ params: groupUuid ? { group_uuid: groupUuid } : {},
398
+ });
399
+ return (body.data ?? {}) as GeoRulesConfig;
400
+ },
401
+ enabled: can,
402
+ });
403
+ }
404
+
405
+ export function useGeoAllowlist() {
406
+ const { can } = useCan(GEOFENCE_VIEW_PERMS);
407
+ return useQuery({
408
+ queryKey: geoKeys.allowlist(),
409
+ queryFn: async (): Promise<AllowlistPayload> => {
410
+ const body = await mojoCall(GEO_ALLOWLIST_ENDPOINT);
411
+ return (body.data ?? {}) as AllowlistPayload;
412
+ },
413
+ enabled: can,
414
+ });
415
+ }
416
+
417
+ export function useBypassHolders() {
418
+ const { can } = useCan(GEOFENCE_VIEW_PERMS);
419
+ return useQuery({
420
+ queryKey: geoKeys.bypass(),
421
+ queryFn: async (): Promise<BypassHoldersPayload> => {
422
+ const body = await mojoCall(GEO_BYPASS_ENDPOINT);
423
+ return (body.data ?? {}) as BypassHoldersPayload;
424
+ },
425
+ enabled: can,
426
+ });
427
+ }
428
+
429
+ /**
430
+ * `POST /api/geo/rules {rule}` — a FULL REPLACE that takes effect fleet-wide
431
+ * immediately and invalidates cached decisions. Never a merge, never the
432
+ * generic Settings editor (which would bypass `validate_rule` entirely).
433
+ * A DSL failure comes back as the validator's own human-readable message.
434
+ */
435
+ export function useSaveGeoRules() {
436
+ const qc = useQueryClient();
437
+ return useMutation({
438
+ mutationFn: async (rule: GeofenceRule) => {
439
+ const body = await mojoCall(GEO_RULES_ENDPOINT, { method: 'POST', body: { rule } });
440
+ return (body.data ?? {}) as { rule?: GeofenceRule; source?: string; modified?: string };
441
+ },
442
+ onSuccess: () => { void qc.invalidateQueries({ queryKey: [GEO_RULES_ENDPOINT] }); },
443
+ });
444
+ }
445
+
446
+ /** `DELETE /api/geo/rules` — drop the portal override; the deploy file (or
447
+ * nothing) becomes effective again. */
448
+ export function useRemoveGeoRulesOverride() {
449
+ const qc = useQueryClient();
450
+ return useMutation({
451
+ mutationFn: async () => {
452
+ const body = await mojoCall(GEO_RULES_ENDPOINT, { method: 'DELETE' });
453
+ return (body.data ?? {}) as { removed?: boolean };
454
+ },
455
+ onSuccess: () => { void qc.invalidateQueries({ queryKey: [GEO_RULES_ENDPOINT] }); },
456
+ });
457
+ }
458
+
459
+ /** `POST /api/geo/simulate` — never cached, never logged, never enforced. */
460
+ export function useGeoSimulate() {
461
+ return useMutation({
462
+ mutationFn: async (body: Record<string, unknown>): Promise<GeoDecision> => {
463
+ const response = await mojoCall(GEO_SIMULATE_ENDPOINT, { method: 'POST', body });
464
+ return (response.data ?? {}) as GeoDecision;
465
+ },
466
+ });
467
+ }
468
+
469
+ export class AllowlistRaceError extends Error {
470
+ constructor() {
471
+ super('The exemption list changed on the server while this form was open. Nothing was saved — reopen the list and try again.');
472
+ this.name = 'AllowlistRaceError';
473
+ }
474
+ }
475
+
476
+ /** The wire shape a `POST /api/geo/allowlist` entry may carry: keys are
477
+ * validated against `{cidr, ip, reason, until}` server-side. */
478
+ export interface AllowlistWriteEntry {
479
+ cidr: string;
480
+ reason?: string;
481
+ until?: string;
482
+ }
483
+
484
+ export interface SaveAllowlistVars {
485
+ /** The full replacement list. */
486
+ entries: AllowlistWriteEntry[];
487
+ /**
488
+ * The `setting` array exactly as it was read when the form opened. The
489
+ * server is refetched and compared against this before the POST.
490
+ */
491
+ baseline: unknown;
492
+ }
493
+
494
+ /**
495
+ * `POST /api/geo/allowlist {entries}` is a FULL REPLACE of the
496
+ * GEOFENCE_ALLOWLIST setting, so a stale local copy silently deletes another
497
+ * operator's concurrent entry. Every write therefore refetches first and
498
+ * ABORTS when the server list has moved. web-mojo posted from possibly-stale
499
+ * local state with no guard at all.
500
+ *
501
+ * An empty list is a legitimate CLEAR, not "no change" — the caller confirms
502
+ * it explicitly.
503
+ */
504
+ export function useSaveGeoAllowlist() {
505
+ const qc = useQueryClient();
506
+ return useMutation({
507
+ mutationFn: async ({ entries, baseline }: SaveAllowlistVars) => {
508
+ const current = await mojoCall(GEO_ALLOWLIST_ENDPOINT);
509
+ const currentSetting = ((current.data ?? {}) as AllowlistPayload).setting ?? [];
510
+ if (JSON.stringify(currentSetting) !== JSON.stringify(baseline)) throw new AllowlistRaceError();
511
+ const body = await mojoCall(GEO_ALLOWLIST_ENDPOINT, { method: 'POST', body: { entries } });
512
+ return (body.data ?? {}) as { entries?: unknown[] };
513
+ },
514
+ onSuccess: () => {
515
+ void qc.invalidateQueries({ queryKey: [GEO_ALLOWLIST_ENDPOINT] });
516
+ void qc.invalidateQueries({ queryKey: [GEO_RULES_ENDPOINT] });
517
+ },
518
+ });
519
+ }
520
+
521
+ // ── Geofence country totals (server-derived, no client row work) ──────
522
+
523
+ const CATEGORY_SLUGS_ENDPOINT = '/api/metrics/category_slugs';
524
+ const COUNTRY_SLUG_PREFIX = 'geofence:blocks:country:';
525
+
526
+ type CategorySlugsEnvelope = Envelope & { slugs?: unknown };
527
+
528
+ export interface CountryTotal {
529
+ country_code: string;
530
+ total: number;
531
+ }
532
+
533
+ /**
534
+ * "Top blocked countries", derived entirely server-side:
535
+ * 1. `GET /api/metrics/category_slugs?category=geofence&account=global`
536
+ * — a FLAT envelope (`{slugs, category, account, status}`); this route
537
+ * returns a raw JsonResponse and does NOT nest under `data`;
538
+ * 2. filter to the `geofence:blocks:country:*` family;
539
+ * 3. ONE `/api/metrics/fetch` over that family, summed per slug.
540
+ *
541
+ * This is never computed from the visible page of events — that would lie
542
+ * about totals whenever the page did not cover the window. It is also exactly
543
+ * the dataset a choropleth would consume, which is why it ships as the
544
+ * geographic answer here (the map itself is #1426's, reserved).
545
+ *
546
+ * `get_category_slugs` returns a SET, so the wire order is nondeterministic;
547
+ * the sort below is the client's, over server-computed totals.
548
+ */
549
+ export function useGeofenceCountryTotals(days = 30, enabled = true) {
550
+ const { can } = useCan(METRICS_GLOBAL_VIEW_PERMS);
551
+ return useQuery({
552
+ queryKey: ['geofence-country-totals', days] as const,
553
+ queryFn: async (): Promise<CountryTotal[]> => {
554
+ const body = await mojoCall(CATEGORY_SLUGS_ENDPOINT, {
555
+ params: { category: 'geofence', account: 'global' },
556
+ }) as CategorySlugsEnvelope;
557
+ const slugs = Array.isArray(body.slugs) ? body.slugs.map(String) : [];
558
+ const countrySlugs = slugs.filter((slug) => slug.startsWith(COUNTRY_SLUG_PREFIX)).sort();
559
+ if (countrySlugs.length === 0) return [];
560
+ const metrics = await mojoMetrics({
561
+ slugs: countrySlugs.join(','),
562
+ account: 'global',
563
+ granularity: 'days',
564
+ range: days > 7 ? '30d' : '7d',
565
+ });
566
+ return metrics.datasets
567
+ .map((series) => ({
568
+ country_code: series.label.slice(COUNTRY_SLUG_PREFIX.length).toUpperCase(),
569
+ total: series.data.reduce((sum, value) => sum + (Number(value) || 0), 0),
570
+ }))
571
+ .filter((row) => row.total > 0)
572
+ .sort((a, b) => b.total - a.total || a.country_code.localeCompare(b.country_code));
573
+ },
574
+ enabled: can && enabled,
575
+ });
576
+ }
577
+
578
+ // ── Convenience: the geoip whitelist leg of the Exemptions tab ────────
579
+
580
+ /**
581
+ * Resolve an address to its GeoLocatedIP id so the `whitelist` action can be
582
+ * addressed. `/api/system/geoip/lookup` CREATES the record when it is absent
583
+ * and refreshes it when expired, behind a 30-per-IP server rate limit — which
584
+ * is exactly right here (an operator is whitelisting an address that may never
585
+ * have been seen), and is why this is an explicit action and never a query.
586
+ */
587
+ export async function resolveGeoIpIdForAddress(endpoint: string, ip: string): Promise<number> {
588
+ const body = await mojoCall(`${endpoint}/lookup`, { params: { ip, auto_refresh: '1' } });
589
+ const row = body.data as { id?: unknown } | null;
590
+ if (row == null || typeof row.id !== 'number') {
591
+ throw new MojoError(`Could not resolve ${ip} to a GeoIP record`, 400);
592
+ }
593
+ return row.id;
594
+ }
595
+
596
+ /**
597
+ * The CHEAP direction: a filtered read of the cache table. Used when the row
598
+ * is known to exist (removing a whitelist), so removal never costs a provider
599
+ * call and never consumes the `/lookup` rate limit.
600
+ */
601
+ export async function findGeoIpIdByAddress(endpoint: string, ip: string): Promise<number> {
602
+ const page = await mojoList<{ id: number }>(endpoint, { ip_address: ip, size: 1, graph: 'default' });
603
+ const id = page.rows[0]?.id;
604
+ if (typeof id !== 'number') throw new MojoError(`No GeoIP record cached for ${ip}`, 404);
605
+ return id;
606
+ }
607
+