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,199 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import {
3
+ defineModel, mojoCall,
4
+ type Params,
5
+ } from '../../client';
6
+ import { SECURITY_VIEW_PERMS } from '../security-permissions';
7
+ import { sanitizeIncidentRow } from '../incidents/sanitize';
8
+
9
+ /** Bouncer data is global. `sys.` prevents an active group grant from opening it. */
10
+ export const BOUNCER_VIEW_PERMS = [
11
+ 'sys.manage_users',
12
+ 'sys.view_security',
13
+ 'sys.manage_security',
14
+ 'sys.security',
15
+ 'sys.users',
16
+ ];
17
+
18
+ export const BOUNCER_MANAGE_PERMS = [
19
+ 'sys.manage_users',
20
+ 'sys.manage_security',
21
+ 'sys.security',
22
+ 'sys.users',
23
+ ];
24
+
25
+ /** Incident rows have their own, narrower model gate. */
26
+ export const BOUNCER_INCIDENT_VIEW_PERMS = SECURITY_VIEW_PERMS;
27
+
28
+ export type BouncerStage = 'assess' | 'submit' | 'event';
29
+ export type BouncerDecision = 'allow' | 'monitor' | 'block' | 'log';
30
+ export type BouncerRiskTier = 'unknown' | 'low' | 'medium' | 'high' | 'blocked';
31
+ export type BotSignatureType =
32
+ | 'ip'
33
+ | 'subnet_24'
34
+ | 'subnet_16'
35
+ | 'user_agent'
36
+ | 'fingerprint'
37
+ | 'signal_set';
38
+ export type BotSignatureSource = 'auto' | 'manual';
39
+
40
+ export interface BouncerGeoIp {
41
+ id: number;
42
+ ip_address: string;
43
+ country_code: string | null;
44
+ country_name: string | null;
45
+ region: string | null;
46
+ city: string | null;
47
+ asn: string | null;
48
+ asn_org: string | null;
49
+ isp: string | null;
50
+ threat_level: string | null;
51
+ is_tor: boolean;
52
+ is_vpn: boolean;
53
+ is_proxy: boolean;
54
+ is_cloud: boolean;
55
+ is_datacenter: boolean;
56
+ is_known_attacker: boolean;
57
+ is_known_abuser: boolean;
58
+ [field: string]: unknown;
59
+ }
60
+
61
+ export interface BouncerDeviceRow {
62
+ id: number;
63
+ muid: string;
64
+ duid: string;
65
+ msid?: string;
66
+ fingerprint_id?: string | null;
67
+ risk_tier: BouncerRiskTier;
68
+ event_count: number;
69
+ block_count: number;
70
+ last_seen_ip: string | null;
71
+ linked_muids?: string[];
72
+ first_seen?: number;
73
+ last_seen: number;
74
+ }
75
+
76
+ export interface BouncerSignalRow {
77
+ id: number;
78
+ device?: BouncerDeviceRow | number | null;
79
+ muid: string;
80
+ duid?: string;
81
+ msid: string;
82
+ mtab?: string;
83
+ session_id?: string;
84
+ stage: BouncerStage;
85
+ ip_address: string | null;
86
+ page_type: string;
87
+ raw_signals?: Record<string, unknown>;
88
+ server_signals?: Record<string, unknown>;
89
+ risk_score: number;
90
+ decision: BouncerDecision;
91
+ triggered_signals?: string[];
92
+ geo_ip?: BouncerGeoIp | null;
93
+ created: number;
94
+ }
95
+
96
+ export interface BotSignatureRow {
97
+ id: number;
98
+ sig_type: BotSignatureType;
99
+ value: string;
100
+ source: BotSignatureSource;
101
+ confidence: number;
102
+ hit_count: number;
103
+ block_count?: number;
104
+ expires_at: number | null;
105
+ is_active: boolean;
106
+ notes?: string;
107
+ created?: number;
108
+ modified: number;
109
+ }
110
+
111
+ export interface BouncerIncidentRow {
112
+ id: number;
113
+ created: number;
114
+ priority: number;
115
+ state: string;
116
+ status: string;
117
+ scope: string;
118
+ category: string;
119
+ country_code: string | null;
120
+ title: string | null;
121
+ details: string | null;
122
+ model_name: string | null;
123
+ model_id: number | null;
124
+ source_ip: string | null;
125
+ hostname: string | null;
126
+ metadata: Record<string, unknown>;
127
+ group_id: number | null;
128
+ }
129
+
130
+ /** Strip a sensitive backend-only field before any list/detail/save cache write. */
131
+ export function sanitizeBouncerSignalRow(row: BouncerSignalRow): BouncerSignalRow {
132
+ const safe = { ...row } as BouncerSignalRow & Record<string, unknown>;
133
+ delete safe.token_nonce;
134
+ return safe;
135
+ }
136
+
137
+ function listGraph(params: Params): Params {
138
+ const safe = { ...params };
139
+ delete safe.graph;
140
+ return { ...safe, graph: 'list' };
141
+ }
142
+
143
+ export const BouncerSignalModel = defineModel<BouncerSignalRow>({
144
+ name: 'bouncer_signal',
145
+ endpoint: '/api/account/bouncer/signal',
146
+ permissions: { view: BOUNCER_VIEW_PERMS },
147
+ normalizeListParams: listGraph,
148
+ sanitizeRow: sanitizeBouncerSignalRow,
149
+ });
150
+
151
+ export const BouncerDeviceModel = defineModel<BouncerDeviceRow>({
152
+ name: 'bouncer_device',
153
+ endpoint: '/api/account/bouncer/device',
154
+ permissions: { view: BOUNCER_VIEW_PERMS, manage: BOUNCER_MANAGE_PERMS },
155
+ normalizeListParams: listGraph,
156
+ });
157
+
158
+ export const BotSignatureModel = defineModel<BotSignatureRow>({
159
+ name: 'bot_signature',
160
+ endpoint: '/api/account/bouncer/signature',
161
+ permissions: { view: BOUNCER_VIEW_PERMS, manage: BOUNCER_MANAGE_PERMS },
162
+ normalizeListParams: listGraph,
163
+ });
164
+
165
+ export const BouncerIncidentModel = defineModel<BouncerIncidentRow>({
166
+ name: 'bouncer_incident',
167
+ endpoint: '/api/incident/incident',
168
+ permissions: { view: BOUNCER_INCIDENT_VIEW_PERMS },
169
+ sanitizeRow: sanitizeIncidentRow,
170
+ });
171
+
172
+ /** Graph is part of the key so a sparse default record cannot satisfy detail. */
173
+ export function bouncerSignalDetailKey(id: number) {
174
+ return [BouncerSignalModel.endpoint, 'one', id, { graph: 'detail' }] as const;
175
+ }
176
+
177
+ export function useBouncerSignalDetail(id: number | null) {
178
+ return useQuery({
179
+ queryKey: id == null
180
+ ? [BouncerSignalModel.endpoint, 'one', null, { graph: 'detail' }] as const
181
+ : bouncerSignalDetailKey(id),
182
+ queryFn: async () => {
183
+ const body = await mojoCall(`${BouncerSignalModel.endpoint}/${id!}`, {
184
+ params: { graph: 'detail' },
185
+ });
186
+ return sanitizeBouncerSignalRow(body.data as BouncerSignalRow);
187
+ },
188
+ enabled: id != null,
189
+ });
190
+ }
191
+
192
+ export const BOUNCER_SIGNATURE_TYPES: ReadonlyArray<{ value: BotSignatureType; label: string }> = [
193
+ { value: 'ip', label: 'IP Address' },
194
+ { value: 'subnet_24', label: 'Subnet /24' },
195
+ { value: 'subnet_16', label: 'Subnet /16' },
196
+ { value: 'user_agent', label: 'User Agent' },
197
+ { value: 'fingerprint', label: 'Browser Fingerprint' },
198
+ { value: 'signal_set', label: 'Signal Set (Campaign)' },
199
+ ];
@@ -0,0 +1,182 @@
1
+ import { useQueryClient } from '@tanstack/react-query';
2
+ import {
3
+ Badge, DetailView, Eyebrow, FlatRow, JsonBlock, ModelTable,
4
+ fmt, groupByDay, modal,
5
+ type Column, type FilterDef, type Tone,
6
+ } from '../../ui';
7
+ import {
8
+ BouncerSignalModel, bouncerSignalDetailKey, useBouncerSignalDetail,
9
+ type BouncerDecision, type BouncerSignalRow,
10
+ } from './models';
11
+
12
+ export function bouncerDecisionTone(decision: BouncerDecision): Tone {
13
+ if (decision === 'allow') return 'success';
14
+ if (decision === 'monitor') return 'warning';
15
+ if (decision === 'block') return 'danger';
16
+ if (decision === 'log') return 'info';
17
+ return 'muted';
18
+ }
19
+
20
+ export function bouncerRiskTone(score: number): Tone {
21
+ if (score >= 80) return 'danger';
22
+ if (score >= 50) return 'warning';
23
+ if (score >= 20) return 'info';
24
+ return 'success';
25
+ }
26
+
27
+ function EmbeddedDevice({ signal }: { signal: BouncerSignalRow }) {
28
+ const device = signal.device && typeof signal.device === 'object' ? signal.device : null;
29
+ if (!device) return <p className="dim-italic">No device record was linked to this assessment.</p>;
30
+ return (
31
+ <>
32
+ <Eyebrow>Linked device</Eyebrow>
33
+ <FlatRow label="MUID"><code>{device.muid}</code></FlatRow>
34
+ <FlatRow label="DUID"><code>{device.duid || '—'}</code></FlatRow>
35
+ <FlatRow label="Fingerprint"><code>{device.fingerprint_id || '—'}</code></FlatRow>
36
+ <FlatRow label="Risk tier"><Badge tone={device.risk_tier === 'low' ? 'success' : device.risk_tier === 'medium' ? 'warning' : device.risk_tier === 'unknown' ? 'muted' : 'danger'}>{device.risk_tier}</Badge></FlatRow>
37
+ <FlatRow label="Activity">{device.event_count} events · {device.block_count} blocks</FlatRow>
38
+ <FlatRow label="Last seen">{fmt.datetime(device.last_seen)}</FlatRow>
39
+ </>
40
+ );
41
+ }
42
+
43
+ function GeoIp({ signal }: { signal: BouncerSignalRow }) {
44
+ const geo = signal.geo_ip;
45
+ if (!geo) return <p className="dim-italic">No GeoIP record was linked to this assessment.</p>;
46
+ const flags = [
47
+ geo.is_tor && 'Tor', geo.is_vpn && 'VPN', geo.is_proxy && 'Proxy',
48
+ geo.is_cloud && 'Cloud', geo.is_datacenter && 'Datacenter',
49
+ geo.is_known_attacker && 'Known attacker', geo.is_known_abuser && 'Known abuser',
50
+ ].filter((flag): flag is string => Boolean(flag));
51
+ return (
52
+ <>
53
+ <Eyebrow>GeoIP</Eyebrow>
54
+ <FlatRow label="Location">{[geo.city, geo.region, geo.country_name || geo.country_code].filter(Boolean).join(', ') || '—'}</FlatRow>
55
+ <FlatRow label="Network">{geo.asn_org || geo.isp || geo.asn || '—'}</FlatRow>
56
+ <FlatRow label="Threat level"><Badge>{geo.threat_level || 'unknown'}</Badge></FlatRow>
57
+ <FlatRow label="Signals">
58
+ <span className="bouncer-token-list">
59
+ {flags.length ? flags.map((flag) => <Badge key={flag} tone="warning">{flag}</Badge>) : <span className="dim">None</span>}
60
+ </span>
61
+ </FlatRow>
62
+ </>
63
+ );
64
+ }
65
+
66
+ export function BouncerSignalDetail({ id, onClose }: { id: number; onClose: () => void }) {
67
+ const queryClient = useQueryClient();
68
+ const { data: signal, isPending, error } = useBouncerSignalDetail(id);
69
+ if (isPending) return <div className="modal-pad dim">Loading signal assessment…</div>;
70
+ if (!signal || error) return <div className="modal-pad text-bad">{error?.message ?? 'Signal assessment not found'}</div>;
71
+
72
+ return (
73
+ <DetailView
74
+ icon="bi-activity"
75
+ title="Signal Assessment"
76
+ subtitle={`${signal.ip_address || 'Unknown IP'} · ${fmt.datetime(signal.created)}`}
77
+ chips={[
78
+ { text: signal.decision.toUpperCase(), tone: bouncerDecisionTone(signal.decision) },
79
+ { text: `Risk ${signal.risk_score}`, tone: bouncerRiskTone(signal.risk_score) },
80
+ { text: signal.stage, tone: 'muted' },
81
+ ]}
82
+ sections={[
83
+ {
84
+ key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
85
+ <>
86
+ <Eyebrow>Assessment</Eyebrow>
87
+ <FlatRow label="Decision"><Badge tone={bouncerDecisionTone(signal.decision)}>{signal.decision.toUpperCase()}</Badge></FlatRow>
88
+ <FlatRow label="Risk score"><strong className={`bouncer-risk bouncer-risk-${bouncerRiskTone(signal.risk_score)}`}>{signal.risk_score}</strong></FlatRow>
89
+ <FlatRow label="Stage">{signal.stage}</FlatRow>
90
+ <FlatRow label="Page type">{signal.page_type || '—'}</FlatRow>
91
+ <FlatRow label="IP address"><code>{signal.ip_address || '—'}</code></FlatRow>
92
+ <FlatRow label="Created">{fmt.datetime(signal.created)}</FlatRow>
93
+ <Eyebrow>Identity</Eyebrow>
94
+ <FlatRow label="MUID"><code>{signal.muid || '—'}</code></FlatRow>
95
+ <FlatRow label="DUID"><code>{signal.duid || '—'}</code></FlatRow>
96
+ <FlatRow label="MSID"><code>{signal.msid || '—'}</code></FlatRow>
97
+ <FlatRow label="MTAB"><code>{signal.mtab || '—'}</code></FlatRow>
98
+ <FlatRow label="Session"><code>{signal.session_id || '—'}</code></FlatRow>
99
+ <Eyebrow>Triggered signals</Eyebrow>
100
+ <div className="bouncer-token-list">
101
+ {signal.triggered_signals?.length
102
+ ? signal.triggered_signals.map((name) => <Badge key={name} tone="warning">{name}</Badge>)
103
+ : <span className="dim-italic">No signals triggered.</span>}
104
+ </div>
105
+ </>
106
+ ),
107
+ },
108
+ { key: 'device', label: 'Device', icon: 'bi-fingerprint', render: () => <EmbeddedDevice signal={signal} /> },
109
+ { key: 'geoip', label: 'GeoIP', icon: 'bi-geo-alt', render: () => <GeoIp signal={signal} /> },
110
+ { key: 'raw', label: 'Raw Signals', icon: 'bi-code-square', render: () => <JsonBlock label="Client signal payload" value={signal.raw_signals ?? {}} defaultOpen /> },
111
+ { key: 'server', label: 'Server Signals', icon: 'bi-hdd-stack', render: () => <JsonBlock label="Server signal payload" value={signal.server_signals ?? {}} defaultOpen /> },
112
+ ]}
113
+ contextMenu={[
114
+ {
115
+ label: 'Refresh', icon: 'bi-arrow-clockwise', onSelect: () => {
116
+ void queryClient.invalidateQueries({ queryKey: bouncerSignalDetailKey(signal.id) });
117
+ },
118
+ },
119
+ ]}
120
+ onClose={onClose}
121
+ />
122
+ );
123
+ }
124
+
125
+ export function showBouncerSignalDetail(id: number): void {
126
+ void modal.detail((close) => (
127
+ <BouncerSignalDetail id={id} onClose={() => close(null)} />
128
+ ));
129
+ }
130
+
131
+ const SIGNAL_COLUMNS: Column<BouncerSignalRow>[] = [
132
+ { key: 'created', label: 'Timestamp', sortable: true, hideable: false, render: (row) => fmt.datetime(row.created) },
133
+ { key: 'ip_address', label: 'IP', render: (row) => <code>{row.ip_address || '—'}</code> },
134
+ { key: 'decision', label: 'Decision', render: (row) => <Badge tone={bouncerDecisionTone(row.decision)}>{row.decision.toUpperCase()}</Badge> },
135
+ { key: 'risk_score', label: 'Risk', sortable: true, align: 'end', render: (row) => <strong className={`bouncer-risk bouncer-risk-${bouncerRiskTone(row.risk_score)}`}>{row.risk_score}</strong> },
136
+ { key: 'page_type', label: 'Page', render: (row) => row.page_type || '—' },
137
+ { key: 'stage', label: 'Stage', render: (row) => <Badge tone="muted">{row.stage}</Badge> },
138
+ { key: 'muid', label: 'Device', render: (row) => <code title={row.muid}>{fmt.truncateMiddle(row.muid, 16)}</code> },
139
+ ];
140
+
141
+ const SIGNAL_FILTERS: FilterDef[] = [
142
+ { key: 'created', label: 'Created', type: 'daterange' },
143
+ { key: 'ip_address', label: 'IP address', type: 'text' },
144
+ { key: 'decision', label: 'Decision', type: 'select', options: [
145
+ { value: 'allow', label: 'Allow' }, { value: 'monitor', label: 'Monitor' },
146
+ { value: 'block', label: 'Block' }, { value: 'log', label: 'Log' },
147
+ ] },
148
+ { key: 'risk_score', label: 'Minimum risk', type: 'number', lookup: 'gte' },
149
+ { key: 'page_type', label: 'Page type', type: 'text' },
150
+ { key: 'stage', label: 'Stage', type: 'select', options: [
151
+ { value: 'assess', label: 'Assess' }, { value: 'submit', label: 'Submit' }, { value: 'event', label: 'Event' },
152
+ ] },
153
+ ];
154
+
155
+ const signalGroups = groupByDay<BouncerSignalRow>('created');
156
+
157
+ export function BouncerSignalsPage() {
158
+ return (
159
+ <ModelTable<BouncerSignalRow>
160
+ model={BouncerSignalModel}
161
+ eyebrow="Security · Bouncer"
162
+ title="Signal Decisions"
163
+ searchPlaceholder="Search MUID, DUID, IP, or decision"
164
+ columns={SIGNAL_COLUMNS}
165
+ filters={SIGNAL_FILTERS}
166
+ presets={[
167
+ { key: 'all', label: 'All', params: {} },
168
+ { key: 'blocked', label: 'Blocked', params: { decision: 'block' } },
169
+ { key: 'monitor', label: 'Monitor', params: { decision: 'monitor' } },
170
+ ]}
171
+ defaultSort="-created"
172
+ columnChooser
173
+ persistState
174
+ persistKey="admin:bouncer:signals"
175
+ exportFormats={['csv', 'json']}
176
+ onRowClick={(row) => showBouncerSignalDetail(row.id)}
177
+ groupBy={signalGroups.groupBy}
178
+ groupHeaderLabel={signalGroups.groupHeaderLabel}
179
+ groupHeaderStyle="mark"
180
+ />
181
+ );
182
+ }
@@ -0,0 +1,220 @@
1
+ import { useQueryClient } from '@tanstack/react-query';
2
+ import {
3
+ Badge, DetailView, Eyebrow, FlatRow, ModelTable, SchemaForm,
4
+ fmt, modal, toast,
5
+ type BatchAction, type Column, type Field, type FilterDef, type FormData,
6
+ } from '../../ui';
7
+ import { useCan } from '../../client';
8
+ import {
9
+ BOUNCER_MANAGE_PERMS, BOUNCER_SIGNATURE_TYPES, BotSignatureModel,
10
+ type BotSignatureRow, type BotSignatureType,
11
+ } from './models';
12
+
13
+ const SIGNATURE_FIELDS: Field[] = [
14
+ {
15
+ name: 'sig_type', type: 'select', label: 'Signature type', required: true, columns: 6,
16
+ options: BOUNCER_SIGNATURE_TYPES.map((option) => ({ ...option })),
17
+ },
18
+ {
19
+ name: 'value', type: 'text', label: 'Value', required: true, columns: 6,
20
+ help: 'The exact value or pattern evaluated by this signature type.',
21
+ },
22
+ {
23
+ name: 'confidence', type: 'text', label: 'Confidence (0–100)', required: true, columns: 6,
24
+ placeholder: '80',
25
+ },
26
+ {
27
+ name: 'expires_at', type: 'datetimepicker', label: 'Expires', columns: 6,
28
+ help: 'Optional for manual signatures; leave empty for no expiry.',
29
+ },
30
+ { name: 'notes', type: 'textarea', label: 'Notes' },
31
+ { name: 'is_active', type: 'switch', label: 'Active' },
32
+ ];
33
+
34
+ function signatureType(value: unknown): BotSignatureType {
35
+ const candidate = String(value ?? '');
36
+ const exact = BOUNCER_SIGNATURE_TYPES.find((option) => option.value === candidate);
37
+ if (!exact) throw new Error('Choose a valid signature type');
38
+ return exact.value;
39
+ }
40
+
41
+ function optionalEpoch(value: unknown): number | null {
42
+ if (value == null || value === '') return null;
43
+ const epoch = Number(value);
44
+ if (!Number.isFinite(epoch) || epoch <= 0) throw new Error('Choose a valid expiry');
45
+ return epoch;
46
+ }
47
+
48
+ function signatureChanges(data: FormData, creating: boolean): Record<string, unknown> {
49
+ const value = String(data.value ?? '').trim();
50
+ if (!value) throw new Error('Value is required');
51
+ const confidence = Number(String(data.confidence ?? '').trim());
52
+ if (!Number.isFinite(confidence) || confidence < 0 || confidence > 100) {
53
+ throw new Error('Confidence must be a number from 0 to 100');
54
+ }
55
+ return {
56
+ sig_type: signatureType(data.sig_type),
57
+ value,
58
+ confidence,
59
+ expires_at: optionalEpoch(data.expires_at),
60
+ notes: String(data.notes ?? ''),
61
+ is_active: data.is_active === true,
62
+ ...(creating ? { source: 'manual' as const } : {}),
63
+ };
64
+ }
65
+
66
+ function SignatureEditor({ row, close }: {
67
+ row?: BotSignatureRow;
68
+ close: (saved: boolean) => void;
69
+ }) {
70
+ const save = BotSignatureModel.useSave();
71
+ const creating = row == null;
72
+ const initial: FormData = creating
73
+ ? { sig_type: 'ip', confidence: 80, expires_at: null, notes: '', is_active: true }
74
+ : {
75
+ sig_type: row.sig_type,
76
+ value: row.value,
77
+ confidence: row.confidence,
78
+ expires_at: row.expires_at,
79
+ notes: row.notes ?? '',
80
+ is_active: row.is_active,
81
+ };
82
+ return (
83
+ <div className="modal-pad">
84
+ <h2 className="modal-title">{creating ? 'Create manual bot signature' : 'Edit bot signature'}</h2>
85
+ {!creating && (
86
+ <p className="dim">
87
+ Source <Badge tone={row.source === 'auto' ? 'info' : 'primary'}>{row.source.toUpperCase()}</Badge>
88
+ {' '}· hits {row.hit_count} · blocks {row.block_count ?? 0}
89
+ </p>
90
+ )}
91
+ <SchemaForm
92
+ fields={SIGNATURE_FIELDS}
93
+ initial={initial}
94
+ submitText={creating ? 'Create signature' : 'Save changes'}
95
+ onCancel={() => close(false)}
96
+ onSubmit={async (data) => {
97
+ await save.mutateAsync({
98
+ id: row?.id ?? null,
99
+ changes: signatureChanges(data, creating),
100
+ });
101
+ toast.success(creating ? 'Bot signature created' : 'Bot signature updated');
102
+ close(true);
103
+ }}
104
+ />
105
+ </div>
106
+ );
107
+ }
108
+
109
+ async function openSignatureEditor(row?: BotSignatureRow): Promise<void> {
110
+ await modal.open<boolean>((close) => <SignatureEditor row={row} close={close} />, { size: 'lg' });
111
+ }
112
+
113
+ function SignatureDetail({ row, onClose }: { row: BotSignatureRow; onClose: () => void }) {
114
+ return (
115
+ <DetailView
116
+ icon="bi-shield-check"
117
+ title={row.value}
118
+ subtitle={`Bot signature #${row.id}`}
119
+ chips={[
120
+ { text: row.sig_type, tone: 'info' },
121
+ { text: row.source.toUpperCase(), tone: row.source === 'auto' ? 'info' : 'primary' },
122
+ { text: row.is_active ? 'ACTIVE' : 'INACTIVE', tone: row.is_active ? 'success' : 'muted' },
123
+ ]}
124
+ sections={[
125
+ {
126
+ key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
127
+ <>
128
+ <Eyebrow>Signature</Eyebrow>
129
+ <FlatRow label="Type">{row.sig_type}</FlatRow>
130
+ <FlatRow label="Value"><code>{row.value}</code></FlatRow>
131
+ <FlatRow label="Source">{row.source}</FlatRow>
132
+ <FlatRow label="Confidence">{row.confidence}%</FlatRow>
133
+ <FlatRow label="Hits">{row.hit_count}</FlatRow>
134
+ <FlatRow label="Blocks">{row.block_count ?? 0}</FlatRow>
135
+ <FlatRow label="Expires">{row.expires_at == null ? 'Never' : fmt.datetime(row.expires_at)}</FlatRow>
136
+ <FlatRow label="Modified">{fmt.datetime(row.modified)}</FlatRow>
137
+ <Eyebrow>Notes</Eyebrow>
138
+ <p className={row.notes ? undefined : 'dim-italic'}>{row.notes || 'No notes.'}</p>
139
+ </>
140
+ ),
141
+ },
142
+ ]}
143
+ onClose={onClose}
144
+ />
145
+ );
146
+ }
147
+
148
+ const SIGNATURE_COLUMNS: Column<BotSignatureRow>[] = [
149
+ { key: 'sig_type', label: 'Type', sortable: true, render: (row) => <Badge tone="info">{row.sig_type}</Badge> },
150
+ { key: 'value', label: 'Value', hideable: false, render: (row) => <code title={row.value}>{fmt.truncate(row.value, 60)}</code> },
151
+ { key: 'source', label: 'Source', render: (row) => <Badge tone={row.source === 'auto' ? 'info' : 'primary'}>{row.source.toUpperCase()}</Badge> },
152
+ { key: 'confidence', label: 'Confidence', sortable: true, align: 'end', render: (row) => `${row.confidence}%` },
153
+ { key: 'hit_count', label: 'Hits', sortable: true, align: 'end' },
154
+ { key: 'is_active', label: 'Status', render: (row) => <Badge tone={row.is_active ? 'success' : 'muted'}>{row.is_active ? 'ON' : 'OFF'}</Badge> },
155
+ { key: 'expires_at', label: 'Expires', sortable: true, render: (row) => row.expires_at == null ? 'Never' : fmt.datetime(row.expires_at) },
156
+ ];
157
+
158
+ const SIGNATURE_FILTERS: FilterDef[] = [
159
+ { key: 'sig_type', label: 'Type', type: 'select', options: BOUNCER_SIGNATURE_TYPES.map((option) => ({ ...option })) },
160
+ { key: 'value', label: 'Value', type: 'text' },
161
+ { key: 'source', label: 'Source', type: 'select', options: [
162
+ { value: 'auto', label: 'Auto' }, { value: 'manual', label: 'Manual' },
163
+ ] },
164
+ { key: 'is_active', label: 'Status', type: 'boolean', trueLabel: 'Active', falseLabel: 'Inactive' },
165
+ { key: 'expires_at', label: 'Expires', type: 'daterange' },
166
+ ];
167
+
168
+ export function BotSignaturesPage() {
169
+ const queryClient = useQueryClient();
170
+ const save = BotSignatureModel.useSave();
171
+ const { can: canManage } = useCan(BOUNCER_MANAGE_PERMS);
172
+
173
+ const batchActions: BatchAction<BotSignatureRow>[] = canManage ? [
174
+ {
175
+ key: 'enable', label: 'Enable', icon: 'bi-check-circle',
176
+ run: (row) => save.mutateAsync({ id: row.id, changes: { is_active: true } }),
177
+ },
178
+ {
179
+ key: 'disable', label: 'Disable', icon: 'bi-pause-circle',
180
+ run: (row) => save.mutateAsync({ id: row.id, changes: { is_active: false } }),
181
+ },
182
+ ] : [];
183
+
184
+ const openRow = async (row: BotSignatureRow) => {
185
+ try {
186
+ const detailed = await BotSignatureModel.fetchOne(queryClient, row.id);
187
+ if (canManage) await openSignatureEditor(detailed);
188
+ else await modal.detail((close) => <SignatureDetail row={detailed} onClose={() => close(null)} />);
189
+ } catch (error) {
190
+ toast.error(error instanceof Error ? error.message : 'Failed to load bot signature');
191
+ }
192
+ };
193
+
194
+ return (
195
+ <ModelTable<BotSignatureRow>
196
+ model={BotSignatureModel}
197
+ eyebrow="Security · Bouncer"
198
+ title="Bot Signatures"
199
+ searchPlaceholder="Search signature type, value, or source"
200
+ columns={SIGNATURE_COLUMNS}
201
+ filters={SIGNATURE_FILTERS}
202
+ presets={[
203
+ { key: 'all', label: 'All', params: {} },
204
+ { key: 'active', label: 'Active', params: { is_active: 'true' } },
205
+ { key: 'manual', label: 'Manual', params: { source: 'manual' } },
206
+ { key: 'auto', label: 'Auto', params: { source: 'auto' } },
207
+ ]}
208
+ defaultSort="-modified"
209
+ columnChooser
210
+ persistState
211
+ persistKey="admin:bouncer:signatures"
212
+ exportFormats={['csv', 'json']}
213
+ selectable={canManage}
214
+ batchActions={batchActions}
215
+ addLabel="New Signature"
216
+ onAdd={canManage ? () => void openSignatureEditor() : undefined}
217
+ onRowClick={(row) => void openRow(row)}
218
+ />
219
+ );
220
+ }
@@ -0,0 +1,61 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { MetricsChart } from '../../charts';
3
+ import { useAuthSnapshot } from '../../client';
4
+ import { fmt } from '../../ui';
5
+ import {
6
+ CLOUDWATCH_GRANULARITIES, loadCloudWatchSeries,
7
+ type CloudWatchChartDefinition, type CloudWatchUnit,
8
+ } from './data';
9
+
10
+ function valueFormatter(unit: CloudWatchUnit): (value: number) => string {
11
+ if (unit === 'percent') return (value) => `${value.toLocaleString()}%`;
12
+ if (unit === 'bytes') return (value) => fmt.filesize(value);
13
+ if (unit === 'seconds') return (value) => `${value.toLocaleString()} s`;
14
+ return (value) => value.toLocaleString();
15
+ }
16
+
17
+ export function CloudWatchChart({ definition, slugs = [], refreshSignal }: {
18
+ definition: CloudWatchChartDefinition;
19
+ slugs?: string[];
20
+ refreshSignal?: unknown;
21
+ }) {
22
+ const auth = useAuthSnapshot();
23
+ return <MetricsChart
24
+ title={definition.title}
25
+ account={definition.account}
26
+ slugs={slugs}
27
+ apiParams={{ category: definition.category, stat: 'avg' }}
28
+ loadSeries={loadCloudWatchSeries}
29
+ seriesCacheKey={`admin-cloudwatch:${auth.uid ?? 'anonymous'}:${definition.account}:${definition.category}`}
30
+ preserveSeriesLabels
31
+ allowedGranularities={[...CLOUDWATCH_GRANULARITIES]}
32
+ refreshSignal={refreshSignal}
33
+ defaultRange="24h"
34
+ defaultGranularity="hours"
35
+ height={200}
36
+ valueFormatter={valueFormatter(definition.unit)}
37
+ showRefresh={false}
38
+ />;
39
+ }
40
+
41
+ export function LazyCloudWatchChart({ definition, slugs, refreshSignal, eager = false }: {
42
+ definition: CloudWatchChartDefinition;
43
+ slugs?: string[];
44
+ refreshSignal?: unknown;
45
+ eager?: boolean;
46
+ }) {
47
+ const ref = useRef<HTMLDivElement>(null);
48
+ const [mounted, setMounted] = useState(eager);
49
+ useEffect(() => {
50
+ if (mounted || !ref.current) return;
51
+ if (typeof IntersectionObserver === 'undefined') { setMounted(true); return; }
52
+ const observer = new IntersectionObserver((entries) => {
53
+ if (entries.some((entry) => entry.isIntersecting)) { setMounted(true); observer.disconnect(); }
54
+ }, { rootMargin: '240px' });
55
+ observer.observe(ref.current);
56
+ return () => observer.disconnect();
57
+ }, [mounted]);
58
+ return <div ref={ref} className="cloudwatch-chart-slot">
59
+ {mounted ? <CloudWatchChart definition={definition} slugs={slugs} refreshSignal={refreshSignal} /> : <div className="panel panel-pad cloudwatch-chart-placeholder"><span className="skel skel-block" /></div>}
60
+ </div>;
61
+ }