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,321 @@
1
+ // LoginLocationMap — the port of web-mojo `LoginLocationMapView.js`'s DATA
2
+ // half, rendered THROUGH #1426's WorldMap. No projection, SVG, marker
3
+ // geometry or colour ramp lives here: the single `portal-mojo/charts` import
4
+ // below is the ONLY coupling point, so reconciling with the map component
5
+ // ever again touches exactly one file.
6
+ //
7
+ // Three source behaviours are corrected rather than reproduced:
8
+ //
9
+ // 1. **Colour by what the wire sends.** `EVENT_COLORS` keyed on
10
+ // `ev.event_type`, a field `UserLoginEvent` has never had — so every dot
11
+ // fell through to grey. Tone here rides `is_new_country` /
12
+ // `is_new_region`, which are real, indexed, and exactly the triage
13
+ // question this surface exists to answer.
14
+ // 2. **Centroids beat server averages.** `_build_aggregation` returns
15
+ // `Avg(latitude)`/`Avg(longitude)` per country; for a country spanning
16
+ // the antimeridian that average lands in the wrong ocean. Summary
17
+ // markers position from `COUNTRY_CENTROIDS` first and fall back to the
18
+ // server average only for codes the table does not know. Region rows
19
+ // have no centroid and always use the average.
20
+ // 3. **Bounded at 500.** The source docstring says `size=500`; the code
21
+ // sent `size: 1000` (`:208`). The docstring wins.
22
+ //
23
+ // Coastlines: `WORLD_LAND` (checked-in public-domain Natural Earth 110m, no
24
+ // runtime dependency) is passed as the basemap. Rendering correctly WITHOUT it
25
+ // remains a hard requirement — drop the prop and WorldMap falls back to ocean +
26
+ // graticule, with the markers, legend, tooltips, drill bar and status line
27
+ // carrying the whole story either way.
28
+ import { useMemo, useState, type ReactNode } from 'react';
29
+ import {
30
+ COUNTRY_CENTROIDS, WorldMap, countryName, scaleMarkerSize, useWorldLand,
31
+ type WorldMapMarker,
32
+ } from '../../../charts';
33
+ import { fmt } from '../../../ui';
34
+ import {
35
+ LOGIN_RISK_LEGEND, loginRiskLegendKey, loginRiskTone,
36
+ normalizeCountryCode, useLoginLocationList, useLoginLocationSummary,
37
+ type LoginEventRow, type LoginLocationSummaryRow,
38
+ } from './models';
39
+
40
+ export type LoginMapMode = 'summary' | 'list';
41
+
42
+ /** What a summary marker hands back to the drill/select handlers. */
43
+ export interface LoginSummaryMarkerData {
44
+ countryCode: string | null;
45
+ region: string | null;
46
+ isRegion: boolean;
47
+ count: number;
48
+ }
49
+
50
+ export type LoginMarkerData = LoginSummaryMarkerData | LoginEventRow;
51
+
52
+ export interface LoginLocationMapProps {
53
+ /** Scopes both legs to one user (UserDetail's Logins → Map tab). */
54
+ userId?: number | null;
55
+ height?: number;
56
+ /** YYYY-MM-DD — `/summary` and `/user` parse only this shape. */
57
+ drStart?: string | null;
58
+ drEnd?: string | null;
59
+ defaultMode?: LoginMapMode;
60
+ /** Single-click a country marker. NOTE: WorldMap defers a single click
61
+ * 250ms so a double-click (drill-down) can cancel it — by design. */
62
+ onCountrySelect?: (countryCode: string) => void;
63
+ /** List mode, global scope only: the tooltip's "View user" affordance. */
64
+ onOpenUser?: (userId: number) => void;
65
+ /** List mode: opens the login event's detail modal. */
66
+ onOpenLogin?: (id: number) => void;
67
+ /** Hides the Summary/List toggle when the caller wants one fixed mode. */
68
+ showModeToggle?: boolean;
69
+ enabled?: boolean;
70
+ className?: string;
71
+ }
72
+
73
+ /** Marker diameter for summary mode. `[18, 44]` is the source's
74
+ * `Math.round(18 + intensity * 26)` expressed as a range. */
75
+ const SUMMARY_SIZE_RANGE: readonly [number, number] = [18, 44];
76
+
77
+ /** Every individual login plots at the same small size (source: `size: 10`). */
78
+ const LIST_MARKER_SIZE = 10;
79
+
80
+ function summaryMarkers(
81
+ rows: readonly LoginLocationSummaryRow[],
82
+ drilling: boolean,
83
+ ): WorldMapMarker<LoginSummaryMarkerData>[] {
84
+ const max = rows.reduce((top, row) => Math.max(top, Number(row.count) || 0), 0);
85
+ const out: WorldMapMarker<LoginSummaryMarkerData>[] = [];
86
+
87
+ for (const row of rows) {
88
+ const code = normalizeCountryCode(row.country_code);
89
+ const isRegion = Boolean(row.region);
90
+ const centroid = !isRegion && code ? COUNTRY_CENTROIDS[code] : undefined;
91
+ // Centroid first (correction 2); the server average is the fallback,
92
+ // and is the ONLY source for a drilled region row.
93
+ const lat = centroid ? centroid.lat : row.latitude;
94
+ const lng = centroid ? centroid.lng : row.longitude;
95
+ // WorldMap skips non-finite points with an aggregated warn; dropping
96
+ // them here keeps that warn for genuinely malformed data.
97
+ if (lat == null || lng == null || !Number.isFinite(lat) || !Number.isFinite(lng)) continue;
98
+
99
+ const count = Number(row.count) || 0;
100
+ const label = isRegion
101
+ ? (row.region || 'Unknown region')
102
+ : (code ? countryName(code) || code : 'Unknown country');
103
+ const newCount = isRegion ? (row.new_region_count ?? 0) : (row.new_country_count ?? 0);
104
+ const newLabel = isRegion ? 'new region' : 'new country';
105
+
106
+ out.push({
107
+ id: isRegion ? `${code ?? '??'}:${row.region ?? ''}` : (code ?? `${lat},${lng}`),
108
+ lat,
109
+ lng,
110
+ size: scaleMarkerSize(count, max, SUMMARY_SIZE_RANGE),
111
+ tone: 'scale',
112
+ intensity: max > 0 ? count / max : 0,
113
+ label,
114
+ value: count,
115
+ detail: (
116
+ <>
117
+ <div>{count.toLocaleString()} login{count === 1 ? '' : 's'}</div>
118
+ {newCount > 0 && (
119
+ <div className="lm-tip-chips">
120
+ <span className="chip chip-warning">{newCount} {newLabel}</span>
121
+ </div>
122
+ )}
123
+ {!isRegion && !drilling && <div className="dim lm-tip-hint">Double-click to drill into regions</div>}
124
+ </>
125
+ ),
126
+ data: { countryCode: code, region: row.region ?? null, isRegion, count },
127
+ });
128
+ }
129
+ return out;
130
+ }
131
+
132
+ function listMarkers(
133
+ rows: readonly LoginEventRow[],
134
+ opts: { scoped: boolean; onOpenUser?: (id: number) => void },
135
+ ): WorldMapMarker<LoginEventRow>[] {
136
+ const out: WorldMapMarker<LoginEventRow>[] = [];
137
+ for (const row of rows) {
138
+ const { latitude: lat, longitude: lng } = row;
139
+ if (lat == null || lng == null || !Number.isFinite(lat) || !Number.isFinite(lng)) continue;
140
+ const where = [row.city, row.region, row.country_code].filter(Boolean).join(', ');
141
+ out.push({
142
+ id: `login-${row.id}`,
143
+ lat,
144
+ lng,
145
+ size: LIST_MARKER_SIZE,
146
+ tone: loginRiskTone(row),
147
+ legendKey: loginRiskLegendKey(row),
148
+ label: where || 'Unknown location',
149
+ detail: (
150
+ <>
151
+ {row.ip_address && <div><code>{row.ip_address}</code></div>}
152
+ <div className="dim">{fmt.datetime(row.created)}</div>
153
+ {row.source && <div className="dim">via {row.source}</div>}
154
+ {(row.is_new_country || row.is_new_region) && (
155
+ <div className="lm-tip-chips">
156
+ {row.is_new_country && <span className="chip chip-danger">New country</span>}
157
+ {!row.is_new_country && row.is_new_region && <span className="chip chip-warning">New region</span>}
158
+ </div>
159
+ )}
160
+ {/* The "View user" button web-mojo built as an HTML string
161
+ with an unescaped display_name — a ReactNode slot here. */}
162
+ {!opts.scoped && row.user?.id != null && opts.onOpenUser && (
163
+ <button
164
+ type="button"
165
+ className="btn btn-compact lm-tip-user"
166
+ onClick={(e) => { e.stopPropagation(); opts.onOpenUser?.(row.user!.id); }}
167
+ >
168
+ <i className="bi bi-person" /> {row.user.display_name || row.user.username || 'View user'}
169
+ </button>
170
+ )}
171
+ </>
172
+ ),
173
+ data: row,
174
+ });
175
+ }
176
+ return out;
177
+ }
178
+
179
+ export function LoginLocationMap({
180
+ userId = null,
181
+ height = 360,
182
+ drStart = null,
183
+ drEnd = null,
184
+ defaultMode = 'summary',
185
+ onCountrySelect,
186
+ onOpenUser,
187
+ onOpenLogin,
188
+ showModeToggle = true,
189
+ enabled = true,
190
+ className = '',
191
+ }: LoginLocationMapProps) {
192
+ const [mode, setMode] = useState<LoginMapMode>(defaultMode);
193
+ const [drillCountry, setDrillCountry] = useState<string | null>(null);
194
+ const scoped = Number.isInteger(userId);
195
+
196
+ const summary = useLoginLocationSummary({
197
+ userId,
198
+ countryCode: drillCountry,
199
+ drStart,
200
+ drEnd,
201
+ enabled: enabled && mode === 'summary',
202
+ });
203
+ const list = useLoginLocationList({
204
+ userId,
205
+ drStart,
206
+ drEnd,
207
+ size: 500,
208
+ enabled: enabled && mode === 'list',
209
+ });
210
+
211
+ const summaryRows = summary.data ?? [];
212
+ const listRows = list.data?.rows ?? [];
213
+ // Coastlines arrive in their own chunk; until then WorldMap draws the
214
+ // graticule fallback, which every element of this surface is legible on.
215
+ const land = useWorldLand();
216
+
217
+ const markers = useMemo<WorldMapMarker<LoginMarkerData>[]>(() => (
218
+ mode === 'summary'
219
+ ? summaryMarkers(summaryRows, drillCountry != null) as WorldMapMarker<LoginMarkerData>[]
220
+ : listMarkers(listRows, { scoped, onOpenUser }) as WorldMapMarker<LoginMarkerData>[]
221
+ ), [mode, summaryRows, listRows, drillCountry, scoped, onOpenUser]);
222
+
223
+ const pending = mode === 'summary' ? summary.isPending : list.isPending;
224
+ const error = mode === 'summary' ? summary.error : list.error;
225
+
226
+ // The status line is the source's `setStatus()`, and it carries the
227
+ // information the map itself cannot: logins with no country never reach
228
+ // these endpoints at all (`/summary` excludes null/empty country_code,
229
+ // and private-range logins carry no coordinates), so a silent absence
230
+ // would otherwise read as missing data.
231
+ const status: ReactNode = (() => {
232
+ if (!enabled) return 'You do not have permission to read login geography.';
233
+ if (error) return <span className="text-bad">{error.message}</span>;
234
+ if (pending) return 'Loading locations…';
235
+ if (mode === 'summary') {
236
+ const total = summaryRows.reduce((sum, row) => sum + (Number(row.count) || 0), 0);
237
+ if (!summaryRows.length) return 'No login locations found.';
238
+ const noun = drillCountry ? 'region' : 'country';
239
+ const n = summaryRows.length;
240
+ return `${total.toLocaleString()} login${total === 1 ? '' : 's'} across ${n} ${noun}${n === 1 ? '' : drillCountry ? 's' : 'ies'}`;
241
+ }
242
+ const plotted = markers.length;
243
+ const missing = listRows.length - plotted;
244
+ if (!plotted && !missing) return 'No login events with location data found.';
245
+ return `${plotted.toLocaleString()} login${plotted === 1 ? '' : 's'} plotted`
246
+ + (missing > 0 ? ` · ${missing.toLocaleString()} without coordinates (see the Logins tab)` : '');
247
+ })();
248
+
249
+ const switchMode = (next: LoginMapMode) => {
250
+ if (next === mode) return;
251
+ setDrillCountry(null);
252
+ setMode(next);
253
+ };
254
+
255
+ return (
256
+ <div className={`login-location-map${className ? ` ${className}` : ''}`}>
257
+ <div className="lm-toolbar">
258
+ {drillCountry && (
259
+ <div className="lm-drill">
260
+ <button className="btn btn-compact" onClick={() => setDrillCountry(null)}>
261
+ <i className="bi bi-arrow-left" /> All countries
262
+ </button>
263
+ <span className="dim">Regions in {countryName(drillCountry) || drillCountry}</span>
264
+ </div>
265
+ )}
266
+ {showModeToggle && (
267
+ <div className="lm-modes" role="group" aria-label="Map data mode">
268
+ <button
269
+ type="button"
270
+ className={`btn btn-compact${mode === 'summary' ? ' btn-primary' : ''}`}
271
+ onClick={() => switchMode('summary')}
272
+ title="Aggregated by country"
273
+ >
274
+ <i className="bi bi-globe-americas" /> Summary
275
+ </button>
276
+ <button
277
+ type="button"
278
+ className={`btn btn-compact${mode === 'list' ? ' btn-primary' : ''}`}
279
+ onClick={() => switchMode('list')}
280
+ title="Every login"
281
+ >
282
+ <i className="bi bi-pin-map" /> Every login
283
+ </button>
284
+ </div>
285
+ )}
286
+ </div>
287
+
288
+ <WorldMap<LoginMarkerData>
289
+ markers={markers}
290
+ height={height}
291
+ land={land}
292
+ loading={pending}
293
+ status={status}
294
+ emptyText={mode === 'summary' ? 'No login locations to plot' : 'No plottable login events'}
295
+ showLegend={mode === 'list'}
296
+ legend={mode === 'list' ? LOGIN_RISK_LEGEND : undefined}
297
+ // Re-fit after a drill so a country's regions fill the frame
298
+ // (the source's post-drillDown fitBounds()).
299
+ fit={mode === 'summary' && drillCountry ? 'markers' : 'none'}
300
+ onMarkerClick={(marker) => {
301
+ const data = marker.data;
302
+ if (mode === 'list') {
303
+ const row = data as LoginEventRow | undefined;
304
+ if (row?.id != null) onOpenLogin?.(row.id);
305
+ return;
306
+ }
307
+ const summaryData = data as LoginSummaryMarkerData | undefined;
308
+ if (summaryData?.countryCode) onCountrySelect?.(summaryData.countryCode);
309
+ }}
310
+ onMarkerDoubleClick={(marker) => {
311
+ // Drill-down is summary-mode + country-markers only —
312
+ // exactly the source's `if (!data || data._isRegion) return`.
313
+ if (mode !== 'summary' || drillCountry) return;
314
+ const data = marker.data as LoginSummaryMarkerData | undefined;
315
+ if (!data || data.isRegion || !data.countryCode) return;
316
+ setDrillCountry(data.countryCode);
317
+ }}
318
+ />
319
+ </div>
320
+ );
321
+ }
@@ -0,0 +1,162 @@
1
+ // Login Locations — the UserDeviceLocationTablePage port: a Map tab over the
2
+ // login-location aggregation, and a Logins tab over the raw events.
3
+ //
4
+ // Clicking a country on the map applies `country_code` to the Logins tab
5
+ // through `<ModelTable key={…} defaultParams={…}>` — an honest REMOUNT rather
6
+ // than reaching into the params store from outside. The params store stays
7
+ // the single source of truth for table state; the key change is what makes
8
+ // the new default land.
9
+ import { useState } from 'react';
10
+ import { ModelTable, fmt, groupByDay, Badge, type Column, type FilterDef, type Preset } from '../../../ui';
11
+ import { useCan } from '../../../client';
12
+ import { COUNTRY_OPTIONS, countryName } from '../../../charts';
13
+ import { countryFlag } from '../geoip/models';
14
+ import { LoginLocationMap } from './LoginLocationMap';
15
+ import { showLoginEventDetail } from './LoginEventDetail';
16
+ import { showUserDeviceDetailByDuid } from './UserDeviceDetail';
17
+ import { LOGIN_SUMMARY_PERMS, LoginEventModel, type LoginEventRow } from './models';
18
+
19
+ const COLUMNS: Column<LoginEventRow>[] = [
20
+ { key: 'created', label: 'Date', sortable: true, hideable: false, render: (row) => fmt.datetime(row.created) },
21
+ { key: 'user', label: 'User', sortable: true, render: (row) => row.user?.display_name || row.user?.username || '—' },
22
+ { key: 'ip_address', label: 'IP', sortable: true, render: (row) => <code>{row.ip_address || '—'}</code> },
23
+ { key: 'city', label: 'City', render: (row) => row.city || '—' },
24
+ { key: 'region', label: 'Region', render: (row) => row.region || '—' },
25
+ {
26
+ key: 'country_code', label: 'Country', sortable: true,
27
+ render: (row) => {
28
+ if (!row.country_code) return '—';
29
+ const flag = countryFlag(row.country_code);
30
+ return <>{flag && <span className="geoip-flag">{flag}</span>}{row.country_code}</>;
31
+ },
32
+ },
33
+ { key: 'source', label: 'Source', sortable: true, render: (row) => row.source || '—' },
34
+ {
35
+ key: 'is_new_country', label: 'New', sortable: true, align: 'center',
36
+ render: (row) => row.is_new_country
37
+ ? <Badge tone="danger">Country</Badge>
38
+ : row.is_new_region ? <Badge tone="warning">Region</Badge> : <span className="dim">—</span>,
39
+ },
40
+ ];
41
+
42
+ const FILTERS: FilterDef[] = [
43
+ { key: 'created', label: 'Date', type: 'daterange' },
44
+ { key: 'country_code', label: 'Country', type: 'select', options: [...COUNTRY_OPTIONS] },
45
+ { key: 'region', label: 'Region', type: 'text' },
46
+ { key: 'city', label: 'City', type: 'text' },
47
+ { key: 'ip_address', label: 'IP address', type: 'text' },
48
+ { key: 'source', label: 'Source', type: 'text' },
49
+ { key: 'user', label: 'User ID', type: 'number', lookup: 'exact', placeholder: 'e.g. 1' },
50
+ { key: 'is_new_country', label: 'New country', type: 'boolean' },
51
+ { key: 'is_new_region', label: 'New region', type: 'boolean' },
52
+ ];
53
+
54
+ function presets(): Preset[] {
55
+ const day = (offsetDays: number) => new Date(Date.now() - offsetDays * 86400000).toISOString().slice(0, 10);
56
+ return [
57
+ { key: 'all', label: 'All', params: {} },
58
+ { key: 'new-country', label: 'New country', params: { is_new_country: 'true' } },
59
+ { key: 'recent', label: 'Last 30 days', params: { dr_field: 'created', dr_start: day(30), dr_end: day(0) } },
60
+ ];
61
+ }
62
+
63
+ export interface LoginLocationsPageProps {
64
+ /** Cross-link seam for the consuming app's user-detail surface. */
65
+ onOpenUser?: (userId: number) => void;
66
+ }
67
+
68
+ export function LoginLocationsPage({ onOpenUser }: LoginLocationsPageProps = {}) {
69
+ const [tab, setTab] = useState<'map' | 'logins'>('map');
70
+ const [country, setCountry] = useState<string | null>(null);
71
+ const canSummary = useCan(LOGIN_SUMMARY_PERMS).can;
72
+ const grouping = groupByDay<LoginEventRow>('created');
73
+
74
+ const openLogin = (id: number) => showLoginEventDetail(id, {
75
+ onOpenUser,
76
+ onOpenDeviceByDuid: showUserDeviceDetailByDuid,
77
+ });
78
+
79
+ return (
80
+ <div className="login-locations-page">
81
+ <div className="panel panel-pad ll-head">
82
+ <div>
83
+ <div className="eyebrow">Security · Devices &amp; Logins</div>
84
+ <h2 className="panel-title">Login Locations</h2>
85
+ <p className="dim">Where every account signed in from, and which of those places are new.</p>
86
+ </div>
87
+ <div className="us-tabs" role="tablist">
88
+ <button
89
+ type="button" role="tab" aria-selected={tab === 'map'}
90
+ className={`us-tab${tab === 'map' ? ' us-tab-active' : ''}`}
91
+ onClick={() => setTab('map')}
92
+ >
93
+ Map
94
+ </button>
95
+ <button
96
+ type="button" role="tab" aria-selected={tab === 'logins'}
97
+ className={`us-tab${tab === 'logins' ? ' us-tab-active' : ''}`}
98
+ onClick={() => setTab('logins')}
99
+ >
100
+ Logins
101
+ </button>
102
+ </div>
103
+ </div>
104
+
105
+ {tab === 'map' && (
106
+ <div className="panel panel-pad">
107
+ <LoginLocationMap
108
+ height={420}
109
+ enabled={canSummary}
110
+ onOpenUser={onOpenUser}
111
+ onOpenLogin={openLogin}
112
+ onCountrySelect={(code) => { setCountry(code); setTab('logins'); }}
113
+ />
114
+ {!canSummary && (
115
+ <p className="dim-italic">
116
+ Aggregated login geography needs a system-level users or security grant.
117
+ </p>
118
+ )}
119
+ </div>
120
+ )}
121
+
122
+ {tab === 'logins' && (
123
+ <>
124
+ {country && (
125
+ <div className="ll-scope">
126
+ <span className="dim">Scoped to</span>
127
+ <Badge tone="info">{countryFlag(country)} {countryName(country) || country}</Badge>
128
+ <button className="btn btn-compact" onClick={() => setCountry(null)}>
129
+ <i className="bi bi-x-lg" /> Clear
130
+ </button>
131
+ </div>
132
+ )}
133
+ {/* The key is the remount: a new country means a new table
134
+ identity, so `defaultParams` genuinely applies instead
135
+ of being ignored as a stale initial value. */}
136
+ <ModelTable<LoginEventRow>
137
+ key={country ?? 'all'}
138
+ model={LoginEventModel}
139
+ eyebrow="Security · Devices & Logins"
140
+ title="Logins"
141
+ searchPlaceholder="Search IP, country, region, or city"
142
+ columns={COLUMNS}
143
+ filters={FILTERS}
144
+ presets={presets()}
145
+ // Only real filter keys go here — `defaultParams`
146
+ // entries render as clearable pills, so a `graph`
147
+ // default would show up as "Graph: list".
148
+ defaultParams={country ? { country_code: country } : undefined}
149
+ defaultSort="-created"
150
+ {...grouping}
151
+ groupHeaderStyle="band"
152
+ columnChooser
153
+ persistState
154
+ persistKey="admin:devices:logins"
155
+ exportFormats={['csv', 'json']}
156
+ onRowClick={(row) => openLogin(row.id)}
157
+ />
158
+ </>
159
+ )}
160
+ </div>
161
+ );
162
+ }