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,470 @@
1
+ // Device / login-location model layer — the CANONICAL definitions of
2
+ // `/api/user/device`, `/api/user/device/location` and `/api/account/logins`.
3
+ // `admin/identity/users/models.ts` RE-EXPORTS from here: one endpoint must
4
+ // never have two `defineModel` calls, because two definitions mean two cache
5
+ // keys and therefore a double fetch of the same rows.
6
+ //
7
+ // Ported from web-mojo `admin/account/devices/DeviceView.js` (961),
8
+ // `UserDeviceLocationView.js` (465) and `LoginLocationMapView.js` (422 — the
9
+ // DATA half only; rendering belongs to #1426's WorldMap), reconciled against
10
+ // `django-mojo/mojo/apps/account/models/device.py`, `models/login_event.py`,
11
+ // `rest/device.py` and `rest/login_event.py` (all read in full 2026-08-06).
12
+ //
13
+ // Backend-forced corrections carried here:
14
+ // · `is_trusted` / `is_blocked` DO NOT EXIST on UserDevice. The source's
15
+ // active toggle, "Mark trusted/untrusted" kebab item, Trusted/Blocked
16
+ // chips and trust timeline row all wrote a field the model never had.
17
+ // · `event_type` DOES NOT EXIST on UserLoginEvent, so the source's
18
+ // EVENT_COLORS map coloured every marker grey. Risk tone here rides
19
+ // `is_new_country` / `is_new_region`, which are real and are the actual
20
+ // triage question.
21
+ // · No model here declares `CAN_DELETE`, and `rest.py` defaults it to
22
+ // False — "Forget device", "Delete GeoIP record" and "Delete location
23
+ // record" are all impossible.
24
+ // · `UserDevice` declares NO `SEARCH_FIELDS`; only DUID/user-agent text is
25
+ // searchable via the default fallback, so the UI must not promise more.
26
+ import { useQuery } from '@tanstack/react-query';
27
+ import {
28
+ defineModel, mojoCall, mojoList, useAuthSnapshot,
29
+ type Params,
30
+ } from '../../../client';
31
+ import type { WorldMapTone } from '../../../charts';
32
+ import type { GeoLocatedIPRow } from '../geoip/models';
33
+
34
+ // ── Permissions (backend-derived, `sys.`-pinned, fail-closed) ─────────
35
+ //
36
+ // `sys.manage_devices` is DELIBERATELY ABSENT from every clause below. It
37
+ // passes the `@requires_global_perms` decorator on `/user/device/location`
38
+ // and `/user/device/lookup`, but it is NOT in `UserDeviceLocation.VIEW_PERMS`
39
+ // (`manage_users | users`), so a caller holding only `manage_devices` gets
40
+ // through the URL gate and is then denied by the model. Alone it opens
41
+ // nothing; offering it as an affordance gate would promise access the server
42
+ // refuses.
43
+
44
+ /** `UserDevice.RestMeta.VIEW_PERMS` minus `owner` (that is the self path). */
45
+ export const USER_DEVICE_VIEW_PERMS = ['sys.manage_users', 'sys.users'];
46
+
47
+ /** URL decorator ∩ `UserDeviceLocation.RestMeta.VIEW_PERMS`. */
48
+ export const DEVICE_LOCATION_VIEW_PERMS = ['sys.manage_users', 'sys.users'];
49
+
50
+ /** `UserLoginEvent.RestMeta.VIEW_PERMS` minus `owner`. */
51
+ export const LOGIN_EVENT_VIEW_PERMS = ['sys.manage_users', 'sys.security', 'sys.users'];
52
+
53
+ /** `@requires_global_perms` on `/account/logins/summary` and `/account/logins/user`. */
54
+ export const LOGIN_SUMMARY_PERMS = ['sys.manage_users', 'sys.security', 'sys.users'];
55
+
56
+ // ── Shared sub-graphs ─────────────────────────────────────────────────
57
+
58
+ /** `parse_user_agent` output — the `device_info` JSONField, verbatim. */
59
+ export interface UAInfo {
60
+ os: { major: string | null; minor: string | null; patch: string | null; family: string; patch_minor: string | null };
61
+ device: { brand: string | null; model: string | null; family: string };
62
+ user_agent: { major: string | null; minor: string | null; patch: string | null; family: string };
63
+ string: string;
64
+ }
65
+
66
+ /** The `basic` User sub-graph every row below embeds. */
67
+ export interface UserBasicRef {
68
+ id: number;
69
+ display_name: string | null;
70
+ username: string;
71
+ last_login: number | null;
72
+ last_activity: number | null;
73
+ is_active: boolean;
74
+ is_email_verified: boolean;
75
+ is_phone_verified: boolean;
76
+ is_dob_verified: boolean;
77
+ avatar: unknown;
78
+ }
79
+
80
+ // ── UserDevice ────────────────────────────────────────────────────────
81
+
82
+ /** `/api/user/device` default graph. NOTE: no `is_trusted`, no `is_blocked`. */
83
+ export interface UserDeviceRow {
84
+ id: number;
85
+ user: UserBasicRef | null;
86
+ /** Server-controlled device identity (the HttpOnly `_muid` cookie). */
87
+ muid: string | null;
88
+ /** Client-claimed localStorage id; `ua-hash-…` for API-born devices. */
89
+ duid: string;
90
+ device_info: UAInfo | null;
91
+ user_agent_hash: string | null;
92
+ last_ip: string | null;
93
+ first_seen: number;
94
+ last_seen: number;
95
+ }
96
+
97
+ /** One `active_sessions[]` tab entry (BouncerSignal, grouped by `mtab`). */
98
+ export interface DeviceSessionTab {
99
+ mtab: string;
100
+ started: number;
101
+ last_activity: number;
102
+ signal_count: number;
103
+ }
104
+
105
+ /** One 24h browser session, grouped by `msid`. */
106
+ export interface DeviceSession {
107
+ msid: string;
108
+ started: number;
109
+ last_activity: number;
110
+ ip: string | null;
111
+ signal_count: number;
112
+ tabs: DeviceSessionTab[];
113
+ }
114
+
115
+ /** `recent_locations[]` — the last 10 locations, flattened by the property. */
116
+ export interface DeviceRecentLocation {
117
+ ip_address: string;
118
+ first_seen: number;
119
+ last_seen: number;
120
+ city?: string;
121
+ country?: string;
122
+ }
123
+
124
+ /** Pre-auth reputation for this device's MUID (`BouncerDevice`). */
125
+ export interface DeviceBouncerRef {
126
+ risk_tier: string;
127
+ event_count: number;
128
+ block_count: number;
129
+ fingerprint_id: string;
130
+ first_seen: number;
131
+ last_seen: number;
132
+ }
133
+
134
+ /**
135
+ * The `sessions` graph. DeviceView's "Session history is not yet recorded
136
+ * server-side" placeholder is OBSOLETE — `bouncer_device`, `active_sessions`
137
+ * and `recent_locations` are all real `extra` properties on the model.
138
+ */
139
+ export interface UserDeviceSessionsRow {
140
+ id: number;
141
+ user: UserBasicRef | null;
142
+ muid: string | null;
143
+ duid: string;
144
+ device_info: UAInfo | null;
145
+ last_ip: string | null;
146
+ first_seen: number;
147
+ last_seen: number;
148
+ bouncer_device: DeviceBouncerRef | null;
149
+ active_sessions: DeviceSession[];
150
+ recent_locations: DeviceRecentLocation[];
151
+ }
152
+
153
+ export const UserDeviceModel = defineModel<UserDeviceRow>({
154
+ name: 'user_device',
155
+ endpoint: '/api/user/device',
156
+ permissions: { view: USER_DEVICE_VIEW_PERMS },
157
+ });
158
+
159
+ /** Graph is part of the key so a sparse default row cannot satisfy it. */
160
+ export function deviceSessionsKey(id: number) {
161
+ return [UserDeviceModel.endpoint, 'one', id, { graph: 'sessions' }] as const;
162
+ }
163
+
164
+ export function useUserDeviceSessions(id: number | null, enabled = true) {
165
+ return useQuery({
166
+ queryKey: id == null
167
+ ? [UserDeviceModel.endpoint, 'one', null, { graph: 'sessions' }] as const
168
+ : deviceSessionsKey(id),
169
+ queryFn: async () => {
170
+ const body = await mojoCall(`${UserDeviceModel.endpoint}/${id!}`, { params: { graph: 'sessions' } });
171
+ return body.data as UserDeviceSessionsRow;
172
+ },
173
+ enabled: enabled && id != null,
174
+ });
175
+ }
176
+
177
+ // ── UserDeviceLocation ────────────────────────────────────────────────
178
+
179
+ /**
180
+ * The `user_device` sub-graph on a location row is `basic`:
181
+ * `["muid","duid","last_ip","last_seen","device_info"]` — it carries NO `id`,
182
+ * so a cross-link from a location back to its device must resolve by `duid`.
183
+ */
184
+ export interface DeviceLocationDeviceRef {
185
+ muid: string | null;
186
+ duid: string;
187
+ last_ip: string | null;
188
+ last_seen: number;
189
+ device_info: UAInfo | null;
190
+ }
191
+
192
+ export interface UserDeviceLocationRow {
193
+ id: number;
194
+ user: UserBasicRef | null;
195
+ user_device: DeviceLocationDeviceRef | null;
196
+ ip_address: string;
197
+ /** The embedded GeoLocatedIP `default` graph, or null while unenriched. */
198
+ geolocation: GeoLocatedIPRow | null;
199
+ first_seen: number;
200
+ last_seen: number;
201
+ }
202
+
203
+ export const UserDeviceLocationModel = defineModel<UserDeviceLocationRow>({
204
+ name: 'user_device_location',
205
+ endpoint: '/api/user/device/location',
206
+ permissions: { view: DEVICE_LOCATION_VIEW_PERMS },
207
+ });
208
+
209
+ // ── UserLoginEvent ────────────────────────────────────────────────────
210
+
211
+ /**
212
+ * `/api/account/logins`. `CAN_CREATE`, `CAN_UPDATE` and `CAN_DELETE` are all
213
+ * explicitly False — this model is read-only by contract, hence no `manage`
214
+ * permission and no actions.
215
+ */
216
+ export interface LoginEventRow {
217
+ id: number;
218
+ ip_address: string | null;
219
+ country_code: string | null;
220
+ region: string | null;
221
+ region_code: string | null;
222
+ city: string | null;
223
+ latitude: number | null;
224
+ longitude: number | null;
225
+ source: string | null;
226
+ is_new_country: boolean;
227
+ is_new_region: boolean;
228
+ created: number;
229
+ modified?: number;
230
+ user: UserBasicRef | null;
231
+ /** `default` graph only — the `basic` UserDevice sub-graph. */
232
+ device?: DeviceLocationDeviceRef | null;
233
+ user_agent_info?: UAInfo | null;
234
+ }
235
+
236
+ export const LoginEventModel = defineModel<LoginEventRow>({
237
+ name: 'login_event',
238
+ endpoint: '/api/account/logins',
239
+ permissions: { view: LOGIN_EVENT_VIEW_PERMS },
240
+ });
241
+
242
+ /**
243
+ * Login risk → map/dot tone, from fields the wire ACTUALLY sends.
244
+ *
245
+ * NOT to be confused with `loginEventTone(eventType)` exported by
246
+ * `portal-mojo/charts`: that one maps the login event-type VOCABULARY
247
+ * (`success_login`, `failed_login`, …) which `UserLoginEvent` has never had a
248
+ * field for — feeding it a row would return `'mute'` for every marker, which
249
+ * is precisely the web-mojo bug this port fixes. Same domain, different
250
+ * input; both are kept because #1287/#1292 may bind a real event-type series.
251
+ */
252
+ export function loginRiskTone(row: Pick<LoginEventRow, 'is_new_country' | 'is_new_region'>): WorldMapTone {
253
+ if (row.is_new_country) return 'bad';
254
+ if (row.is_new_region) return 'warn';
255
+ return 'ok';
256
+ }
257
+
258
+ /** The map legend the tone above implies. */
259
+ export const LOGIN_RISK_LEGEND: readonly { key: string; label: string; tone: WorldMapTone }[] = [
260
+ { key: 'new-country', label: 'New country', tone: 'bad' },
261
+ { key: 'new-region', label: 'New region', tone: 'warn' },
262
+ { key: 'known', label: 'Known location', tone: 'ok' },
263
+ ];
264
+
265
+ export function loginRiskLegendKey(row: Pick<LoginEventRow, 'is_new_country' | 'is_new_region'>): string {
266
+ if (row.is_new_country) return 'new-country';
267
+ if (row.is_new_region) return 'new-region';
268
+ return 'known';
269
+ }
270
+
271
+ // ── Login-location aggregation (the LoginLocationMapView data half) ───
272
+
273
+ /**
274
+ * One `/summary` or `/user` row. `_build_aggregation` groups by
275
+ * `country_code` (or `country_code, region` while drilling) and annotates
276
+ * `count`, `Avg(latitude)`, `Avg(longitude)` and the matching new-* count.
277
+ * The two branches emit DIFFERENT keys, so both are optional here.
278
+ */
279
+ export interface LoginLocationSummaryRow {
280
+ country_code: string | null;
281
+ region?: string | null;
282
+ count: number;
283
+ latitude: number | null;
284
+ longitude: number | null;
285
+ new_country_count?: number;
286
+ new_region_count?: number;
287
+ }
288
+
289
+ export interface LoginLocationSummaryArgs {
290
+ /** Present → `/account/logins/user?user_id=`; absent → `/summary`. */
291
+ userId?: number | null;
292
+ /** Present → drill into this country's regions. Must be /^[A-Z]{2,3}$/. */
293
+ countryCode?: string | null;
294
+ /** YYYY-MM-DD. `/summary` and `/user` accept ONLY dr_start/dr_end. */
295
+ drStart?: string | null;
296
+ drEnd?: string | null;
297
+ enabled?: boolean;
298
+ }
299
+
300
+ const COUNTRY_CODE_RE = /^[A-Z]{2,3}$/;
301
+
302
+ /** `_validate_country_code`: uppercased, 2–3 letters, else silently ignored. */
303
+ export function normalizeCountryCode(value: string | null | undefined): string | null {
304
+ if (!value) return null;
305
+ const upper = String(value).toUpperCase();
306
+ return COUNTRY_CODE_RE.test(upper) ? upper : null;
307
+ }
308
+
309
+ /**
310
+ * Aggregated login geography.
311
+ *
312
+ * Two traps encoded here:
313
+ * · `login_event.py` reads `request.DATA.get('region')` with NO string
314
+ * coercion, so the STRING "false" is truthy and would silently drill.
315
+ * `region=1` is therefore sent ONLY while drilling, never `region=false`.
316
+ * · `user_id` must parse as an int or the endpoint answers
317
+ * `{status: false, code: 400}` — a non-numeric id is treated as "no user"
318
+ * rather than being forwarded.
319
+ */
320
+ export function useLoginLocationSummary(args: LoginLocationSummaryArgs) {
321
+ const auth = useAuthSnapshot();
322
+ const userId = Number.isInteger(args.userId) ? Number(args.userId) : null;
323
+ const country = normalizeCountryCode(args.countryCode);
324
+ const path = userId != null ? '/api/account/logins/user' : '/api/account/logins/summary';
325
+
326
+ const params: Params = {};
327
+ if (userId != null) params.user_id = userId;
328
+ if (args.drStart) params.dr_start = args.drStart;
329
+ if (args.drEnd) params.dr_end = args.drEnd;
330
+ if (country) {
331
+ params.country_code = country;
332
+ // Only ever sent while drilling — `region=false` would ALSO drill.
333
+ params.region = 1;
334
+ }
335
+
336
+ return useQuery({
337
+ queryKey: [path, auth.uid, params] as const,
338
+ queryFn: async (): Promise<LoginLocationSummaryRow[]> => {
339
+ const body = await mojoCall(path, { params });
340
+ const rows = body.data;
341
+ return Array.isArray(rows) ? (rows as LoginLocationSummaryRow[]) : [];
342
+ },
343
+ enabled: args.enabled !== false && auth.authenticated && Boolean(auth.uid),
344
+ });
345
+ }
346
+
347
+ export interface LoginLocationListArgs {
348
+ userId?: number | null;
349
+ drStart?: string | null;
350
+ drEnd?: string | null;
351
+ /** Bounded at 500 — the source's docstring said 500 and its code sent
352
+ * 1000 (LoginLocationMapView.js:208). The docstring is the sane bound. */
353
+ size?: number;
354
+ enabled?: boolean;
355
+ }
356
+
357
+ /** The `list` graph carries lat/lng plus the `basic` user — everything the
358
+ * "every login" mode plots and every tooltip needs, and nothing more. */
359
+ export function useLoginLocationList(args: LoginLocationListArgs) {
360
+ const auth = useAuthSnapshot();
361
+ const userId = Number.isInteger(args.userId) ? Number(args.userId) : null;
362
+ const params: Params = {
363
+ graph: 'list',
364
+ sort: '-created',
365
+ size: Math.min(500, Math.max(1, args.size ?? 500)),
366
+ };
367
+ if (userId != null) params.user = userId;
368
+ // The LIST endpoint uses the dr_field/dr_start/dr_end triple — a different
369
+ // wire from /summary's bare dr_start/dr_end. Always name dr_field.
370
+ if (args.drStart || args.drEnd) {
371
+ params.dr_field = 'created';
372
+ if (args.drStart) params.dr_start = args.drStart;
373
+ if (args.drEnd) params.dr_end = args.drEnd;
374
+ }
375
+ return useQuery({
376
+ queryKey: [LoginEventModel.endpoint, 'list', auth.uid, params] as const,
377
+ queryFn: () => mojoList<LoginEventRow>(LoginEventModel.endpoint, params),
378
+ enabled: args.enabled !== false && auth.authenticated && Boolean(auth.uid),
379
+ });
380
+ }
381
+
382
+ // ── Device presentation helpers (DeviceView.js pure functions) ────────
383
+
384
+ /** "Chrome 148" — `browserLabel` from DeviceView.js:64-68. */
385
+ export function browserLabel(info: UAInfo | null | undefined): string {
386
+ const ua = info?.user_agent;
387
+ const parts = [ua?.family, ua?.major].filter(Boolean);
388
+ return parts.length ? parts.join(' ') : 'Unknown browser';
389
+ }
390
+
391
+ /** "Mac OS X 10.15" — `osLabel` from DeviceView.js:71-75. */
392
+ export function osLabel(info: UAInfo | null | undefined): string {
393
+ const os = info?.os;
394
+ if (!os?.family) return 'Unknown OS';
395
+ const version = [os.major, os.minor].filter(Boolean).join('.');
396
+ return `${os.family} ${version}`.trim();
397
+ }
398
+
399
+ /** "Apple iPhone" — `_getDevice` from UserDeviceLocationView.js:123-127. */
400
+ export function deviceLabel(info: UAInfo | null | undefined): string {
401
+ const device = info?.device;
402
+ const parts = [device?.brand, device?.family].filter(Boolean);
403
+ return parts.length ? parts.join(' ') : 'Unknown device';
404
+ }
405
+
406
+ /** Full dotted browser version, empty when the UA carried none. */
407
+ export function browserVersion(info: UAInfo | null | undefined): string {
408
+ const ua = info?.user_agent;
409
+ return [ua?.major, ua?.minor, ua?.patch].filter((v) => v != null && v !== '').join('.');
410
+ }
411
+
412
+ export function osVersion(info: UAInfo | null | undefined): string {
413
+ const os = info?.os;
414
+ return [os?.major, os?.minor, os?.patch].filter((v) => v != null && v !== '').join('.');
415
+ }
416
+
417
+ /** `pickIcon` from DeviceView.js:78-94, verbatim precedence. */
418
+ export function deviceIcon(info: UAInfo | null | undefined): string {
419
+ const browser = (info?.user_agent?.family ?? '').toLowerCase();
420
+ const os = (info?.os?.family ?? '').toLowerCase();
421
+ const device = (info?.device?.family ?? '').toLowerCase();
422
+ if (browser.includes('chrome')) return 'bi-browser-chrome';
423
+ if (browser.includes('firefox')) return 'bi-browser-firefox';
424
+ if (browser.includes('safari')) return 'bi-browser-safari';
425
+ if (browser.includes('edge')) return 'bi-browser-edge';
426
+ if (os.includes('mac') || os.includes('ios')) return 'bi-apple';
427
+ if (os.includes('windows')) return 'bi-windows';
428
+ if (os.includes('android')) return 'bi-android2';
429
+ if (os.includes('linux')) return 'bi-ubuntu';
430
+ if (device.includes('iphone')) return 'bi-phone';
431
+ if (device.includes('ipad')) return 'bi-tablet';
432
+ return 'bi-laptop';
433
+ }
434
+
435
+ /** `daysActive` from DeviceView.js:118-123 — first_seen → last_seen span. */
436
+ export function daysActive(row: Pick<UserDeviceRow, 'first_seen' | 'last_seen'> | null | undefined): number | null {
437
+ if (row?.first_seen == null || row.last_seen == null) return null;
438
+ return Math.max(0, Math.floor((row.last_seen - row.first_seen) / 86400));
439
+ }
440
+
441
+ export type DevicePresence = 'online' | 'offline' | 'never';
442
+
443
+ /** `_buildHeaderAux`'s presence rule: online when last_seen < 5 minutes. */
444
+ export function presenceOf(lastSeen: number | null | undefined): DevicePresence {
445
+ if (lastSeen == null) return 'never';
446
+ return Date.now() / 1000 - lastSeen < 300 ? 'online' : 'offline';
447
+ }
448
+
449
+ // ── Cross-account sharing ─────────────────────────────────────────────
450
+
451
+ /**
452
+ * "Is this device shared across accounts?" — the item's headline question.
453
+ *
454
+ * `UserDevice` is unique on `(user, duid)`, so the SAME physical browser
455
+ * signing into two accounts produces two rows sharing a `muid` (and usually
456
+ * a `duid`). MUID is server-controlled and therefore authoritative; pre-
457
+ * Bouncer rows have an empty `muid` and fall back to `duid`.
458
+ */
459
+ export function useDeviceSiblings(device: UserDeviceRow | null | undefined, enabled = true) {
460
+ const muid = device?.muid || '';
461
+ const duid = device?.duid || '';
462
+ const key: Params = muid ? { muid } : duid ? { duid } : {};
463
+ const usable = Boolean(muid || duid);
464
+ const query = UserDeviceModel.useList(
465
+ { ...key, size: 25, sort: '-last_seen' },
466
+ { enabled: enabled && usable },
467
+ );
468
+ const siblings = (query.data?.rows ?? []).filter((row) => row.id !== device?.id);
469
+ return { ...query, siblings };
470
+ }
@@ -0,0 +1,135 @@
1
+ // GeoIP Cache — the GeoLocatedIPTablePage port. The source shipped six
2
+ // filters and a search placeholder promising "IP, country, or ISP"; the
3
+ // model's real SEARCH_FIELDS are `ip_address, city, country_name, asn_org,
4
+ // isp`, and eleven more fields are indexed and filterable. Both are corrected
5
+ // here rather than reproduced.
6
+ import { useState } from 'react';
7
+ import { useQueryClient } from '@tanstack/react-query';
8
+ import {
9
+ Badge, ModelTable, formModal, fmt, toast,
10
+ type Column, type FilterDef, type Tone,
11
+ } from '../../../ui';
12
+ import { useCan } from '../../../client';
13
+ import { COUNTRY_OPTIONS } from '../../../charts';
14
+ import { GEOIP_LOOKUP_FIELDS } from './geoip-forms';
15
+ import { showGeoIpDossier } from './GeoIpDossier';
16
+ import {
17
+ GEOIP_MANAGE_PERMS, GeoLocatedIPModel, THREAT_LEVEL_OPTIONS,
18
+ blockActive, blockExpired, countryFlag, geoIpSafeExporter, lookupGeoIp,
19
+ threatTone, whitelistActive, whitelistExpired,
20
+ type GeoLocatedIPRow,
21
+ } from './models';
22
+
23
+ const COLUMNS: Column<GeoLocatedIPRow>[] = [
24
+ {
25
+ key: 'ip_address', label: 'IP address', sortable: true, hideable: false,
26
+ render: (row) => <code>{row.ip_address}</code>,
27
+ },
28
+ { key: 'city', label: 'City', sortable: true, render: (row) => row.city || '—' },
29
+ { key: 'region', label: 'Region', sortable: true, render: (row) => row.region || '—' },
30
+ {
31
+ key: 'country_name', label: 'Country', sortable: true,
32
+ render: (row) => {
33
+ const flag = countryFlag(row.country_code);
34
+ if (!row.country_code && !row.country_name) return '—';
35
+ return <>{flag && <span className="geoip-flag">{flag}</span>}{row.country_name || row.country_code}</>;
36
+ },
37
+ },
38
+ { key: 'isp', label: 'ISP', sortable: true, render: (row) => row.isp || row.asn_org || '—' },
39
+ {
40
+ key: 'threat_level', label: 'Threat', sortable: true,
41
+ render: (row) => <Badge tone={threatTone(row.threat_level) as Tone}>{row.threat_level || 'none'}</Badge>,
42
+ },
43
+ {
44
+ // Enforcement is COMPUTED, not `is_blocked` — an expired block reads
45
+ // "Block expired", and a whitelist beats a block, exactly like the
46
+ // model's Python properties.
47
+ key: 'is_blocked', label: 'Enforcement', sortable: true,
48
+ render: (row) => {
49
+ if (blockActive(row)) return <Badge tone="danger"><i className="bi bi-slash-circle" /> Blocked</Badge>;
50
+ if (whitelistActive(row)) return <Badge tone="info"><i className="bi bi-shield-check" /> Whitelisted</Badge>;
51
+ if (blockExpired(row)) return <Badge tone="warning">Block expired</Badge>;
52
+ if (whitelistExpired(row)) return <Badge tone="warning">Whitelist expired</Badge>;
53
+ return <span className="dim">—</span>;
54
+ },
55
+ },
56
+ { key: 'last_seen', label: 'Last seen', sortable: true, render: (row) => fmt.relative(row.last_seen) },
57
+ ];
58
+
59
+ const FILTERS: FilterDef[] = [
60
+ { key: 'country_code', label: 'Country', type: 'select', options: [...COUNTRY_OPTIONS] },
61
+ { key: 'threat_level', label: 'Threat level', type: 'select', options: THREAT_LEVEL_OPTIONS.filter((o) => o.value !== '') },
62
+ { key: 'isp', label: 'ISP contains', type: 'text' },
63
+ { key: 'asn_org', label: 'ASN org contains', type: 'text' },
64
+ { key: 'is_blocked', label: 'Blocked', type: 'boolean' },
65
+ { key: 'is_whitelisted', label: 'Whitelisted', type: 'boolean' },
66
+ { key: 'is_vpn', label: 'VPN', type: 'boolean' },
67
+ { key: 'is_tor', label: 'Tor', type: 'boolean' },
68
+ { key: 'is_proxy', label: 'Proxy', type: 'boolean' },
69
+ { key: 'is_datacenter', label: 'Datacenter', type: 'boolean' },
70
+ { key: 'is_known_attacker', label: 'Known attacker', type: 'boolean' },
71
+ { key: 'last_seen', label: 'Last seen', type: 'daterange' },
72
+ ];
73
+
74
+ export function GeoIpCachePage() {
75
+ const qc = useQueryClient();
76
+ const canManage = useCan(GEOIP_MANAGE_PERMS).can;
77
+ const [busy, setBusy] = useState(false);
78
+
79
+ /**
80
+ * "Look up IP" is the ONE place `/system/geoip/lookup` is called. It
81
+ * creates and refreshes records at provider cost behind a 30/ip rate
82
+ * limit, so it is never a background query — and a 429 lands as a toast
83
+ * rather than a silent no-op.
84
+ */
85
+ const onLookup = async () => {
86
+ if (busy) return;
87
+ const data = await formModal({
88
+ title: 'Look up IP',
89
+ fields: GEOIP_LOOKUP_FIELDS,
90
+ submitText: 'Look up',
91
+ intro: 'Fetches geolocation and threat intelligence from the configured provider and caches the result.',
92
+ });
93
+ const ip = String(data?.ip ?? '').trim();
94
+ if (!ip) return;
95
+ setBusy(true);
96
+ try {
97
+ const row = await lookupGeoIp(qc, ip);
98
+ toast.success(`Looked up ${row.ip_address}`);
99
+ showGeoIpDossier(row.id);
100
+ } catch (err) {
101
+ toast.error(err instanceof Error ? err.message : `Lookup failed for ${ip}`);
102
+ } finally {
103
+ setBusy(false);
104
+ }
105
+ };
106
+
107
+ return (
108
+ <ModelTable<GeoLocatedIPRow>
109
+ model={GeoLocatedIPModel}
110
+ eyebrow="Security · IP Intelligence"
111
+ title="GeoIP Cache"
112
+ searchPlaceholder="Search IP, city, country, ASN org, or ISP"
113
+ columns={COLUMNS}
114
+ filters={FILTERS}
115
+ presets={[
116
+ { key: 'all', label: 'All', params: {} },
117
+ { key: 'threats', label: 'Threats', params: { threat_level__in: 'high,critical' } },
118
+ { key: 'blocked', label: 'Blocked', params: { is_blocked: 'true' } },
119
+ { key: 'whitelisted', label: 'Whitelisted', params: { is_whitelisted: 'true' } },
120
+ // One preset is one param bundle, and Django cannot OR across
121
+ // three boolean columns — so this is honestly "Tor exits",
122
+ // not a catch-all "Anonymizers". VPN and Proxy are filters.
123
+ { key: 'tor', label: 'Tor exits', params: { is_tor: 'true' } },
124
+ ]}
125
+ defaultSort="-last_seen"
126
+ columnChooser
127
+ persistState
128
+ persistKey="admin:geoip:cache"
129
+ exporter={geoIpSafeExporter}
130
+ exportFormats={['csv', 'json']}
131
+ {...(canManage ? { addLabel: 'Look up IP', onAdd: () => void onLookup() } : {})}
132
+ onRowClick={(row) => showGeoIpDossier(row.id)}
133
+ />
134
+ );
135
+ }